Compare commits

...
5 Commits
Author SHA1 Message Date
OthmanAdi 9bf31fc41e release: v2.36.0 (parallel plan isolation + Codex session isolation + Hermes docs)
Closes #146, #147, #148.

Parallel plan isolation (#148, @shawnli1874): init-session.sh slug mode
creates .planning/YYYY-MM-DD-<slug>/ for each task. set-active-plan.sh/.ps1
switch the active plan pointer. resolve-plan-dir.sh/.ps1 provide the
resolution chain ($PLAN_ID env, .active_plan file, newest plan dir, legacy
fallback). All four Codex hooks route through the resolver. resolve_latest_dir
now requires task_plan.md to be present, preventing system dirs like sessions/
from being selected as plan dirs. short_uuid() probes each Python candidate
before trusting command -v, fixing silent failure on Windows Store aliases.

Codex session isolation (#146, @githubYiheng): sessions attach to a plan by
creating .planning/sessions/<session_id>.attached. 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. Legacy mode preserved when
.planning/sessions/ does not exist.

Hermes integration notes (#147, @09ashishkapoor): docs/hermes.md gains an
Integration Notes section covering current support level, what is not full
parity with hook-native platforms, recommended integration pattern, and a
tradeoffs table.

34 new tests across five files. Version bumped to 2.36.0 across 14 SKILL.md
variants, plugin.json, marketplace.json, and CITATION.cff.
2026-05-01 18:37:25 +02:00
Ahmad Othman Ammar Adi. 9fb5529c9e Update README.md 2026-04-29 21:49:54 +02:00
OthmanAdi 2d685e9a2a docs: add @Emin017 to CONTRIBUTORS, add v2.35.0/v2.35.1 to README releases table
- CONTRIBUTORS.md: added Emin017 (Qiming Chu) for PR #145 env bash shebangs fix; bumped total to 37+, updated date to April 29 2026
- README.md: bumped version badge to v2.35.1; added missing v2.35.0 row (Hermes + NLPM audit) and v2.35.1 row (shebang portability) to releases table; updated Current Version heading
2026-04-29 21:44:49 +02:00
Ahmad Othman Ammar Adi. 8db4368340 Merge pull request #145 from Emin017/fix-shebang
fix: use env bash shebangs
2026-04-29 20:51:38 +02:00
Emin 0a9e668436 fix: use env bash shebangs
Use /usr/bin/env bash so scripts resolve bash through PATH instead of
assuming /bin/bash exists. This improves shebang compatibility on
systems such as NixOS.
2026-04-29 14:44:20 +08:00
63 changed files with 1342 additions and 116 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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"
---
+25
View File
@@ -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
+5 -2
View File
@@ -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
+4
View File
@@ -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})
+3 -2
View File
@@ -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.
+5
View File
@@ -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)
+76
View File
@@ -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
+4
View File
@@ -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)
+3 -2
View File
@@ -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
+18 -4
View File
@@ -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
+1 -1
View File
@@ -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"
---
+1 -1
View File
@@ -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"
---
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
---
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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*
+6 -4
View File
@@ -8,13 +8,12 @@
[![Benchmark](https://img.shields.io/badge/Benchmark-96.7%25_pass_rate-brightgreen)](docs/evals.md)
[![A/B Verified](https://img.shields.io/badge/A%2FB_Blind-3%2F3_wins-brightgreen)](docs/evals.md)
[![Loaditout Security Grade](https://loaditout.ai/badge/OthmanAdi/planning-with-files)](https://loaditout.ai/skills/OthmanAdi/planning-with-files)
[![SkillCheck Validated](https://img.shields.io/badge/SkillCheck-Validated-4c1)](https://getskillcheck.com)
[![Security Verified](https://img.shields.io/badge/Security-Audited_%26_Fixed_v2.21.0-blue)](docs/evals.md)
[![Skills Playground](https://skillsplayground.com/badges/installs/othmanadi-planning-with-files-planning-with-files.svg)](https://skillsplayground.com/skills/othmanadi-planning-with-files-planning-with-files/)
[![Downloads](https://skill-history.com/badge/othmanadi/planning-with-files.svg)](https://skill-history.com/othmanadi/planning-with-files)
[![Version](https://img.shields.io/badge/version-2.34.1-brightgreen)](https://github.com/OthmanAdi/planning-with-files/releases)
[![Version](https://img.shields.io/badge/version-2.36.0-brightgreen)](https://github.com/OthmanAdi/planning-with-files/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Closed Issues](https://img.shields.io/github/issues-closed/OthmanAdi/planning-with-files?color=success)](https://github.com/OthmanAdi/planning-with-files/issues?q=is%3Aissue+is%3Aclosed)
[![Closed PRs](https://img.shields.io/github/issues-pr-closed/OthmanAdi/planning-with-files?color=success)](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.
---
+78
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
+173 -68
View File
@@ -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
+44
View File
@@ -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
+76
View File
@@ -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
+50
View File
@@ -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'"
+50
View File
@@ -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}"
+1 -1
View File
@@ -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 [اسم المشروع]
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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 [项目名称]
+1 -1
View File
@@ -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 [專案名稱]
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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
+160
View File
@@ -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()
+165
View File
@@ -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()
+98
View File
@@ -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()
+110
View File
@@ -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()
+110
View File
@@ -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()