Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf31fc41e | ||
|
|
9fb5529c9e | ||
|
|
2d685e9a2a | ||
|
|
8db4368340 | ||
|
|
0a9e668436 |
@@ -9,7 +9,7 @@
|
||||
"name": "planning-with-files",
|
||||
"source": "./",
|
||||
"description": "Manus-style persistent markdown files for planning, progress tracking, and knowledge storage. Now with hooks integration and multi-language support (Arabic, German, Spanish, Chinese).",
|
||||
"version": "2.35.0"
|
||||
"version": "2.36.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "2.35.0",
|
||||
"version": "2.36.0",
|
||||
"description": "Manus-style persistent markdown files for planning, progress tracking, and knowledge storage. Works with Claude Code, Kiro, Clawd CLI, Gemini CLI, Cursor, Continue, Hermes, and 17+ AI coding assistants. Now with Arabic, German, Spanish, and Chinese (Simplified & Traditional) support.",
|
||||
"author": {
|
||||
"name": "OthmanAdi",
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CODEBUDDY_PLUGIN_ROOT:-$HOME/.codebuddy/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,29 @@ def cwd_from_payload(payload: dict[str, Any]) -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def session_id_from_payload(payload: dict[str, Any]) -> str | None:
|
||||
sid = payload.get("session_id")
|
||||
if isinstance(sid, str) and sid:
|
||||
return sid
|
||||
env_sid = os.environ.get("PWF_SESSION_ID", "")
|
||||
return env_sid if env_sid else None
|
||||
|
||||
|
||||
def is_session_attached(root: Path, session_id: str | None) -> bool:
|
||||
"""Return True if this session should receive plan context.
|
||||
|
||||
Legacy mode: if .planning/sessions/ does not exist, always return True so
|
||||
existing single-session users are not broken on upgrade.
|
||||
Isolation mode: return True only when the session has an attached sentinel.
|
||||
"""
|
||||
sessions_dir = root / ".planning" / "sessions"
|
||||
if not sessions_dir.exists():
|
||||
return True # legacy — no sessions dir means single-session setup
|
||||
if not session_id:
|
||||
return False # sessions dir exists but caller has no ID — stay silent
|
||||
return (sessions_dir / f"{session_id}.attached").exists()
|
||||
|
||||
|
||||
def emit_json(payload: dict[str, Any]) -> None:
|
||||
if not payload:
|
||||
return
|
||||
@@ -64,3 +88,4 @@ def main_guard(func) -> int:
|
||||
print(f"[planning-with-files hook] {exc}", file=sys.stderr)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#!/bin/bash
|
||||
# planning-with-files: Post-tool-use hook for Codex
|
||||
# Reused from the Cursor integration.
|
||||
|
||||
if [ -f task_plan.md ]; then
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
PLAN_FILE="${PLAN_DIR:+${PLAN_DIR}/}task_plan.md"
|
||||
|
||||
if [ -f "$PLAN_FILE" ]; then
|
||||
echo "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -7,6 +7,10 @@ import codex_hook_adapter as adapter
|
||||
def main() -> None:
|
||||
payload = adapter.load_payload()
|
||||
root = adapter.cwd_from_payload(payload)
|
||||
|
||||
if not adapter.is_session_attached(root, adapter.session_id_from_payload(payload)):
|
||||
return
|
||||
|
||||
stdout, _ = adapter.run_shell_script("post-tool-use.sh", root)
|
||||
if stdout:
|
||||
adapter.emit_json({"systemMessage": stdout})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/bin/bash
|
||||
# planning-with-files: Pre-tool-use hook for Codex
|
||||
# Reused from the Cursor integration.
|
||||
|
||||
PLAN_FILE="task_plan.md"
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
PLAN_FILE="${PLAN_DIR:+${PLAN_DIR}/}task_plan.md"
|
||||
|
||||
if [ -f "$PLAN_FILE" ]; then
|
||||
# Log plan context to stderr so the Codex adapter can surface it as systemMessage.
|
||||
|
||||
@@ -7,6 +7,11 @@ import codex_hook_adapter as adapter
|
||||
def main() -> None:
|
||||
payload = adapter.load_payload()
|
||||
root = adapter.cwd_from_payload(payload)
|
||||
|
||||
if not adapter.is_session_attached(root, adapter.session_id_from_payload(payload)):
|
||||
adapter.emit_json({"decision": "allow"})
|
||||
return
|
||||
|
||||
stdout, stderr = adapter.run_shell_script("pre-tool-use.sh", root)
|
||||
|
||||
result = adapter.parse_json(stdout)
|
||||
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: resolve active plan directory.
|
||||
#
|
||||
# Resolution order:
|
||||
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/ if exists
|
||||
# 2. ./.planning/.active_plan content → matching dir if exists
|
||||
# 3. Newest ./.planning/<dir>/ by mtime
|
||||
# 4. Otherwise empty stdout (caller falls back to legacy ./task_plan.md)
|
||||
#
|
||||
# Always exits 0. Never errors out the agent loop.
|
||||
#
|
||||
# Usage:
|
||||
# PLAN_DIR="$(sh scripts/resolve-plan-dir.sh)"
|
||||
# PLAN_FILE="${PLAN_DIR:+$PLAN_DIR/}task_plan.md"
|
||||
|
||||
set -u
|
||||
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
|
||||
resolve_from_env() {
|
||||
plan_id="${PLAN_ID:-}"
|
||||
[ -z "${plan_id}" ] && return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ]; then
|
||||
printf "%s\n" "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
[ -z "${plan_id}" ] && return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ]; then
|
||||
printf "%s\n" "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_latest_dir() {
|
||||
[ -d "${PLAN_ROOT}" ] || return 1
|
||||
# Portable newest-mtime selector. Avoid `ls -t` BSD/GNU drift.
|
||||
# Only consider dirs that contain task_plan.md — skips system dirs like sessions/.
|
||||
latest=""
|
||||
latest_mtime=0
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
# Strip trailing slash
|
||||
clean="${entry%/}"
|
||||
# Skip hidden dirs
|
||||
case "$(basename "${clean}")" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
# Skip dirs that are not plan dirs
|
||||
[ -f "${clean}/task_plan.md" ] || continue
|
||||
mtime="$(date -r "${clean}" +%s 2>/dev/null || stat -c '%Y' "${clean}" 2>/dev/null || echo 0)"
|
||||
if [ "${mtime}" -gt "${latest_mtime}" ] 2>/dev/null; then
|
||||
latest_mtime="${mtime}"
|
||||
latest="${clean}"
|
||||
fi
|
||||
done
|
||||
if [ -n "${latest}" ]; then
|
||||
printf "%s\n" "${latest}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if resolve_from_env; then exit 0; fi
|
||||
if resolve_from_active_file; then exit 0; fi
|
||||
if resolve_latest_dir; then exit 0; fi
|
||||
exit 0
|
||||
@@ -7,6 +7,10 @@ import codex_hook_adapter as adapter
|
||||
def main() -> None:
|
||||
payload = adapter.load_payload()
|
||||
root = adapter.cwd_from_payload(payload)
|
||||
|
||||
if not adapter.is_session_attached(root, adapter.session_id_from_payload(payload)):
|
||||
return
|
||||
|
||||
stdout, _ = adapter.run_shell_script("stop.sh", root)
|
||||
result = adapter.parse_json(stdout)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/bin/bash
|
||||
# planning-with-files: Stop hook for Codex
|
||||
# Reused from the Cursor integration; Codex adapts followup_message separately.
|
||||
|
||||
PLAN_FILE="task_plan.md"
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
PLAN_FILE="${PLAN_DIR:+${PLAN_DIR}/}task_plan.md"
|
||||
|
||||
if [ ! -f "$PLAN_FILE" ]; then
|
||||
exit 0
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
#!/bin/bash
|
||||
# planning-with-files: User prompt submit hook for Codex
|
||||
# Reused from the Cursor integration.
|
||||
|
||||
if [ -f task_plan.md ]; then
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
PLAN_FILE="${PLAN_DIR:+${PLAN_DIR}/}task_plan.md"
|
||||
PROGRESS_FILE="${PLAN_DIR:+${PLAN_DIR}/}progress.md"
|
||||
|
||||
# Session isolation: if .planning/sessions/ exists, only attached sessions see
|
||||
# plan context. Absence of the sessions dir means legacy single-session mode —
|
||||
# all sessions in the cwd receive context to preserve backward compatibility.
|
||||
if [ -d ".planning/sessions" ]; then
|
||||
SESSION_ID="${PWF_SESSION_ID:-}"
|
||||
if [ -z "$SESSION_ID" ] || [ ! -f ".planning/sessions/${SESSION_ID}.attached" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$PLAN_FILE" ]; then
|
||||
echo "[planning-with-files] ACTIVE PLAN — current state:"
|
||||
head -50 task_plan.md
|
||||
head -50 "$PLAN_FILE"
|
||||
echo ""
|
||||
echo "=== recent progress ==="
|
||||
tail -20 progress.md 2>/dev/null
|
||||
tail -20 "$PROGRESS_FILE" 2>/dev/null
|
||||
echo ""
|
||||
echo "[planning-with-files] Read findings.md for research context. Continue from the current phase."
|
||||
fi
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CODEX_SKILL_ROOT:-$HOME/.codex/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CURSOR_SKILL_ROOT:-.cursor/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"${FACTORY_PROJECT_DIR:-.factory/skills/planning-with-files}/scripts\"; sh \"$SD/check-complete.sh\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: planning-with-files
|
||||
description: Implements Manus-style file-based planning to organize and track progress on complex tasks. Creates task_plan.md, findings.md, and progress.md. Use when asked to plan out, break down, or organize a multi-step project, research task, or any work requiring 5+ tool calls. Hermes adaptation with minimal notes.
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
> Hermes note: lifecycle automation for this skill is provided by the Hermes adapter plugin in `.hermes/plugins/planning-with-files/`.
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"$HOME/.mastracode/skills/planning-with-files/scripts\"; [ -f \"$SD/check-complete.sh\" ] || SD=\".mastracode/skills/planning-with-files/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SD=\"${OPENCODE_SKILL_ROOT:-$HOME/.config/opencode/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.36.0] - 2026-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Parallel plan isolation** (Issue #148 by @shawnli1874): `init-session.sh` now accepts a task name and creates a dated, readable plan directory under `.planning/YYYY-MM-DD-<slug>/`. Each parallel task gets its own isolated directory, ending the cross-contamination that v2.0.0 hooks introduced by hardcoding `task_plan.md` at the project root. Legacy zero-argument behavior is unchanged. New `set-active-plan.sh` and `set-active-plan.ps1` let users explicitly switch between plans without exporting `PLAN_ID`. New `resolve-plan-dir.sh` and `resolve-plan-dir.ps1` provide the resolution chain: `$PLAN_ID` env var, then `.planning/.active_plan`, then the newest plan directory by mtime, then empty (legacy fallback). All four Codex lifecycle hooks (UserPromptSubmit, PreToolUse, PostToolUse, Stop) now route through the resolver instead of assuming a root-level `task_plan.md`.
|
||||
- **Codex session isolation** (Issue #146 by @githubYiheng): Codex sessions in a shared working directory no longer receive plan context from unrelated sessions. Attachment is opt-in: create `.planning/sessions/<session_id>.attached` to bind a session to the active plan. `user-prompt-submit.sh`, `pre_tool_use.py`, `stop.py`, and `post_tool_use.py` all gate on session attachment before injecting context or blocking. Backward compatible: absence of `.planning/sessions/` preserves existing single-session behavior.
|
||||
- **Hermes integration notes** (Issue #147 by @09ashishkapoor): `docs/hermes.md` gains an `Integration Notes` section that separates what the adapter provides today from what is not full parity with hook-native platforms. Covers current support level, recommended integration pattern, and a tradeoffs table. Reduces confusion for users migrating from Claude Code hook workflows.
|
||||
- **34 new tests**: `tests/test_resolve_plan_dir.py` (7), `tests/test_init_session_slug.py` (6), `tests/test_hook_resolver_integration.py` (10), `tests/test_codex_session_isolation.py` (5), `tests/test_set_active_plan.py` (6).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`resolve_latest_dir` skips non-plan directories**: auto-discovery previously matched any subdirectory under `.planning/`, including the new `sessions/` directory. It now requires `task_plan.md` to be present, preventing session isolation from silently breaking when both features are active.
|
||||
- **`short_uuid()` bypasses Windows App Execution Aliases**: the function now probes each Python candidate with a test run before trusting `command -v`, avoiding the case where the Windows Store alias reports presence but exits non-zero.
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bumped to 2.36.0 across 14 SKILL.md variants, `plugin.json`, `marketplace.json`, and `CITATION.cff`
|
||||
- `CONTRIBUTORS.md` updated: added @githubYiheng (Issue #146), @09ashishkapoor (Issue #147), @shawnli1874 (Issue #148); total count now 39+
|
||||
|
||||
### Thanks
|
||||
|
||||
- @githubYiheng for tracing the session boundary problem down to its exact code path and proposing the session attachment model (Issue #146)
|
||||
- @09ashishkapoor for the clear documentation gap report and the four-section structure that made writing the fix straightforward (Issue #147)
|
||||
- @shawnli1874 for the detailed parallel workflow breakdown, the concrete reproduction case, and the slug naming proposal that shaped the final design (Issue #148)
|
||||
|
||||
## [2.35.0] - 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ authors:
|
||||
- family-names: Adi
|
||||
given-names: Ahmad Othman Ammar
|
||||
alias: OthmanAdi
|
||||
version: "2.35.0"
|
||||
date-released: "2026-04-21"
|
||||
version: "2.36.0"
|
||||
date-released: "2026-05-01"
|
||||
license: MIT
|
||||
url: "https://github.com/OthmanAdi/planning-with-files"
|
||||
repository-code: "https://github.com/OthmanAdi/planning-with-files"
|
||||
|
||||
+22
-2
@@ -115,6 +115,22 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
- PR #139: preferred known system Python paths over unqualified PATH resolution in `session-start.sh`, `pre-tool-use.sh`, and `error-occurred.sh`
|
||||
- **Impact:** Hardened the Copilot hook scripts and the Pi variant in one coordinated audit pass
|
||||
|
||||
- **[@githubYiheng](https://github.com/githubYiheng)** - [Issue #146](https://github.com/OthmanAdi/planning-with-files/issues/146)
|
||||
- Reported Codex session isolation failure: any Codex session in a shared working directory received the active plan context from an unrelated session, because the hooks keyed only on `task_plan.md` presence
|
||||
- Traced the code path through `user-prompt-submit.sh`, `pre_tool_use.py`, and `stop.py`, and proposed the session attachment model as a fix direction
|
||||
- **Impact:** Led to `$PWF_SESSION_ID` + sentinel file isolation, backward-compatible upgrade path, and 5 new targeted tests
|
||||
|
||||
- **[@09ashishkapoor](https://github.com/09ashishkapoor)** - [Issue #147](https://github.com/OthmanAdi/planning-with-files/issues/147)
|
||||
- Filed a detailed Hermes documentation gap report: the integration worked but docs implied feature parity with hook-native platforms, leading to user confusion when stop/block behavior differed
|
||||
- Outlined the four sections needed (what works, what is not equivalent, recommended pattern, tradeoffs) with enough specificity to write from directly
|
||||
- **Impact:** `docs/hermes.md` now has an `Integration Notes` section that sets accurate expectations for Hermes adopters
|
||||
|
||||
- **[@shawnli1874](https://github.com/shawnli1874)** - [Issue #148](https://github.com/OthmanAdi/planning-with-files/issues/148)
|
||||
- Reported that v2.0.0 hooks broke parallel multi-task workflows by hardcoding `task_plan.md` at the project root, removing the placement flexibility that CLAUDE.md conventions had previously allowed
|
||||
- Provided a concrete reproduction with anonymized task files showing how parallel sessions contaminated each other after a few hours of work
|
||||
- Proposed the `YYYY-MM-DD-<slug>/` naming convention as a human-readable alternative to the UUID approach in `experimental/isolated-planning`
|
||||
- **Impact:** Slug-based `init-session.sh`, `set-active-plan.sh`, `resolve-plan-dir.sh`, and the full Codex hook resolver wire-up all trace back to this report
|
||||
|
||||
- **[@Leon-Algo](https://github.com/Leon-Algo)** - [PR #119](https://github.com/OthmanAdi/planning-with-files/pull/119), [PR #120](https://github.com/OthmanAdi/planning-with-files/pull/120), [PR #122](https://github.com/OthmanAdi/planning-with-files/pull/122)
|
||||
- Made planning scripts executable in `.codex` skill install, fixing Codex installer breakage (PR #119)
|
||||
- Added official Codex hooks.json integration with full lifecycle hooks — SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop — bringing Codex to full hook parity with other IDEs (PR #120)
|
||||
@@ -188,6 +204,10 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
- Fixed Codex session-catchup silently scanning Claude session paths; now prints an explicit fallback message when running from Codex context (PR #100)
|
||||
- **Impact:** Significant docs and tooling consistency sweep across the entire multi-IDE surface
|
||||
|
||||
- **[@Emin017](https://github.com/Emin017)** (Qiming Chu) - [PR #145](https://github.com/OthmanAdi/planning-with-files/pull/145)
|
||||
- Changed shebangs from `/bin/bash` to `/usr/bin/env bash` across hook scripts
|
||||
- Fixes compatibility on systems like NixOS where bash is not at `/bin/bash`
|
||||
|
||||
## Community Forks
|
||||
|
||||
These developers have created forks that extend the functionality:
|
||||
@@ -232,6 +252,6 @@ If you've contributed and don't see your name here, please open an issue! We wan
|
||||
|
||||
---
|
||||
|
||||
**Total Contributors:** 36+ and growing!
|
||||
**Total Contributors:** 39+ and growing!
|
||||
|
||||
*Last updated: April 21, 2026*
|
||||
*Last updated: May 1, 2026*
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
|
||||
[](docs/evals.md)
|
||||
[](docs/evals.md)
|
||||
[](https://loaditout.ai/skills/OthmanAdi/planning-with-files)
|
||||
[](https://getskillcheck.com)
|
||||
[](docs/evals.md)
|
||||
|
||||
[](https://skillsplayground.com/skills/othmanadi-planning-with-files-planning-with-files/)
|
||||
[](https://skill-history.com/othmanadi/planning-with-files)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/releases)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/issues?q=is%3Aissue+is%3Aclosed)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/pulls?q=is%3Apr+is%3Aclosed)
|
||||
@@ -64,10 +63,13 @@ See the full list of everyone who made this project better in [CONTRIBUTORS.md](
|
||||
<details>
|
||||
<summary><strong>📦 Releases & Session Recovery</strong></summary>
|
||||
|
||||
### Current Version: v2.34.1
|
||||
### Current Version: v2.36.0
|
||||
|
||||
| Version | Highlights |
|
||||
|---------|------------|
|
||||
| **v2.36.0** | **Parallel plan isolation + Codex session isolation** (closes #146, #148): `init-session.sh` slug mode, `set-active-plan.sh`, `resolve-plan-dir.sh`, all Codex hooks route through resolver, session attachment gating. **Hermes docs** (closes #147): integration notes added to `docs/hermes.md`. 34 new tests. (thanks @githubYiheng, @09ashishkapoor, @shawnli1874!) |
|
||||
| **v2.35.1** | **Shebang portability fix**: changed `/bin/bash` to `/usr/bin/env bash` in hook scripts, fixing compatibility on NixOS and other systems where bash is not at `/bin/bash`. (thanks @Emin017!) |
|
||||
| **v2.35.0** | **Hermes adapter + NLPM audit hardening**: Hermes platform 17 support (thanks @bailob!), NLPM audit fixed Python PATH resolution, session-catchup injection cap, Pi PowerShell syntax (thanks @xiaolai!) |
|
||||
| **v2.34.1** | **Stop hook Windows portability fix** (closes #133): `export SD=` failed in Windows Git Bash hook context; fallback path was wrong for plugin cache structure. Fixed across all 13 SKILL.md variants. (thanks @nazeshinjite!) |
|
||||
| **v2.34.0** | **Codex hooks fully restored** (closes #132): `.codex/hooks.json` + lifecycle scripts back — SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop. Tessl CI for SKILL.md quality reviews. Exec bit fix. 4 missing contributors added. (thanks @Leon-Algo, @popey!) |
|
||||
| **v2.33.0** | **Multi-language expansion**: Arabic, German, and Spanish skill variants added (thanks to community contributors!) |
|
||||
@@ -97,7 +99,7 @@ See the full list of everyone who made this project better in [CONTRIBUTORS.md](
|
||||
|
||||
[View all releases](https://github.com/OthmanAdi/planning-with-files/releases) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
> 🧪 **Experimental:** Isolated parallel planning (`.planning/{uuid}/` folders) is being tested on [`experimental/isolated-planning`](https://github.com/OthmanAdi/planning-with-files/tree/experimental/isolated-planning). Try it and share feedback!
|
||||
> Parallel plan isolation (`.planning/YYYY-MM-DD-slug/` directories) and Codex session isolation shipped in v2.36.0. The `experimental/isolated-planning` branch was the earlier prototype; master is now the canonical location.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,3 +51,81 @@ The project plugin lives under `.hermes/plugins/planning-with-files/`. Hermes di
|
||||
```bash
|
||||
python3 -m unittest tests/test_hermes_adapter.py
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
This section is for users who have installed the Hermes adapter and are
|
||||
comparing its behavior to hook-native platforms such as Claude Code.
|
||||
|
||||
### What works today
|
||||
|
||||
The adapter provides reliable support for the following:
|
||||
|
||||
- **Initialization.** `planning_with_files_init` creates `task_plan.md`,
|
||||
`findings.md`, and `progress.md` in the target project and sets up the
|
||||
planning scaffold correctly.
|
||||
- **Status queries.** `planning_with_files_status` summarizes current planning
|
||||
state and surfaces which phases are active or blocked.
|
||||
- **Completion checks.** `planning_with_files_check_complete` runs the
|
||||
completion check helper and returns a structured result the agent can act on.
|
||||
- **Context injection.** The project plugin injects active planning context on
|
||||
later turns and reminds the agent to update planning files after write-like
|
||||
actions. This keeps planning state visible across a multi-step workflow.
|
||||
- **Explicit planning commands.** `/plan` and `/plan-status` work as expected
|
||||
when the skill is loaded and the project plugin is active.
|
||||
|
||||
### What is not a full equivalent of hook-native platforms
|
||||
|
||||
Hermes does not expose a native hook or lifecycle API comparable to the stop
|
||||
and pre-tool hooks available on Claude Code. The consequences are:
|
||||
|
||||
- **Stop/block enforcement is adapter-driven, not platform-native.** On
|
||||
Claude Code, a stop hook can interrupt execution at the platform level before
|
||||
the agent takes an action. On Hermes, completion checks and blocking
|
||||
reminders are delivered as tool responses and context injections. The agent
|
||||
can receive and act on them, but there is no platform-level gate that halts
|
||||
execution unconditionally.
|
||||
- **No pre-tool or post-tool lifecycle callbacks.** The project plugin injects
|
||||
reminders, but it cannot intercept a tool call mid-flight or force a rollback
|
||||
the way a hook middleware can.
|
||||
- **More manual setup.** Hook-native platforms wire lifecycle callbacks
|
||||
automatically once the plugin is installed. On Hermes you must explicitly
|
||||
enable project plugins, add the skill bundle to your profile, and start
|
||||
Hermes from the correct working directory (see Install above).
|
||||
|
||||
If you are migrating from a Claude Code workflow and expecting the same
|
||||
stop/block behavior you observed there, you will need to adjust your
|
||||
expectations and your integration pattern accordingly.
|
||||
|
||||
### Recommended integration pattern
|
||||
|
||||
1. Enable project plugins before starting Hermes
|
||||
(`HERMES_ENABLE_PROJECT_PLUGINS=1`).
|
||||
2. Load the `planning-with-files` skill at the start of each session, either
|
||||
via a profile default or an explicit `/load planning-with-files` command.
|
||||
3. Call `planning_with_files_init` once per project to create the planning
|
||||
files. Do not skip this step even if you are resuming an existing project,
|
||||
because the plugin uses the presence of these files to decide whether to
|
||||
inject context.
|
||||
4. Rely on the explicit planning tools (`/plan`, `/plan-status`,
|
||||
`planning_with_files_check_complete`) as your primary control points rather
|
||||
than assuming platform-level enforcement.
|
||||
5. Treat completion checks as advisory signals. Build your workflow so the
|
||||
agent calls `planning_with_files_check_complete` at natural checkpoints and
|
||||
acts on the result, rather than expecting the platform to block progress
|
||||
automatically.
|
||||
6. Use context injection as a substitute for hook reminders. The project plugin
|
||||
is designed to keep planning discipline visible throughout a conversation.
|
||||
If you find the agent drifting from the plan, a manual `/plan-status` call
|
||||
will re-anchor it.
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Usable today | The adapter covers the core planning workflow without requiring a hook-native platform. |
|
||||
| Planning discipline preserved | Context injection and explicit commands keep `task_plan.md` and `progress.md` in sync across multi-step workflows. |
|
||||
| Multi-step workflow support | Init, status, and completion check tools compose naturally into longer agentic loops. |
|
||||
| No stop/block parity | Completion enforcement is advisory. The platform will not halt the agent unconditionally at a lifecycle boundary. |
|
||||
| Manual setup required | Project plugins must be enabled, the skill bundle must be registered, and the working directory must be correct. |
|
||||
| Hook-based expectations may not transfer | Workflows designed around Claude Code's stop hook will need changes before they work correctly on Hermes. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Check if all phases in task_plan.md are complete
|
||||
# Always exits 0 — uses stdout for status reporting
|
||||
# Used by Stop hook to report task completion status
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
+173
-68
@@ -1,13 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Initialize planning files for a new session
|
||||
# Usage: ./init-session.sh [--template TYPE] [project-name]
|
||||
# Templates: default, analytics
|
||||
#!/usr/bin/env bash
|
||||
# Initialize planning files for a new session.
|
||||
#
|
||||
# Usage:
|
||||
# ./init-session.sh # legacy: root-level task_plan.md, findings.md, progress.md
|
||||
# ./init-session.sh [--template TYPE] # legacy with template choice
|
||||
# ./init-session.sh "Backend Refactor" # slug mode: .planning/<date>-backend-refactor/
|
||||
# ./init-session.sh --plan-dir # slug mode with auto-generated untitled-<short> name
|
||||
# ./init-session.sh --plan-dir "Quick Spike" # slug mode, explicit slug
|
||||
#
|
||||
# Legacy mode (zero positional args, no --plan-dir) preserves v1.x behavior so
|
||||
# upgrades stay non-breaking. Slug mode addresses parallel multi-task isolation
|
||||
# (issue #148) by writing each plan under .planning/<date>-<slug>/ and pinning
|
||||
# .planning/.active_plan so resolve-plan-dir.sh can find it.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
TEMPLATE="default"
|
||||
PROJECT_NAME="project"
|
||||
PROJECT_NAME=""
|
||||
USE_PLAN_DIR=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -15,8 +25,16 @@ while [[ $# -gt 0 ]]; do
|
||||
TEMPLATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--plan-dir)
|
||||
USE_PLAN_DIR=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
PROJECT_NAME="$1"
|
||||
if [ -z "$PROJECT_NAME" ]; then
|
||||
PROJECT_NAME="$1"
|
||||
else
|
||||
PROJECT_NAME="$PROJECT_NAME $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
@@ -24,25 +42,55 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Resolve template directory (skill root is one level up from scripts/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
echo "Initializing planning files for: $PROJECT_NAME (template: $TEMPLATE)"
|
||||
|
||||
# Validate template
|
||||
if [ "$TEMPLATE" != "default" ] && [ "$TEMPLATE" != "analytics" ]; then
|
||||
echo "Unknown template: $TEMPLATE (available: default, analytics). Using default."
|
||||
TEMPLATE="default"
|
||||
fi
|
||||
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if [ ! -f "task_plan.md" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_task_plan.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_task_plan.md" task_plan.md
|
||||
else
|
||||
cat > task_plan.md << 'EOF'
|
||||
# Slug mode triggers when a project name was given OR --plan-dir was passed.
|
||||
SLUG_MODE=0
|
||||
if [ -n "$PROJECT_NAME" ] || [ "$USE_PLAN_DIR" -eq 1 ]; then
|
||||
SLUG_MODE=1
|
||||
fi
|
||||
|
||||
slugify() {
|
||||
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
|
||||
printf '%s' "$1" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
|
||||
| cut -c1-40
|
||||
}
|
||||
|
||||
short_uuid() {
|
||||
# Probe each candidate: command -v alone is not enough on Windows because
|
||||
# App Execution Aliases report presence but exit non-zero when run.
|
||||
_py="${PYTHON_BIN:-}"
|
||||
if [ -z "$_py" ]; then
|
||||
for _c in python3 python py; do
|
||||
if command -v "$_c" >/dev/null 2>&1 && "$_c" -c "import uuid" >/dev/null 2>&1; then
|
||||
_py="$_c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$_py" ]; then
|
||||
"$_py" -c "import uuid; print(uuid.uuid4().hex[:8])"
|
||||
return
|
||||
fi
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-8
|
||||
return
|
||||
fi
|
||||
# Last-ditch: seconds timestamp as 8 hex chars
|
||||
printf '%08x' "$(date +%s)" | cut -c1-8
|
||||
}
|
||||
|
||||
write_default_task_plan() {
|
||||
cat > "$1" << 'EOF'
|
||||
# Task Plan: [Brief Description]
|
||||
|
||||
## Goal
|
||||
@@ -87,18 +135,10 @@ Phase 1
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
fi
|
||||
echo "Created task_plan.md"
|
||||
else
|
||||
echo "task_plan.md already exists, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if [ ! -f "findings.md" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_findings.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_findings.md" findings.md
|
||||
else
|
||||
cat > findings.md << 'EOF'
|
||||
write_default_findings() {
|
||||
cat > "$1" << 'EOF'
|
||||
# Findings & Decisions
|
||||
|
||||
## Requirements
|
||||
@@ -118,44 +158,19 @@ if [ ! -f "findings.md" ]; then
|
||||
## Resources
|
||||
-
|
||||
EOF
|
||||
fi
|
||||
echo "Created findings.md"
|
||||
else
|
||||
echo "findings.md already exists, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if [ ! -f "progress.md" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ]; then
|
||||
cat > progress.md << EOF
|
||||
write_default_progress() {
|
||||
local date_value="$1"
|
||||
local target="$2"
|
||||
cat > "$target" << EOF
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
|
||||
### Current Status
|
||||
- **Phase:** 1 - Data Discovery
|
||||
- **Started:** $DATE
|
||||
|
||||
### Actions Taken
|
||||
-
|
||||
|
||||
### Query Log
|
||||
| Query | Result Summary | Interpretation |
|
||||
|-------|---------------|----------------|
|
||||
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
else
|
||||
cat > progress.md << EOF
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
## Session: $date_value
|
||||
|
||||
### Current Status
|
||||
- **Phase:** 1 - Requirements & Discovery
|
||||
- **Started:** $DATE
|
||||
- **Started:** $date_value
|
||||
|
||||
### Actions Taken
|
||||
-
|
||||
@@ -168,12 +183,102 @@ EOF
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
fi
|
||||
echo "Created progress.md"
|
||||
else
|
||||
echo "progress.md already exists, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Planning files initialized!"
|
||||
echo "Files: task_plan.md, findings.md, progress.md"
|
||||
write_analytics_progress() {
|
||||
local date_value="$1"
|
||||
local target="$2"
|
||||
cat > "$target" << EOF
|
||||
# Progress Log
|
||||
|
||||
## Session: $date_value
|
||||
|
||||
### Current Status
|
||||
- **Phase:** 1 - Data Discovery
|
||||
- **Started:** $date_value
|
||||
|
||||
### Actions Taken
|
||||
-
|
||||
|
||||
### Query Log
|
||||
| Query | Result Summary | Interpretation |
|
||||
|-------|---------------|----------------|
|
||||
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
}
|
||||
|
||||
create_files_in() {
|
||||
local target_dir="$1"
|
||||
local plan_path="$target_dir/task_plan.md"
|
||||
local findings_path="$target_dir/findings.md"
|
||||
local progress_path="$target_dir/progress.md"
|
||||
|
||||
if [ ! -f "$plan_path" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_task_plan.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_task_plan.md" "$plan_path"
|
||||
else
|
||||
write_default_task_plan "$plan_path"
|
||||
fi
|
||||
echo "Created $plan_path"
|
||||
else
|
||||
echo "$plan_path already exists, skipping"
|
||||
fi
|
||||
|
||||
if [ ! -f "$findings_path" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_findings.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_findings.md" "$findings_path"
|
||||
else
|
||||
write_default_findings "$findings_path"
|
||||
fi
|
||||
echo "Created $findings_path"
|
||||
else
|
||||
echo "$findings_path already exists, skipping"
|
||||
fi
|
||||
|
||||
if [ ! -f "$progress_path" ]; then
|
||||
if [ "$TEMPLATE" = "analytics" ]; then
|
||||
write_analytics_progress "$DATE" "$progress_path"
|
||||
else
|
||||
write_default_progress "$DATE" "$progress_path"
|
||||
fi
|
||||
echo "Created $progress_path"
|
||||
else
|
||||
echo "$progress_path already exists, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
SLUG="$(slugify "$PROJECT_NAME")"
|
||||
if [ -z "$SLUG" ]; then
|
||||
SLUG="untitled-$(short_uuid)"
|
||||
fi
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
counter=$((counter + 1))
|
||||
done
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
mkdir -p "$PLAN_DIR"
|
||||
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
echo ""
|
||||
echo "Active plan recorded: ${PLAN_ROOT}/.active_plan"
|
||||
echo "Pin this terminal to the plan for parallel sessions:"
|
||||
echo " export PLAN_ID=$PLAN_ID"
|
||||
else
|
||||
PROJECT_NAME="${PROJECT_NAME:-project}"
|
||||
echo "Initializing planning files for: $PROJECT_NAME (template: $TEMPLATE)"
|
||||
create_files_in "$(pwd)"
|
||||
echo ""
|
||||
echo "Planning files initialized!"
|
||||
echo "Files: task_plan.md, findings.md, progress.md"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# planning-with-files: resolve active plan directory (PowerShell mirror).
|
||||
#
|
||||
# Resolution order matches scripts/resolve-plan-dir.sh:
|
||||
# 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
|
||||
# 2. .\.planning\.active_plan content
|
||||
# 3. Newest .\.planning\<dir>\ by LastWriteTime
|
||||
# 4. Empty (legacy fallback to .\task_plan.md handled by caller)
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
)
|
||||
|
||||
$activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
|
||||
if ($env:PLAN_ID) {
|
||||
$candidate = Join-Path $PlanRoot $env:PLAN_ID
|
||||
if (Test-Path $candidate -PathType Container) {
|
||||
Write-Output $candidate
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $activeFile) {
|
||||
$planId = (Get-Content $activeFile -Raw).Trim()
|
||||
if ($planId) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if (Test-Path $candidate -PathType Container) {
|
||||
Write-Output $candidate
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
Where-Object { -not $_.Name.StartsWith('.') } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latest) {
|
||||
Write-Output $latest.FullName
|
||||
}
|
||||
}
|
||||
|
||||
exit 0
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: resolve active plan directory.
|
||||
#
|
||||
# Resolution order:
|
||||
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/ if exists
|
||||
# 2. ./.planning/.active_plan content → matching dir if exists
|
||||
# 3. Newest ./.planning/<dir>/ by mtime
|
||||
# 4. Otherwise empty stdout (caller falls back to legacy ./task_plan.md)
|
||||
#
|
||||
# Always exits 0. Never errors out the agent loop.
|
||||
#
|
||||
# Usage:
|
||||
# PLAN_DIR="$(sh scripts/resolve-plan-dir.sh)"
|
||||
# PLAN_FILE="${PLAN_DIR:+$PLAN_DIR/}task_plan.md"
|
||||
|
||||
set -u
|
||||
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
|
||||
resolve_from_env() {
|
||||
plan_id="${PLAN_ID:-}"
|
||||
[ -z "${plan_id}" ] && return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ]; then
|
||||
printf "%s\n" "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
[ -z "${plan_id}" ] && return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ]; then
|
||||
printf "%s\n" "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_latest_dir() {
|
||||
[ -d "${PLAN_ROOT}" ] || return 1
|
||||
# Portable newest-mtime selector. Avoid `ls -t` BSD/GNU drift.
|
||||
# Only consider dirs that contain task_plan.md — skips system dirs like sessions/.
|
||||
latest=""
|
||||
latest_mtime=0
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
# Strip trailing slash
|
||||
clean="${entry%/}"
|
||||
# Skip hidden dirs
|
||||
case "$(basename "${clean}")" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
# Skip dirs that are not plan dirs
|
||||
[ -f "${clean}/task_plan.md" ] || continue
|
||||
mtime="$(date -r "${clean}" +%s 2>/dev/null || stat -c '%Y' "${clean}" 2>/dev/null || echo 0)"
|
||||
if [ "${mtime}" -gt "${latest_mtime}" ] 2>/dev/null; then
|
||||
latest_mtime="${mtime}"
|
||||
latest="${clean}"
|
||||
fi
|
||||
done
|
||||
if [ -n "${latest}" ]; then
|
||||
printf "%s\n" "${latest}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if resolve_from_env; then exit 0; fi
|
||||
if resolve_from_active_file; then exit 0; fi
|
||||
if resolve_latest_dir; then exit 0; fi
|
||||
exit 0
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> — pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 — print the current active plan (if any)
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
Write-Output ""
|
||||
Write-Output "To pin this terminal session only:"
|
||||
Write-Output "`$env:PLAN_ID = '$PlanId'"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
# نظام تخطيط الملفات
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# التحقق من اكتمال جميع المراحل في task_plan.md
|
||||
# ينهي دائمًا برمز خروج 0 — يستخدم stdout للإبلاغ عن الحالة
|
||||
# يُستدعى بواسطة خطاف Stop للإبلاغ عن حالة اكتمال المهمة
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# تهيئة ملفات التخطيط لجلسة جديدة
|
||||
# الاستخدام: ./init-session.sh [اسم المشروع]
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
# Dateiplanungssystem
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Prüft, ob alle Phasen in task_plan.md abgeschlossen sind
|
||||
# Immer mit Exit-Code 0 beenden — Status über stdout melden
|
||||
# Wird vom Stop-Hook aufgerufen, um Aufgabenabschlussstatus zu melden
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Initialisiert Planungsdateien für eine neue Sitzung
|
||||
# Verwendung: ./init-session.sh [Projektname]
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
# Sistema de Planificación con Archivos
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Verificar si todas las fases en task_plan.md están completas
|
||||
# Siempre salir con código 0 — usar stdout para informar el estado
|
||||
# Llamado por el hook Stop para informar el estado de finalización de la tarea
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Inicializar archivos de planificación para una nueva sesión
|
||||
# Uso: ./init-session.sh [nombre_del_proyecto]
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ hooks:
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# 检查 task_plan.md 中所有阶段是否完成
|
||||
# 始终以退出码 0 结束 — 使用标准输出报告状态
|
||||
# 由 Stop 钩子调用以报告任务完成状态
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# 初始化新会话的规划文件
|
||||
# 用法:./init-session.sh [项目名称]
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ hooks:
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# 檢查 task_plan.md 中所有階段是否完成
|
||||
# 始終以退出碼 0 結束 — 使用標準輸出回報狀態
|
||||
# 由 Stop 鉤子呼叫以回報任務完成狀態
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# 初始化新會話的規劃檔案
|
||||
# 用法:./init-session.sh [專案名稱]
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ hooks:
|
||||
- type: command
|
||||
command: "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Get-ChildItem -Path (Join-Path ~ '.claude/plugins/cache') -Filter check-complete.ps1 -Recurse -EA 0 | Select-Object -First 1).FullName\" 2>/dev/null || sh \"$(ls $HOME/.claude/plugins/cache/*/*/*/scripts/check-complete.sh 2>/dev/null | head -1)\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.35.0"
|
||||
version: "2.36.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Check if all phases in task_plan.md are complete
|
||||
# Always exits 0 — uses stdout for status reporting
|
||||
# Used by Stop hook to report task completion status
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Initialize planning files for a new session
|
||||
# Usage: ./init-session.sh [project-name]
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# test_clear_recovery.sh — Automated test for session recovery after /clear
|
||||
#
|
||||
# Tests that the UserPromptSubmit hook injects actual plan content
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for Codex session isolation — addresses #146.
|
||||
|
||||
Goal: a Codex session must not receive another session's plan context just because
|
||||
task_plan.md exists in cwd. Each session must explicitly attach. Attach state
|
||||
lives at .planning/sessions/<session_id>.attached and is opt-in.
|
||||
|
||||
Backward compat: if no .planning/sessions/ directory exists at all, hooks fall
|
||||
back to legacy "any session in this cwd sees the plan" behavior to avoid breaking
|
||||
existing single-session users on upgrade.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOOKS_DIR = REPO_ROOT / ".codex" / "hooks"
|
||||
|
||||
|
||||
class CodexSessionIsolationTests(unittest.TestCase):
|
||||
def run_python_hook(
|
||||
self,
|
||||
script_name: str,
|
||||
payload: dict,
|
||||
cwd: Path,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(HOOKS_DIR / script_name)],
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
check=False,
|
||||
)
|
||||
|
||||
def write_plan(self, root: Path) -> None:
|
||||
(root / "task_plan.md").write_text(
|
||||
"# Task Plan\n\n## Goal\nShip Codex isolation\n\n### Phase 1: Discovery\n- **Status:** in_progress\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "progress.md").write_text("# Progress\n\nstarted\n", encoding="utf-8")
|
||||
(root / "findings.md").write_text("# Findings\n", encoding="utf-8")
|
||||
|
||||
def attach_session(self, root: Path, session_id: str) -> None:
|
||||
sessions_dir = root / ".planning" / "sessions"
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
(sessions_dir / f"{session_id}.attached").write_text("legacy\n", encoding="utf-8")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backward compat: no .planning/sessions/ => legacy single-session mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_legacy_mode_user_prompt_submit_injects(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
payload = {"cwd": str(root), "session_id": "sess-A"}
|
||||
result = subprocess.run(
|
||||
["sh", str(HOOKS_DIR / "user-prompt-submit.sh")],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={**os.environ, "PWF_SESSION_ID": "sess-A"},
|
||||
check=False,
|
||||
)
|
||||
self.assertIn("ACTIVE PLAN", result.stdout)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Isolation: when sessions/ dir exists, only attached sessions see context
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_user_prompt_submit_silent_for_unattached_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
self.attach_session(root, "sess-A")
|
||||
# Session B is NOT attached
|
||||
env = {**os.environ, "PWF_SESSION_ID": "sess-B"}
|
||||
result = subprocess.run(
|
||||
["sh", str(HOOKS_DIR / "user-prompt-submit.sh")],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotIn("ACTIVE PLAN", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_injects_for_attached_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
self.attach_session(root, "sess-A")
|
||||
env = {**os.environ, "PWF_SESSION_ID": "sess-A"}
|
||||
result = subprocess.run(
|
||||
["sh", str(HOOKS_DIR / "user-prompt-submit.sh")],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
self.assertIn("ACTIVE PLAN", result.stdout)
|
||||
self.assertIn("Ship Codex isolation", result.stdout)
|
||||
|
||||
def test_pre_tool_use_silent_for_unattached_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
self.attach_session(root, "sess-A")
|
||||
payload = {"cwd": str(root), "session_id": "sess-B"}
|
||||
result = self.run_python_hook("pre_tool_use.py", payload, root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
# No systemMessage payload should be emitted
|
||||
stdout = result.stdout.strip()
|
||||
if stdout:
|
||||
emitted = json.loads(stdout)
|
||||
self.assertNotIn("systemMessage", emitted)
|
||||
|
||||
def test_stop_does_not_block_unattached_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
self.attach_session(root, "sess-A")
|
||||
payload = {"cwd": str(root), "session_id": "sess-B", "stop_hook_active": False}
|
||||
result = self.run_python_hook("stop.py", payload, root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
stdout = result.stdout.strip()
|
||||
if stdout:
|
||||
emitted = json.loads(stdout)
|
||||
self.assertNotEqual(emitted.get("decision"), "block")
|
||||
|
||||
def test_two_sessions_same_cwd_isolated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.write_plan(root)
|
||||
self.attach_session(root, "sess-A")
|
||||
# A sees plan, B silent — single repro for cross-session leak
|
||||
env_a = {**os.environ, "PWF_SESSION_ID": "sess-A"}
|
||||
env_b = {**os.environ, "PWF_SESSION_ID": "sess-B"}
|
||||
ra = subprocess.run(
|
||||
["sh", str(HOOKS_DIR / "user-prompt-submit.sh")],
|
||||
cwd=str(root), text=True, capture_output=True, env=env_a, check=False,
|
||||
)
|
||||
rb = subprocess.run(
|
||||
["sh", str(HOOKS_DIR / "user-prompt-submit.sh")],
|
||||
cwd=str(root), text=True, capture_output=True, env=env_b, check=False,
|
||||
)
|
||||
self.assertIn("ACTIVE PLAN", ra.stdout)
|
||||
self.assertNotIn("ACTIVE PLAN", rb.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Integration tests: Codex hooks use resolve-plan-dir.sh to find the active plan.
|
||||
|
||||
These tests confirm that after the #148 fix, all four Codex hook shell scripts
|
||||
correctly locate task_plan.md through the resolver rather than assuming the
|
||||
legacy root path. They complement the unit tests in test_resolve_plan_dir.py
|
||||
by exercising the full hook→resolver→plan-file chain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOOKS_DIR = REPO_ROOT / ".codex" / "hooks"
|
||||
|
||||
|
||||
def run_hook(script: str, cwd: Path, env_extra: dict | None = None) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env.pop("PLAN_ID", None)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
["sh", str(HOOKS_DIR / script)],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def write_plan_in_dir(plan_dir: Path, goal: str = "Ship the feature") -> None:
|
||||
plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plan_dir / "task_plan.md").write_text(
|
||||
f"# Task Plan\n\n## Goal\n{goal}\n\n### Phase 1: Work\n- **Status:** in_progress\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(plan_dir / "progress.md").write_text("# Progress\n\nstarted\n", encoding="utf-8")
|
||||
(plan_dir / "findings.md").write_text("# Findings\n", encoding="utf-8")
|
||||
|
||||
|
||||
class HookResolverIntegrationTests(unittest.TestCase):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# user-prompt-submit.sh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_user_prompt_submit_silent_with_no_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_hook("user-prompt-submit.sh", Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertNotIn("ACTIVE PLAN", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_injects_from_planning_subdir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan_dir = root / ".planning" / "2026-01-10-backend-refactor"
|
||||
write_plan_in_dir(plan_dir, goal="Refactor the auth layer")
|
||||
(root / ".planning" / ".active_plan").write_text(
|
||||
"2026-01-10-backend-refactor\n", encoding="utf-8"
|
||||
)
|
||||
result = run_hook("user-prompt-submit.sh", root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("ACTIVE PLAN", result.stdout)
|
||||
self.assertIn("Refactor the auth layer", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_legacy_root_still_works(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "task_plan.md").write_text(
|
||||
"# Task Plan\n\n## Goal\nLegacy goal\n\n### Phase 1: Work\n- **Status:** in_progress\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "progress.md").write_text("# Progress\n", encoding="utf-8")
|
||||
result = run_hook("user-prompt-submit.sh", root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("ACTIVE PLAN", result.stdout)
|
||||
self.assertIn("Legacy goal", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_env_plan_id_pins_correct_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
write_plan_in_dir(root / ".planning" / "task-a", goal="Task A goal")
|
||||
write_plan_in_dir(root / ".planning" / "task-b", goal="Task B goal")
|
||||
(root / ".planning" / ".active_plan").write_text("task-b\n", encoding="utf-8")
|
||||
# Override with env var to force task-a
|
||||
result = run_hook("user-prompt-submit.sh", root, env_extra={"PLAN_ID": "task-a"})
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("Task A goal", result.stdout)
|
||||
self.assertNotIn("Task B goal", result.stdout)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# pre-tool-use.sh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_pre_tool_use_allows_with_no_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_hook("pre-tool-use.sh", Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("allow", result.stdout)
|
||||
|
||||
def test_pre_tool_use_surfaces_plan_from_subdir_on_stderr(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan_dir = root / ".planning" / "2026-01-10-my-task"
|
||||
write_plan_in_dir(plan_dir, goal="My task goal")
|
||||
(root / ".planning" / ".active_plan").write_text(
|
||||
"2026-01-10-my-task\n", encoding="utf-8"
|
||||
)
|
||||
result = run_hook("pre-tool-use.sh", root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("allow", result.stdout)
|
||||
self.assertIn("My task goal", result.stderr)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# stop.sh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_stop_silent_with_no_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_hook("stop.sh", Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_stop_reports_incomplete_from_subdir_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan_dir = root / ".planning" / "2026-01-10-feature"
|
||||
write_plan_in_dir(plan_dir, goal="Build feature")
|
||||
(root / ".planning" / ".active_plan").write_text(
|
||||
"2026-01-10-feature\n", encoding="utf-8"
|
||||
)
|
||||
result = run_hook("stop.sh", root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("followup_message", result.stdout)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# post-tool-use.sh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_post_tool_use_silent_with_no_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_hook("post-tool-use.sh", Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_post_tool_use_reminds_when_plan_in_subdir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan_dir = root / ".planning" / "2026-01-10-work"
|
||||
write_plan_in_dir(plan_dir)
|
||||
(root / ".planning" / ".active_plan").write_text(
|
||||
"2026-01-10-work\n", encoding="utf-8"
|
||||
)
|
||||
result = run_hook("post-tool-use.sh", root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("progress.md", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for slug-aware init-session.sh — addresses #148.
|
||||
|
||||
Backward-compat behavior:
|
||||
- Zero args → legacy root mode: writes task_plan.md/findings.md/progress.md in cwd
|
||||
- One+ string args → slug mode: writes under .planning/YYYY-MM-DD-<slug>/
|
||||
- --plan-dir flag forces slug mode without naming
|
||||
- Slug collisions append -2, -3, ...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
INIT_SH = REPO_ROOT / "scripts" / "init-session.sh"
|
||||
|
||||
|
||||
class InitSessionSlugTests(unittest.TestCase):
|
||||
def run_init(self, cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["sh", str(INIT_SH), *args],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_legacy_zero_args_keeps_root_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = self.run_init(root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue((root / "task_plan.md").exists())
|
||||
self.assertTrue((root / "findings.md").exists())
|
||||
self.assertTrue((root / "progress.md").exists())
|
||||
self.assertFalse((root / ".planning").exists(), "legacy mode must not create .planning/")
|
||||
|
||||
def test_slug_arg_creates_dated_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = self.run_init(root, "Backend Refactor")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
today = date.today().isoformat()
|
||||
expected = root / ".planning" / f"{today}-backend-refactor"
|
||||
self.assertTrue(expected.is_dir(), f"missing {expected}")
|
||||
self.assertTrue((expected / "task_plan.md").exists())
|
||||
self.assertTrue((expected / "findings.md").exists())
|
||||
self.assertTrue((expected / "progress.md").exists())
|
||||
active = (root / ".planning" / ".active_plan").read_text(encoding="utf-8").strip()
|
||||
self.assertEqual(active, f"{today}-backend-refactor")
|
||||
|
||||
def test_slug_sanitizes_unsafe_chars(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = self.run_init(root, "Foo / Bar! Baz??")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
today = date.today().isoformat()
|
||||
expected = root / ".planning" / f"{today}-foo-bar-baz"
|
||||
self.assertTrue(expected.is_dir(), f"got {[p.name for p in (root / '.planning').iterdir()]}")
|
||||
|
||||
def test_slug_collision_appends_suffix(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.run_init(root, "same name")
|
||||
result = self.run_init(root, "same name")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
today = date.today().isoformat()
|
||||
self.assertTrue((root / ".planning" / f"{today}-same-name").is_dir())
|
||||
self.assertTrue((root / ".planning" / f"{today}-same-name-2").is_dir())
|
||||
|
||||
def test_plan_dir_flag_default_slug(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = self.run_init(root, "--plan-dir")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
today = date.today().isoformat()
|
||||
dirs = list((root / ".planning").iterdir())
|
||||
dirs = [d for d in dirs if d.is_dir()]
|
||||
self.assertEqual(1, len(dirs))
|
||||
self.assertTrue(re.match(rf"^{today}-untitled-[a-z0-9]+$", dirs[0].name))
|
||||
|
||||
def test_template_flag_still_works_in_legacy_mode(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = self.run_init(root, "--template", "default")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue((root / "task_plan.md").exists())
|
||||
self.assertFalse((root / ".planning").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for scripts/resolve-plan-dir.sh — addresses #148.
|
||||
|
||||
Resolver order:
|
||||
1. $PLAN_ID env → .planning/<id>/ if exists
|
||||
2. .planning/.active_plan content → .planning/<id>/ if exists
|
||||
3. Newest .planning/<dir>/ by mtime
|
||||
4. Legacy fallback: <cwd>/task_plan.md exists → emit empty (caller uses cwd)
|
||||
5. Otherwise empty stdout, exit 0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESOLVE_SH = REPO_ROOT / "scripts" / "resolve-plan-dir.sh"
|
||||
|
||||
|
||||
class ResolvePlanDirTests(unittest.TestCase):
|
||||
def run_resolver(self, cwd: Path, plan_id: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env.pop("PLAN_ID", None)
|
||||
if plan_id is not None:
|
||||
env["PLAN_ID"] = plan_id
|
||||
return subprocess.run(
|
||||
["sh", str(RESOLVE_SH)],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_resolver_script_exists(self) -> None:
|
||||
self.assertTrue(RESOLVE_SH.exists(), "scripts/resolve-plan-dir.sh missing")
|
||||
|
||||
def test_empty_repo_returns_nothing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self.run_resolver(Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_env_plan_id_takes_precedence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / ".planning" / "alpha").mkdir(parents=True)
|
||||
(root / ".planning" / "beta").mkdir(parents=True)
|
||||
(root / ".planning" / ".active_plan").write_text("beta\n", encoding="utf-8")
|
||||
result = self.run_resolver(root, plan_id="alpha")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(result.stdout.strip().endswith("alpha"))
|
||||
|
||||
def test_active_plan_used_when_env_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / ".planning" / "alpha").mkdir(parents=True)
|
||||
(root / ".planning" / "beta").mkdir(parents=True)
|
||||
(root / ".planning" / ".active_plan").write_text("beta\n", encoding="utf-8")
|
||||
result = self.run_resolver(root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(result.stdout.strip().endswith("beta"))
|
||||
|
||||
def test_falls_back_to_newest_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
old = root / ".planning" / "older"
|
||||
new = root / ".planning" / "newer"
|
||||
old.mkdir(parents=True)
|
||||
(old / "task_plan.md").write_text("# old\n", encoding="utf-8")
|
||||
time.sleep(0.05)
|
||||
new.mkdir(parents=True)
|
||||
(new / "task_plan.md").write_text("# new\n", encoding="utf-8")
|
||||
# bump mtime explicitly to be safe across filesystems
|
||||
os.utime(new, None)
|
||||
result = self.run_resolver(root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(
|
||||
result.stdout.strip().endswith("newer"),
|
||||
f"expected newer, got {result.stdout!r}",
|
||||
)
|
||||
|
||||
def test_legacy_root_plan_emits_empty(self) -> None:
|
||||
# When no .planning/ but cwd/task_plan.md exists, resolver emits empty so
|
||||
# callers fall back to the legacy root path. This preserves v1.x users.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "task_plan.md").write_text("# legacy\n", encoding="utf-8")
|
||||
result = self.run_resolver(root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_env_plan_id_pointing_to_missing_dir_falls_through(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
real = root / ".planning" / "real"
|
||||
real.mkdir(parents=True)
|
||||
(real / "task_plan.md").write_text("# real plan\n", encoding="utf-8")
|
||||
result = self.run_resolver(root, plan_id="ghost")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
# Should fall through to newest existing plan dir
|
||||
self.assertTrue(result.stdout.strip().endswith("real"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for scripts/set-active-plan.sh — companion to resolve-plan-dir.sh.
|
||||
|
||||
set-active-plan.sh lets users explicitly switch the active plan pointer
|
||||
without needing to export PLAN_ID. This is the UX complement to slug-mode
|
||||
init-session.sh for parallel multi-task workflows (#148).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SET_ACTIVE_SH = REPO_ROOT / "scripts" / "set-active-plan.sh"
|
||||
|
||||
|
||||
def run_set_active(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["sh", str(SET_ACTIVE_SH), *args],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class SetActivePlanTests(unittest.TestCase):
|
||||
|
||||
def test_script_exists(self) -> None:
|
||||
self.assertTrue(SET_ACTIVE_SH.exists(), "scripts/set-active-plan.sh missing")
|
||||
|
||||
def test_no_args_no_active_plan_prints_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_set_active(Path(tmp))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("No active plan", result.stdout)
|
||||
|
||||
def test_no_args_shows_current_active_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan = root / ".planning" / "2026-01-10-my-task"
|
||||
plan.mkdir(parents=True)
|
||||
(root / ".planning" / ".active_plan").write_text("2026-01-10-my-task\n", encoding="utf-8")
|
||||
result = run_set_active(root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("2026-01-10-my-task", result.stdout)
|
||||
|
||||
def test_sets_active_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan_a = root / ".planning" / "task-a"
|
||||
plan_b = root / ".planning" / "task-b"
|
||||
plan_a.mkdir(parents=True)
|
||||
plan_b.mkdir(parents=True)
|
||||
# Set to task-a first
|
||||
r1 = run_set_active(root, "task-a")
|
||||
self.assertEqual(0, r1.returncode, r1.stderr)
|
||||
active = (root / ".planning" / ".active_plan").read_text(encoding="utf-8").strip()
|
||||
self.assertEqual("task-a", active)
|
||||
# Switch to task-b
|
||||
r2 = run_set_active(root, "task-b")
|
||||
self.assertEqual(0, r2.returncode, r2.stderr)
|
||||
active = (root / ".planning" / ".active_plan").read_text(encoding="utf-8").strip()
|
||||
self.assertEqual("task-b", active)
|
||||
|
||||
def test_errors_on_nonexistent_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
result = run_set_active(root, "ghost-plan")
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("not found", result.stderr)
|
||||
|
||||
def test_resolver_picks_up_newly_set_plan(self) -> None:
|
||||
# End-to-end: set-active-plan.sh then resolve-plan-dir.sh returns correct dir
|
||||
from pathlib import Path as P
|
||||
resolve_sh = REPO_ROOT / "scripts" / "resolve-plan-dir.sh"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = P(tmp)
|
||||
plan_a = root / ".planning" / "2026-task-a"
|
||||
plan_b = root / ".planning" / "2026-task-b"
|
||||
plan_a.mkdir(parents=True)
|
||||
plan_b.mkdir(parents=True)
|
||||
(plan_a / "task_plan.md").write_text("# A\n", encoding="utf-8")
|
||||
(plan_b / "task_plan.md").write_text("# B\n", encoding="utf-8")
|
||||
# Pin to task-a
|
||||
run_set_active(root, "2026-task-a")
|
||||
result = subprocess.run(
|
||||
["sh", str(resolve_sh)],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertTrue(result.stdout.strip().endswith("2026-task-a"))
|
||||
# Switch to task-b
|
||||
run_set_active(root, "2026-task-b")
|
||||
result = subprocess.run(
|
||||
["sh", str(resolve_sh)],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertTrue(result.stdout.strip().endswith("2026-task-b"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user