Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb292cba44 | ||
|
|
1a2d95eeb0 | ||
|
|
0ec48c1c13 | ||
|
|
0d286ac530 | ||
|
|
78a526dc60 | ||
|
|
f90780c92f | ||
|
|
59b04ab2a7 | ||
|
|
f13118cadf | ||
|
|
11b0ff6750 | ||
|
|
4340fc2a9c | ||
|
|
f1b54f5b0d | ||
|
|
242a5c95c3 | ||
|
|
055a6af7f8 | ||
|
|
a52877e2ae | ||
|
|
e8ce66f962 | ||
|
|
bdef0e0aa0 | ||
|
|
7efb050593 | ||
|
|
ea8e9093c1 | ||
|
|
8209f7a387 | ||
|
|
565d4ffab4 | ||
|
|
fbc562f5f4 |
@@ -9,7 +9,7 @@
|
||||
"name": "planning-with-files",
|
||||
"source": "./",
|
||||
"description": "Persistent file-based planning for AI coding agents. Crash-proof markdown plans that survive context loss, /clear, and crashes, with an opt-in completion gate and multi-agent shared state on disk. Manus-style, installs across 60+ agents via the SKILL.md standard.",
|
||||
"version": "3.4.0"
|
||||
"version": "3.6.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.4.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Persistent file-based planning for AI coding agents. Crash-proof markdown plans (task_plan.md, findings.md, progress.md) that survive context loss and /clear, with an opt-in completion gate and multi-agent shared state. Manus-style. Works with Claude Code, Codex CLI, Cursor, Kiro, OpenCode and 60+ agents via the SKILL.md standard. Includes Arabic, German, Spanish, and Chinese (Simplified and Traditional).",
|
||||
"author": {
|
||||
"name": "OthmanAdi",
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
+10
-3
@@ -2,11 +2,12 @@
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"matcher": "startup|resume|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh .codex/hooks/session-start.sh 2>/dev/null || sh \"$HOME/.codex/hooks/session-start.sh\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd run_sh.py session-start.sh",
|
||||
"statusMessage": "Loading planning context"
|
||||
}
|
||||
]
|
||||
@@ -17,7 +18,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh .codex/hooks/user-prompt-submit.sh 2>/dev/null || sh \"$HOME/.codex/hooks/user-prompt-submit.sh\" 2>/dev/null || true"
|
||||
"command": "sh .codex/hooks/user-prompt-submit.sh 2>/dev/null || sh \"$HOME/.codex/hooks/user-prompt-submit.sh\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd run_sh.py user-prompt-submit.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +31,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/pre_tool_use.py 2>/dev/null || python3 \"$HOME/.codex/hooks/pre_tool_use.py\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd pre_tool_use.py",
|
||||
"statusMessage": "Checking plan before Bash"
|
||||
}
|
||||
]
|
||||
@@ -39,7 +42,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/permission_request.py 2>/dev/null || python3 \"$HOME/.codex/hooks/permission_request.py\" 2>/dev/null || true"
|
||||
"command": "python3 .codex/hooks/permission_request.py 2>/dev/null || python3 \"$HOME/.codex/hooks/permission_request.py\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd permission_request.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,6 +55,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/post_tool_use.py 2>/dev/null || python3 \"$HOME/.codex/hooks/post_tool_use.py\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd post_tool_use.py",
|
||||
"statusMessage": "Reviewing Bash against plan"
|
||||
}
|
||||
]
|
||||
@@ -63,6 +68,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh .codex/hooks/pre-compact.sh 2>/dev/null || sh \"$HOME/.codex/hooks/pre-compact.sh\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd run_sh.py pre-compact.sh",
|
||||
"statusMessage": "Preparing planning context before compact"
|
||||
}
|
||||
]
|
||||
@@ -74,6 +80,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/stop.py 2>/dev/null || python3 \"$HOME/.codex/hooks/stop.py\" 2>/dev/null || true",
|
||||
"commandWindows": "cmd /c .codex\\hooks\\pwf-hook.cmd stop.py",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -58,7 +59,7 @@ def is_session_attached(root: Path, session_id: str | None) -> bool:
|
||||
def emit_json(payload: dict[str, Any]) -> None:
|
||||
if not payload:
|
||||
return
|
||||
json.dump(payload, sys.stdout, ensure_ascii=False)
|
||||
json.dump(payload, sys.stdout, ensure_ascii=True)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
@@ -72,13 +73,76 @@ def parse_json(text: str) -> dict[str, Any]:
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def run_shell_script(script_name: str, cwd: Path) -> tuple[str, str]:
|
||||
def _windows_git_bash() -> tuple[str | None, list[str]]:
|
||||
"""Locate git-for-windows sh.exe plus the unix-tools dirs the .sh hooks need.
|
||||
|
||||
Returns (sh_path, extra_path_dirs). A default Git-for-Windows install puts
|
||||
only cmd\\git.exe on PATH, not usr\\bin\\sh.exe or the coreutils (grep, head,
|
||||
date, tr) the hook scripts call and re-invoke via nested `sh`. So when `sh`
|
||||
is not directly on PATH we anchor on git.exe (or the standard install roots)
|
||||
and probe for sh.exe and its sibling bin dirs. This is exactly issue #201:
|
||||
the reporter had git bash installed but its usr\\bin was not on PATH.
|
||||
"""
|
||||
system32 = (Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32").resolve()
|
||||
for exe in ("sh", "bash"):
|
||||
found = shutil.which(exe)
|
||||
if found:
|
||||
candidate = Path(found).resolve()
|
||||
parent = candidate.parent
|
||||
# Windows' bash.exe is a WSL launcher, not a POSIX shell. Selecting
|
||||
# it before Git Bash makes every shell hook fail when WSL has no
|
||||
# installed distro (the common Git-for-Windows-only setup), and even
|
||||
# a working WSL bash cannot run C:\ script paths. The Store alias
|
||||
# under WindowsApps is the same launcher.
|
||||
if parent != system32 and parent.name.lower() != "windowsapps":
|
||||
return str(candidate), [str(parent)]
|
||||
|
||||
roots: list[Path] = []
|
||||
git = shutil.which("git")
|
||||
if git:
|
||||
# <root>\cmd\git.exe or <root>\bin\git.exe -> <root>
|
||||
roots.append(Path(git).resolve().parent.parent)
|
||||
for env_var in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
||||
base = os.environ.get(env_var)
|
||||
if base:
|
||||
roots.append(Path(base) / "Git")
|
||||
roots.append(Path(base) / "Programs" / "Git")
|
||||
|
||||
for root in roots:
|
||||
sh_exe = root / "usr" / "bin" / "sh.exe"
|
||||
if sh_exe.exists():
|
||||
extra = [root / "usr" / "bin", root / "bin", root / "mingw64" / "bin"]
|
||||
return str(sh_exe), [str(d) for d in extra if d.exists()]
|
||||
return None, []
|
||||
|
||||
|
||||
def run_shell_script(script_name: str, cwd: Path, *args: str) -> tuple[str, str]:
|
||||
sh_cmd = "sh"
|
||||
env = None
|
||||
if os.name == "nt":
|
||||
sh_path, extra_dirs = _windows_git_bash()
|
||||
if sh_path is None:
|
||||
# No git bash reachable: run nothing rather than crash. An advisory
|
||||
# hook must never surface an error (issue #201). docs/codex.md tells
|
||||
# Windows users to install Git for Windows to enable these hooks.
|
||||
return "", ""
|
||||
sh_cmd = sh_path
|
||||
env = os.environ.copy()
|
||||
if extra_dirs:
|
||||
env["PATH"] = os.pathsep.join(extra_dirs) + os.pathsep + env.get("PATH", "")
|
||||
# session-catchup.py resolves via $PYTHON_BIN first; hand it the real
|
||||
# interpreter so it never falls back to the Store python3.exe stub.
|
||||
env.setdefault("PYTHON_BIN", sys.executable)
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", str(HOOK_DIR / script_name)],
|
||||
[sh_cmd, str(HOOK_DIR / script_name), *args],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
return result.stdout.strip(), result.stderr.strip()
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ def main() -> None:
|
||||
if not adapter.is_session_attached(root, adapter.session_id_from_payload(payload)):
|
||||
return
|
||||
|
||||
plan = root / "task_plan.md"
|
||||
# Pass a relative plan root so the shell resolver returns a path that is
|
||||
# valid in both Git Bash and native Windows Python.
|
||||
plan_dir, _ = adapter.run_shell_script("resolve-plan-dir.sh", root, ".planning")
|
||||
plan = root / plan_dir / "task_plan.md" if plan_dir else root / "task_plan.md"
|
||||
if not plan.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ def main() -> None:
|
||||
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)
|
||||
@@ -21,7 +20,12 @@ def main() -> None:
|
||||
return
|
||||
|
||||
if stderr:
|
||||
adapter.emit_json({"systemMessage": stderr})
|
||||
adapter.emit_json({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext": stderr,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
setlocal
|
||||
rem planning-with-files: Windows hook launcher for Codex (issue #201).
|
||||
rem Codex runs each hook's commandWindows; on Windows that routes here as:
|
||||
rem cmd /c .codex\hooks\pwf-hook.cmd <script.py> [args...]
|
||||
rem A .cmd is the one form that runs identically whether Codex spawns the hook
|
||||
rem via cmd.exe, PowerShell, or direct CreateProcess, and it lets us fall back
|
||||
rem from the py launcher to python.exe. The Microsoft Store python3.exe alias is
|
||||
rem deliberately never selected. Always exits 0 so an advisory hook cannot
|
||||
rem report "hook exited with code 1".
|
||||
set "PY_EXE="
|
||||
set "PY_ARGS="
|
||||
if defined PYTHON_BIN if exist "%PYTHON_BIN%" set "PY_EXE=%PYTHON_BIN%"
|
||||
if not defined PY_EXE where py >nul 2>nul && set "PY_EXE=py" && set "PY_ARGS=-3"
|
||||
if not defined PY_EXE where python >nul 2>nul && set "PY_EXE=python"
|
||||
rem Codex can start hooks with a reduced PATH. Probe the normal uv and CPython
|
||||
rem install locations so a valid user installation still works in that case.
|
||||
if not defined PY_EXE for /d %%D in ("%APPDATA%\uv\python\*") do if exist "%%~fD\python.exe" if not defined PY_EXE set "PY_EXE=%%~fD\python.exe"
|
||||
if not defined PY_EXE for /d %%D in ("%LOCALAPPDATA%\uv\python\*") do if exist "%%~fD\python.exe" if not defined PY_EXE set "PY_EXE=%%~fD\python.exe"
|
||||
if not defined PY_EXE for /d %%D in ("%LOCALAPPDATA%\Programs\Python\Python*") do if exist "%%~fD\python.exe" if not defined PY_EXE set "PY_EXE=%%~fD\python.exe"
|
||||
if not defined PY_EXE exit /b 0
|
||||
"%PY_EXE%" %PY_ARGS% "%~dp0%~1" %2 %3 %4 %5
|
||||
exit /b 0
|
||||
@@ -1,76 +1,14 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: resolve active plan directory.
|
||||
# planning-with-files: global Codex hook resolver entry point.
|
||||
#
|
||||
# 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"
|
||||
# The resolver implementation is owned by the installed skill. Keep this file
|
||||
# as a thin forwarding shim so Hook and helper callers cannot silently drift
|
||||
# onto different slug/containment rules again.
|
||||
|
||||
set -u
|
||||
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
HOOK_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)" || exit 0
|
||||
CANONICAL_RESOLVER="${HOOK_DIR}/../skills/planning-with-files/scripts/resolve-plan-dir.sh"
|
||||
|
||||
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
|
||||
[ -f "${CANONICAL_RESOLVER}" ] || exit 0
|
||||
exec sh "${CANONICAL_RESOLVER}" "$@"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""planning-with-files: Windows front-door for the pure-shell Codex hooks.
|
||||
|
||||
The three shell-only hooks (session-start, user-prompt-submit, pre-compact) are
|
||||
invoked directly as ``sh <script>.sh`` on macOS/Linux. On Windows their
|
||||
``commandWindows`` routes here:
|
||||
|
||||
cmd /c .codex\\hooks\\pwf-hook.cmd run_sh.py session-start.sh
|
||||
|
||||
We reuse the adapter's shell resolver, which locates the git-for-windows
|
||||
``sh.exe`` and puts its coreutils on PATH, then run the same ``.sh`` the unix
|
||||
hook runs and print its stdout. Never used on unix. Always exits 0.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
return
|
||||
script_name = sys.argv[1]
|
||||
payload = adapter.load_payload()
|
||||
root = adapter.cwd_from_payload(payload)
|
||||
stdout, _ = adapter.run_shell_script(script_name, root)
|
||||
if not stdout:
|
||||
return
|
||||
|
||||
context_events = {
|
||||
"session-start.sh": "SessionStart",
|
||||
"user-prompt-submit.sh": "UserPromptSubmit",
|
||||
}
|
||||
if script_name in context_events:
|
||||
result = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": context_events[script_name],
|
||||
"additionalContext": stdout,
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=True) + "\n")
|
||||
return
|
||||
|
||||
if script_name == "pre-compact.sh":
|
||||
result = {
|
||||
"continue": True,
|
||||
"systemMessage": stdout,
|
||||
}
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=True) + "\n")
|
||||
return
|
||||
|
||||
sys.stdout.write(stdout + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(adapter.main_guard(main))
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -178,7 +178,7 @@ Manus operates in a continuous 7-step loop:
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Single-Action Execution:** ONE tool call per turn. No parallel execution.
|
||||
- **Single-Action Execution (Manus 2025 original constraint):** ONE tool call per turn, no parallel execution. This documents Manus's 2025 sandbox practice. **2026 update:** modern hosts (Claude Code, Codex CLI) support parallel tool calls and subagents, so this constraint no longer applies as written. The plan file, not the one-call-per-turn rule, remains the coordination point: parallel calls and subagents share state through the durable markdown plan on disk.
|
||||
- **Plan is Required:** Agent must ALWAYS know: goal, current phase, remaining phases
|
||||
- **Files are Memory:** Context = volatile. Filesystem = persistent.
|
||||
- **Never Repeat Failures:** If action failed, next action MUST be different
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Tests: runs the pytest suite on Linux and Windows plus the Pi extension
|
||||
# vitest suite on every PR and push to master.
|
||||
# The pytest suite also runs scripts/sync-ide-folders.py --verify via
|
||||
# tests/test_canonical_script_sync.py, so cross-copy parity is covered.
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: pytest (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install test dependencies
|
||||
# pyyaml is optional at test time; without it the YAML frontmatter
|
||||
# test in tests/test_skill_frontmatter_valid.py silently skips.
|
||||
run: python -m pip install pytest pyyaml
|
||||
- name: Assert sh is available
|
||||
# Many hook tests guard on shutil.which("sh") and skip when absent.
|
||||
# Fail loudly instead of silently losing coverage (Git Bash provides
|
||||
# sh.exe on windows-latest).
|
||||
run: python -c "import shutil, sys; sys.exit(0 if shutil.which('sh') else 1)"
|
||||
- name: Run pytest
|
||||
run: python -m pytest tests/ -q
|
||||
|
||||
vitest:
|
||||
name: vitest (Pi extension)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .pi/skills/planning-with-files/extensions/planning-with-files
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: .pi/skills/planning-with-files/extensions/planning-with-files/package-lock.json
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Run vitest
|
||||
run: npm test
|
||||
@@ -2,7 +2,7 @@
|
||||
name: planning-with-files
|
||||
description: "Manus-style persistent file-based planning for AI coding agents: keeps task_plan.md, findings.md, and progress.md on disk so work survives context loss and /clear. 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: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
> Hermes note: lifecycle automation for this skill is provided by the Hermes adapter plugin in `.hermes/plugins/planning-with-files/`.
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ Manus operates in a continuous 7-step loop:
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Single-Action Execution:** ONE tool call per turn. No parallel execution.
|
||||
- **Single-Action Execution (Manus 2025 original constraint):** ONE tool call per turn, no parallel execution. This documents Manus's 2025 sandbox practice. **2026 update:** modern hosts (Claude Code, Codex CLI) support parallel tool calls and subagents, so this constraint no longer applies as written. The plan file, not the one-call-per-turn rule, remains the coordination point: parallel calls and subagents share state through the durable markdown plan on disk.
|
||||
- **Plan is Required:** Agent must ALWAYS know: goal, current phase, remaining phases
|
||||
- **Files are Memory:** Context = volatile. Filesystem = persistent.
|
||||
- **Never Repeat Failures:** If action failed, next action MUST be different
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
+41
-2
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -88,6 +88,10 @@ function completePlan(): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function closedIncompletePlan(): string {
|
||||
return incompletePlan() + "\n<!-- pwf: closed -->\n";
|
||||
}
|
||||
|
||||
function attestPlan(cwd: string, content: string): void {
|
||||
writeFileSync(join(cwd, ".planning", "demo", ".attestation"), sha256(content));
|
||||
}
|
||||
@@ -267,7 +271,7 @@ describe("Pi extension runtime handlers", () => {
|
||||
content: expect.stringContaining("PreToolUse recitation"),
|
||||
display: false,
|
||||
}),
|
||||
{ deliverAs: "steer", triggerTurn: false },
|
||||
{ deliverAs: "nextTurn", triggerTurn: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -372,6 +376,41 @@ describe("Pi extension runtime handlers", () => {
|
||||
|
||||
expect(pi.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("agent_end sends no follow-up when the plan is closed even if incomplete", async () => {
|
||||
const cwd = makeWorkspace(closedIncompletePlan());
|
||||
const pi = loadExtension();
|
||||
const ctx = createContext(cwd);
|
||||
|
||||
await approvePlan(pi, ctx);
|
||||
await emit(pi, "agent_end", {}, ctx);
|
||||
|
||||
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolveNewestPlanDir ranks by task_plan.md file mtime, not directory mtime", async () => {
|
||||
const cwd = makeWorkspace(incompletePlan()); // creates .planning/demo (incomplete)
|
||||
const donePlanDir = join(cwd, ".planning", "done");
|
||||
mkdirSync(donePlanDir, { recursive: true });
|
||||
writeFileSync(join(donePlanDir, "task_plan.md"), completePlan());
|
||||
writeFileSync(join(donePlanDir, "progress.md"), "done\n");
|
||||
const older = new Date(Date.now() - 60_000);
|
||||
const newer = new Date();
|
||||
// done DIR older than demo DIR, but done's task_plan.md file NEWER than demo's
|
||||
utimesSync(donePlanDir, older, older);
|
||||
utimesSync(join(cwd, ".planning", "demo"), newer, newer);
|
||||
utimesSync(join(donePlanDir, "task_plan.md"), newer, newer);
|
||||
utimesSync(join(cwd, ".planning", "demo", "task_plan.md"), older, older);
|
||||
|
||||
const pi = loadExtension();
|
||||
const ctx = createContext(cwd);
|
||||
await emit(pi, "agent_end", {}, ctx);
|
||||
|
||||
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
||||
"[planning-with-files] ALL PHASES COMPLETE (2/2).",
|
||||
"info",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi extension runtime modes", () => {
|
||||
|
||||
+3415
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tomxprime/planning-with-files-pi-extension",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PlanPaths {
|
||||
|
||||
export interface PlanStatus extends PlanPaths {
|
||||
exists: boolean;
|
||||
closed: boolean;
|
||||
totalPhases: number;
|
||||
completePhases: number;
|
||||
inProgressPhases: number;
|
||||
@@ -43,7 +44,8 @@ function resolveNewestPlanDir(planRoot: string): string | undefined {
|
||||
.map((dir) => {
|
||||
let mtime = 0;
|
||||
try {
|
||||
mtime = statSync(dir).mtimeMs;
|
||||
// Rank by task_plan.md mtime, not the directory: editing a plan's contents does not bump the dir mtime, which let a completed plan lose to a stale sibling (#203).
|
||||
mtime = statSync(join(dir, "task_plan.md")).mtimeMs;
|
||||
} catch {
|
||||
mtime = 0;
|
||||
}
|
||||
@@ -119,6 +121,7 @@ export function readPlanStatus(cwd: string): PlanStatus {
|
||||
return {
|
||||
...paths,
|
||||
exists: false,
|
||||
closed: false,
|
||||
totalPhases: 0,
|
||||
completePhases: 0,
|
||||
inProgressPhases: 0,
|
||||
@@ -130,6 +133,7 @@ export function readPlanStatus(cwd: string): PlanStatus {
|
||||
}
|
||||
|
||||
const planContent = safeRead(paths.planPath);
|
||||
const closed = /<!--\s*pwf:\s*closed\s*-->/i.test(planContent);
|
||||
const lines = planContent.split("\n");
|
||||
|
||||
const phaseRegex = /^###\s+Phase\b/i;
|
||||
@@ -164,6 +168,7 @@ export function readPlanStatus(cwd: string): PlanStatus {
|
||||
return {
|
||||
...paths,
|
||||
exists: true,
|
||||
closed,
|
||||
totalPhases: total,
|
||||
completePhases: complete,
|
||||
inProgressPhases: inProgress,
|
||||
|
||||
@@ -409,7 +409,7 @@ function registerCommands(pi: ExtensionAPI, state: RuntimeState): void {
|
||||
const status = readPlanStatus(ctx.cwd);
|
||||
if (!status.exists) return;
|
||||
|
||||
if (isAllPhasesComplete(status)) {
|
||||
if (isAllPhasesComplete(status) || status.closed) {
|
||||
const active = state.loopTimersBySession.get(sessionId);
|
||||
if (active) clearInterval(active);
|
||||
state.loopTimersBySession.delete(sessionId);
|
||||
@@ -543,7 +543,7 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
||||
content: buildTamperMessage(status),
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "steer", triggerTurn: false },
|
||||
{ deliverAs: "nextTurn", triggerTurn: false },
|
||||
);
|
||||
} else if (mode === "parity") {
|
||||
pi.sendMessage(
|
||||
@@ -552,7 +552,7 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
||||
content: buildPreToolParityRecitation(status),
|
||||
display: false,
|
||||
},
|
||||
{ deliverAs: "steer", triggerTurn: false },
|
||||
{ deliverAs: "nextTurn", triggerTurn: false },
|
||||
);
|
||||
} else if (mode === "cache-safe") {
|
||||
pi.sendMessage(
|
||||
@@ -561,7 +561,7 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
||||
content: PRE_TOOL_CACHE_SAFE_REMINDER,
|
||||
display: false,
|
||||
},
|
||||
{ deliverAs: "steer", triggerTurn: false },
|
||||
{ deliverAs: "nextTurn", triggerTurn: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -607,6 +607,10 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
||||
|
||||
const sessionId = getSessionId(ctx);
|
||||
const planKey = getPlanSessionKey(ctx, status);
|
||||
if (status.closed) {
|
||||
state.autoContinueCountBySessionPlan.set(planKey, 0);
|
||||
return;
|
||||
}
|
||||
const mode = deriveEffectiveMode(resolveConfiguredMode(ctx.cwd), ctx);
|
||||
|
||||
if (isAllPhasesComplete(status)) {
|
||||
@@ -653,6 +657,12 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
||||
"Update progress.md with what was done, then read task_plan.md and continue remaining phases." +
|
||||
(goal ? ` Goal: ${goal}` : "");
|
||||
|
||||
if (process.env.PWF_DEBUG) {
|
||||
console.error(
|
||||
`[planning-with-files] agent_end nag: cwd=${ctx.cwd} planId=${status.planId ?? "root"} closed=${status.closed} phases=${status.completePhases}/${status.totalPhases}`,
|
||||
);
|
||||
}
|
||||
|
||||
pi.sendUserMessage(continueMessage, { deliverAs: "followUp" });
|
||||
});
|
||||
|
||||
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
+104
@@ -2,6 +2,110 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [3.6.0] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Plan resolution and hook injection went silently dark on machines with Windows-native coreutils on PATH.** A native coreutils build (for example `C:\Program Files\coreutils`, increasingly common via winget or uutils) shadows Git Bash's tools inside `sh`, and its `realpath` canonicalizes MSYS `/c/...` input to `C:\`-style backslash output. The containment guard in `resolve-plan-dir.sh` and `inject-plan.sh` compares canonical paths with a forward-slash prefix pattern, so every comparison failed: the resolver resolved nothing, `inject-plan.sh` emitted nothing, and every hook fire exited 0 with no error. The plan mechanisms this skill is built on were fully disabled on such machines with no visible symptom, and Linux CI could never reproduce it. Canonical paths are now backslash-normalized (pure-shell, no extra process) before comparison. Found live during a repo audit on a machine where 33 local tests failed while CI stayed green.
|
||||
|
||||
- **The resolver's containment root still canonicalized `$PWD` instead of `.`.** The v3.2.0 fix for 8.3 short-name and `/tmp`-alias mismatches landed in `inject-plan.sh` only; `resolve-plan-dir.sh` kept the old form and additionally canonicalized absolute candidates built from the `$PWD` string, which a Windows-native `realpath` does not spell the same way as a short-form process cwd. The resolver now canonicalizes the root via `.` and checks candidates through their cwd-relative form, so both sides resolve through the same physical-cwd base. Emitted output is unchanged.
|
||||
|
||||
- **The local `test_ledger.py` hang on Windows is gone.** Known since v3.4.1 as "hangs locally, green on CI"; it stopped reproducing once the containment comparison was fixed. The ledger suite (12 tests) now passes locally in under 15 seconds.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/plan-doctor` self-check command** (`commands/plan-doctor.md`, `scripts/plan-doctor.sh`, dual-shipped and parity-tested). Every failure in this class is silent by design (hooks always exit 0), so a broken install looks identical to "no plan yet". The doctor answers, in one pass: does resolution work here and which plan wins, does injection actually emit plan context, what path shape does the canonicalizer produce (warns about the pre-v3.6.0 dark condition), is the plan attested, which install surfaces exist on this machine, and what one hook fire costs in wall-clock. From the July 2026 benchmark improvement backlog.
|
||||
|
||||
- **Install-route matrix and trust prerequisite in `docs/installation.md`.** The plugin route ships `commands/` and registers hooks reliably; `npx skills add` and manual skill copies ship no slash commands, and SKILL.md frontmatter hooks have been observed unregistered on project-level installs (headless Claude Code 2.1.201, July 2026 benchmark). Project trust (`hasTrustDialogAccepted`) silently gates project-level skills. Both conditions are now documented with the doctor as the verification step.
|
||||
|
||||
- **Belt-and-suspenders trigger line for CLAUDE.md.** The July 2026 benchmark measured unforced skill engagement at 60-67% while always-loaded rules-file instructions engaged 100%. Install docs now offer a one-line CLAUDE.md snippet that makes engagement deterministic while the skill description keeps handling discovery.
|
||||
|
||||
- **Regression tests for the backslash-canonicalizer class** (`tests/test_containment.py`): a PATH-prepended stub `realpath` reproduces the Windows-native output shape on every platform, so ubuntu CI now guards a Windows-only field failure.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hook fire cost dropped.** Per-candidate `grep -Eq` slug checks and `basename` calls in `resolve-plan-dir.sh` and `inject-plan.sh` were replaced with equivalent shell builtins (case patterns and parameter expansion), and the resolver computes its containment root once per run instead of once per candidate. One `inject-plan.sh` fire measures 289ms wall-clock on the same machine that measured 2.0-2.4s at v3.4.0 (July 2026 benchmark, F4). Output is byte-identical; the legacy invariant holds.
|
||||
|
||||
- README documents v3.6.0, adds a plan-mode handoff FAQ (how an accepted plan-mode plan becomes `task_plan.md` phases, answering the open question in #19), and updates the supported-agents answer to the current `.agents/skills/` standard landscape. The stale Manus-2025 "one tool call per turn" guidance was replaced with the 2026 parallel-host update in the last two variant copies that still carried it (`.factory`, `.mastracode`).
|
||||
|
||||
### Verification
|
||||
|
||||
- Full suite green locally on the machine that reproduced the bug: 205 passed plus 12 ledger tests, 5 skipped, 0 failed (was 33 failed before the fix). Live smoke: `sh scripts/inject-plan.sh` emits plan context in a repo with an active plan, `scripts/plan-doctor.sh` reports PASS on resolution and injection with the Windows-native canonicalizer present.
|
||||
|
||||
## [3.5.1] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex Windows hook resolver defaulted to the WSL bash launcher** (extracted from PR #207 by @mahdiit). The shell resolver in `codex_hook_adapter` preferred `C:\Windows\System32\bash.exe`, the WSL launcher, whenever Git Bash's `usr\bin` was not on PATH, the default Git for Windows install layout. On machines where WSL is present but no distro is installed, a common Docker Desktop setup, that launcher exists on disk but fails immediately, so every shell hook silently failed. This is why #201 and #204 kept resurfacing on some Windows machines even after those releases shipped. The resolver now skips WSL launchers, both the System32 binary and the Microsoft Store WindowsApps alias, and continues on to the Git for Windows probe.
|
||||
|
||||
- **`pwf-hook.cmd` lost the Python interpreter under Codex's reduced PATH** (also from PR #207). Codex starts hooks with a trimmed environment, so the launcher's plain `py -3` / `python` lookup often found nothing. `pwf-hook.cmd` now honors a `PYTHON_BIN` override, probes the standard `uv` and CPython install locations, and quotes the interpreter path so installs under a path containing spaces still resolve.
|
||||
|
||||
- **Pi extension 1.2.1: pre-tool recitations and the tamper notice were consumed by interactive tool dialogs** (#206, diagnosed with the exact mechanism by @jschmied). The `tool_call` hook delivered queued planning text as steer, but when an interactive tool such as AskUserQuestion blocked the turn on its own custom UI, the steer text was read as the dialog's answer instead of reaching the model. Recitations and the tamper notice are now delivered as `nextTurn`, landing after the interactive tool releases the turn instead of being swallowed by it. Bundled Pi extension bumped to 1.2.1.
|
||||
|
||||
- **Two test-portability failures surfaced by the first hosted-runner run** (PR #198 by @Yigtwxx, test files only, no production script changes). `tests/test_containment.py` canonicalized the resolver's Git Bash POSIX path with `os.path.realpath`, which read `/tmp/...` as `C:\tmp` and failed the escaping-symlink containment assertion on symlink-capable `windows-latest`; a `host_realpath()` helper now maps the path back with `cygpath` before comparing. `tests/test_path_fix.py` ran two Windows-shaped path vectors on `ubuntu-latest`, where `Path.resolve()` treats `C:/...` as relative and prepends the CWD; both now skip unless `os.name == "nt"`, the only platform where that input shape is meaningful. In both cases the resolver rejected the escape correctly and only the test comparison was wrong.
|
||||
|
||||
### Added
|
||||
|
||||
- **CI test workflow** (`.github/workflows/tests.yml`, PR #199 by @Yigtwxx, closes #197). Runs the pytest suite on `ubuntu-latest` and `windows-latest` (Python 3.12) plus the Pi extension vitest suite on `ubuntu-latest` (Node 22), on every pull request and push to master. Until now the only CI was the Tessl skill-prose review, so nothing ran the 200+ pytest tests or the 21 Pi vitest tests that CONTRIBUTING.md asks contributors to run before opening a PR. The `windows-latest` leg targets the recurring Windows regression class (the v3.2.0 audit found `session-catchup.py` silently non-functional on Windows). Workflow permissions are `contents: read` only, `fail-fast: false` keeps a Windows-only failure visible instead of masked by a cancel, and an explicit step asserts `sh` is on PATH so the roughly ten hook tests that skip without it cannot silently drop coverage.
|
||||
|
||||
### Verification
|
||||
|
||||
- Python suite green on master before merge (200 passed, 5 skipped on a non-symlink-capable Windows host). Contributor's hosted-runner run on PR #199 green on all three jobs: pytest on ubuntu with symlink containment tests running, the equivalent profile on windows, and 21 vitest.
|
||||
- Supply-chain review on PR #198 and #199: no new runtime dependencies, no install scripts, no bin shims. The workflow installs `pytest` and `pyyaml` for the pytest job and runs `npm install` against the Pi extension's existing devDependencies (vitest, typescript, @types/node). It uses `pull_request` (not `pull_request_target`), so fork PRs run with a read-only token and no secret access.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Mahdi (@mahdiit) for producing the Codex Windows WSL bash launcher fix and the Windows Python discovery hardening (PR #207), using Codex Terra after v3.4.1 still failed to clear the hook errors on his own machine.
|
||||
- jschmied for diagnosing #206 down to the exact runtime location, steer delivered recitations consumed by AskUserQuestion's interactive dialog, and for the `nextTurn` delivery fix that shipped.
|
||||
- Yigtwxx (Yiğit) for filing the CI gap (#197) and landing both the test-portability fixes (#198) and the pytest plus vitest workflow (#199).
|
||||
|
||||
## [3.5.0] - 2026-07-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex hooks on Windows emitted invalid JSON and failed on Unicode** (PR #205 by @yolo0731, closes #204). On Windows the Codex front door forwarded plain `[planning-with-files]` stdout where Codex expects `hookSpecificOutput.additionalContext` (SessionStart, UserPromptSubmit) or a common-fields object (PreCompact), so those hooks were rejected. UTF-8 plan text also broke twice, once decoding shell output through the Windows code page in `subprocess.run(text=True)` and again writing `ensure_ascii=False` JSON through cmd.exe. The fix serializes each event in its supported Codex JSON shape with ASCII-safe output, decodes shell output as UTF-8 with `errors="replace"`, routes PreToolUse plan text through model-visible `additionalContext`, resolves scoped `.planning/<slug>/` plans in PermissionRequest, adds `clear|compact` to the SessionStart matcher, and writes the `.active_plan` pointer as UTF-8 without a BOM. The containment resolver also now fails closed when canonicalization is unavailable. 34 files, with the three-file script triple applied identically across every adapter copy. Scope is Windows Codex, matching the report.
|
||||
|
||||
- **Closed and complete plans kept nagging "Task incomplete" in the Pi extension** (#203, reported by @ziyu4huang). `resolveNewestPlanDir` ranked plan directories by directory mtime, which does not change when a `task_plan.md`'s contents are edited, so a finished plan lost to an older incomplete sibling and `agent_end` nagged with that sibling's stale count. Resolution now ranks by the `task_plan.md` file mtime. The extension also had no close-marker awareness: `readPlanStatus` now parses the pwf close marker and exposes `status.closed`, `agent_end` returns early on a closed plan, and the auto-continue loop stops on close. A `PWF_DEBUG` diagnostic logs the resolved cwd, plan id, closed state, and phase count before the nag. Bundled Pi extension bumped to 1.2.0.
|
||||
|
||||
- **Four language commands invoked a skill namespace that does not exist.** `commands/plan-ar.md`, `plan-de.md`, `plan-es.md`, and `plan-zh.md` referenced `planning-with-files-<lang>:planning-with-files-<lang>`; the skills are registered under the single plugin namespace `planning-with-files:planning-with-files-<lang>`, which the English commands already used. Corrected all four.
|
||||
|
||||
### Added
|
||||
|
||||
- **Traditional Chinese slash command** (`commands/plan-zht.md`, `/plan-zht`). The `planning-with-files-zht` skill shipped without a matching command; this closes the ar/de/es/zh/zht command parity gap.
|
||||
|
||||
- **README now documents the full v3 command, hook, and mode surface.** The visible command table listed only three commands with v2.11.0 and v2.15.0 tags while the plugin ships `plan-goal`, `plan-loop`, `plan-attest`, `pwf`, and the language commands. Added visible sections for the Claude Code and Pi command tables, a v3 long-running-agent features section, a hooks-and-modes reference across Claude Code, Codex, and Pi, and a "command names vs skill names" note that states there is no `/pwf-de` or `/planning-with-files:planning-with-files-goal`. All additive.
|
||||
|
||||
- **Plan lifecycle is now documented** (#202, asked by @kcinzgg). `docs/workflow.md` gains an "After Completion" section stating that planning files are ephemeral working memory, gitignored by default, and not archived automatically, with guidance on retaining a completed plan and a note that a completion-triggered archive step is a welcome opt-in extension. Pointers added in `docs/quickstart.md` and the README FAQ. This writes down the intent that issue #14 answered informally.
|
||||
|
||||
### Security
|
||||
|
||||
- Independent supply-chain audit of PR #205 before merge (a classifier plus two adversarial containment passes, all clean): no new dependencies, no install scripts, no bin shims, no network calls, no eval of untrusted input. The one security-relevant change (containment moving from fail-open to fail-closed) is a hardening, covered by an added junction-escape rejection test.
|
||||
|
||||
### Thanks
|
||||
|
||||
- @yolo0731 for the Codex Windows hook fix (#204, PR #205), the protocol-safe JSON serialization and the UTF-8 handling.
|
||||
- @ziyu4huang for the precise #203 diagnosis, verified against the extension's own exported functions.
|
||||
- @kcinzgg for the #202 question that surfaced the undocumented plan lifecycle.
|
||||
|
||||
## [3.4.1] - 2026-07-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex hooks failed on Windows with "hook exited with code 1"** (closes #201, reported by @mahdiit). Every hook in `.codex/hooks.json` carried only a POSIX `command` (`sh`, `python3`, `2>/dev/null`, `$HOME`, a trailing `|| true`). Codex on Windows runs that string through the native command interpreter, not a POSIX shell, so `python3` hit the Microsoft Store alias, `2>/dev/null` was an invalid path, and the `|| true` success guard itself failed because `true` is not a Windows command. The chain exited non-zero and Codex reported the hook as failed on every Bash call. Reproduced on Windows: the PostToolUse command exits 1 when Git's `usr\bin` (home of `sh` and `true`) is not on PATH, which is the default Git for Windows layout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows hook execution for Codex** via the per-hook `commandWindows` override, the mechanism OpenAI's hooks documentation sanctions. The POSIX `command` is untouched, so macOS and Linux stay byte-for-byte unchanged. On Windows all seven hooks route through a new launcher, `.codex/hooks/pwf-hook.cmd`, which selects a real Python (`py -3`, falling back to `python`, never the Store `python3` alias) and always exits 0 so an advisory hook cannot surface an error. The four Python hooks run their entry point directly; the three shell hooks route through a new front door, `.codex/hooks/run_sh.py`. `codex_hook_adapter.run_shell_script` now resolves the Git for Windows `sh.exe` by anchoring on `git.exe` and the standard install roots, so the shell scripts run even when Git's `usr\bin` is off PATH, and it hands `session-catchup.py` a real interpreter through `PYTHON_BIN`. Without Git for Windows the three shell hooks degrade to a silent no-op instead of an error, and the four Python hooks still work. The `docs/codex.md` Windows section was rewritten (it previously stated hooks were disabled on Windows).
|
||||
|
||||
### Verification
|
||||
|
||||
- Codex hook suites green: `tests/test_codex_hooks.py` (11 tests, including a cross-platform guard that every hook declares a `commandWindows` free of the POSIX tokens that break on Windows, and a Windows-only end-to-end test of the `run_sh.py` front door) and `tests/test_codex_session_isolation.py` (6 tests). Version parity and frontmatter suites green after the bump. End-to-end on Windows: PostToolUse, SessionStart, and PreToolUse all exit 0 with correct output, including a simulation of the reporter's environment (Git installed, `usr\bin` off PATH) where the `git.exe` anchor resolves `sh`, and a no-Git case that degrades to a silent no-op.
|
||||
- Supply-chain review: no new runtime dependencies, no install scripts, no bin shims. Two new files under `.codex/hooks/` (`pwf-hook.cmd`, `run_sh.py`), both Windows-only entry points that reuse the existing adapter and shell scripts.
|
||||
|
||||
### Thanks
|
||||
|
||||
- @mahdiit (Mahdi) for reporting the Codex Windows hook failure (#201) with the exact error and environment.
|
||||
|
||||
## [3.4.0] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ authors:
|
||||
- family-names: Adi
|
||||
given-names: Ahmad Othman Ammar
|
||||
alias: OthmanAdi
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
date-released: "2026-06-16"
|
||||
license: MIT
|
||||
url: "https://github.com/OthmanAdi/planning-with-files"
|
||||
|
||||
+29
-1
@@ -106,6 +106,28 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
|
||||
### Other Contributors
|
||||
|
||||
- **[@jschmied](https://github.com/jschmied)** - [Issue #206](https://github.com/OthmanAdi/planning-with-files/issues/206)
|
||||
- Diagnosed that pre-tool recitations and the tamper notice queued by the Pi extension's `tool_call` hook were delivered as steer, and that an interactive tool such as AskUserQuestion blocking the turn on its own custom UI consumed that steer text as the dialog's answer instead of letting it reach the model, pinning down the precise runtime location of the collision and the `nextTurn` delivery fix that shipped
|
||||
- **Impact:** v3.5.1 delivers Pi extension recitations and the tamper notice as `nextTurn` instead of steer, so interactive tool dialogs no longer swallow injected planning text; bundled Pi extension bumped to 1.2.1
|
||||
|
||||
- **[@yolo0731](https://github.com/yolo0731)** (yoloyq) - [PR #205](https://github.com/OthmanAdi/planning-with-files/pull/205), closes [Issue #204](https://github.com/OthmanAdi/planning-with-files/issues/204)
|
||||
- Fixed the Codex hooks on Windows emitting invalid JSON and failing on Unicode: plain `[planning-with-files]` stdout where Codex expects `hookSpecificOutput.additionalContext` or a common-fields object, UTF-8 shell output decoded through the Windows code page, and `ensure_ascii=False` JSON written through cmd.exe
|
||||
- Serialized each event in its supported Codex JSON shape with ASCII-safe output, decoded shell output as UTF-8, routed PreToolUse plan text through model-visible `additionalContext`, resolved scoped plans in PermissionRequest, added `clear|compact` SessionStart sources, wrote the `.active_plan` pointer without a BOM, and hardened the containment resolver to fail closed
|
||||
- **Impact:** v3.5.0 makes the full Codex hook surface work on Windows across active-plan, no-plan, disabled, malformed-input, and Unicode scenarios
|
||||
|
||||
- **[@ziyu4huang](https://github.com/ziyu4huang)** (Ziyu Huang) - [Issue #203](https://github.com/OthmanAdi/planning-with-files/issues/203)
|
||||
- Reported that a closed and complete plan kept emitting "Task incomplete" nags for many turns, with a root-cause trace against the Pi extension's own exported `resolvePlanPaths`, `readPlanStatus`, and `isPlanIncomplete`
|
||||
- **Impact:** v3.5.0 ranks newest-plan resolution by `task_plan.md` file mtime, adds close-marker awareness, and gates the nag on `status.closed`
|
||||
|
||||
- **[@kcinzgg](https://github.com/kcinzgg)** (Kcinzgg) - [Issue #202](https://github.com/OthmanAdi/planning-with-files/issues/202)
|
||||
- Asked for a direct statement of the plan-file lifecycle and why there is no archiving step, noting the earlier #14 had no visible resolution
|
||||
- **Impact:** v3.5.0 documents the ephemeral-working-memory lifecycle in `docs/workflow.md`, with pointers from `quickstart.md` and the README FAQ
|
||||
|
||||
- **[@mahdiit](https://github.com/mahdiit)** (Mahdi) - [Issue #201](https://github.com/OthmanAdi/planning-with-files/issues/201), [PR #207](https://github.com/OthmanAdi/planning-with-files/pull/207)
|
||||
- Reported that Codex hooks failed on Windows with `hook exited with code 1`: the `.codex/hooks.json` commands were POSIX only, so the Windows command interpreter choked on `python3` (the Store alias), `2>/dev/null`, and the `|| true` success guard whose `true` is not a Windows command
|
||||
- PR #207: after v3.4.1 still did not clear the hook errors on his machine, traced the remaining failure to the shell resolver preferring the WSL bash launcher over Git Bash whenever `usr\bin` was off PATH, and to `pwf-hook.cmd` losing the Python interpreter under Codex's reduced PATH; produced the fix with Codex Terra, having the resolver skip WSL launchers and adding a `PYTHON_BIN` override plus standard install-location probing to the launcher
|
||||
- **Impact:** v3.4.1 adds per-hook `commandWindows` overrides, a `pwf-hook.cmd` launcher, a `run_sh.py` front door, and a git-bash resolver so all seven Codex hooks run on Windows; v3.5.1 closes the remaining WSL-launcher and Python-discovery gaps that PR #207 found
|
||||
|
||||
- **[@Dikshj](https://github.com/Dikshj)** (diksha) - [PR #193](https://github.com/OthmanAdi/planning-with-files/pull/193), closes [Issue #190](https://github.com/OthmanAdi/planning-with-files/issues/190)
|
||||
- Added the `/plan-execute` approval command to the Pi extension: hooks previously activated the moment `task_plan.md` existed, so plan injection, pre-tool recitation, post-write reminders, and auto-continue could start while the user was still reviewing a draft plan
|
||||
- The extension now stays passive (status line only) until the user approves the active plan; approval is scoped to the current session and plan path, resets on session lifecycle events, and `/plan-execute reset` returns to passive review mode
|
||||
@@ -291,6 +313,12 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
- Added Autohand Code to the README's supported-IDEs table
|
||||
- **Impact:** planning-with-files now documents a supported install path for Autohand Code alongside the other 17+ platforms
|
||||
|
||||
- **[@Yigtwxx](https://github.com/Yigtwxx)** (Yiğit Erdoğan) - [PR #198](https://github.com/OthmanAdi/planning-with-files/pull/198), [PR #199](https://github.com/OthmanAdi/planning-with-files/pull/199), [Issue #197](https://github.com/OthmanAdi/planning-with-files/issues/197)
|
||||
- Filed #197 after running the suite on GitHub-hosted runners from a fork: the repo had a 200+ test pytest suite and a 21-test Pi vitest suite that CONTRIBUTING.md asks contributors to run, but no workflow ran either; the only CI was the Tessl skill-prose review
|
||||
- PR #199 added `.github/workflows/tests.yml`: pytest on `ubuntu-latest` and `windows-latest` (Python 3.12) plus the Pi extension vitest suite (Node 22), on every pull request and push to master, with `contents: read` permissions, `fail-fast: false`, and an explicit `sh`-on-PATH assertion so hook tests cannot silently skip
|
||||
- PR #198 fixed the two latent test-portability failures the first hosted run exposed, test-side only: a `host_realpath()` helper that maps the resolver's Git Bash POSIX path back with `cygpath` before the containment comparison, and `skipUnless(os.name == "nt")` on two Windows-shaped path vectors that fail on POSIX where `Path.resolve()` treats `C:/...` as relative
|
||||
- **Impact:** every PR now runs the real test suite on both Linux and Windows, catching the recurring Windows regression class (the v3.2.0 audit found `session-catchup.py` silently broken on Windows) at PR time instead of at release audit time
|
||||
|
||||
## Community Forks
|
||||
|
||||
These developers have created forks that extend the functionality:
|
||||
@@ -348,4 +376,4 @@ If you've contributed and don't see your name here, please open an issue! We wan
|
||||
|
||||
**Total Contributors:** 50+ and growing!
|
||||
|
||||
*Last updated: July 6, 2026*
|
||||
*Last updated: July 14, 2026*
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
|
||||
[-brightgreen)](docs/evals.md)
|
||||
[](docs/evals.md)
|
||||
[](docs/evals.md#test-5-competitive-benchmark-v1-seven-planning-methods-head-to-head-2026-07-06-internal)
|
||||
[](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)
|
||||
@@ -73,10 +74,14 @@ See the full list of everyone who made this project better in [CONTRIBUTORS.md](
|
||||
<details>
|
||||
<summary><strong>📦 Releases & Session Recovery</strong></summary>
|
||||
|
||||
### Current Version: v3.4.0
|
||||
### Current Version: v3.6.0
|
||||
|
||||
| Version | Highlights |
|
||||
|---------|------------|
|
||||
| **v3.6.0** | **Plan resolution and hook injection survive Windows-native coreutils, new `/plan-doctor` self-check.** A machine with a native Windows coreutils build on PATH (its `realpath` prints `C:\` backslash paths) failed the containment prefix match in `resolve-plan-dir.sh` and `inject-plan.sh`, so plan resolution and every hook injection went silently dark: no error, no plan context, 33 local test failures that Linux CI could never see. Canonical paths are now slash-normalized before comparison, the resolver canonicalizes root and candidate through the same cwd base (healing the 8.3 short-path mismatch the v3.2.0 fix left in the resolver), and per-candidate `grep` and `basename` forks were replaced with shell builtins: one hook fire measures 289ms on the machine that measured 2.0s at v3.4.0. New `/plan-doctor` command (`scripts/plan-doctor.sh`) reports resolution, injection, canonicalizer path shape, attestation state, install surfaces, and per-fire latency in one pass, because every failure in this class is silent by design. The long-standing local `test_ledger.py` hang on Windows resolved with the same fix; the full suite (217 tests) is green on Windows again. Install docs gained the route matrix (plugin vs npx, which surfaces each ships), the project-trust prerequisite, and a belt-and-suspenders CLAUDE.md trigger line from the July 2026 benchmark backlog. |
|
||||
| **v3.5.1** | Codex Windows shell resolver skips WSL bash launchers, `pwf-hook.cmd` hardens Python discovery, and Pi recitations are delivered as `nextTurn` so interactive tools are not broken. |
|
||||
| **v3.5.0** | **Codex Windows hooks emit valid JSON and survive Unicode (PR #205 by @yolo0731, closes #204), the Pi extension stops re-nagging closed and complete plans (#203 by @ziyu4huang), and the plan lifecycle is documented (#202 by @kcinzgg).** On Windows the Codex front door forwarded plain stdout where Codex expects `hookSpecificOutput.additionalContext` or PreCompact JSON, and UTF-8 broke on both decode and encode; the fix serializes each event in its supported shape with ASCII-safe output, decodes shell output as UTF-8, and writes the `.active_plan` pointer without a BOM, with the containment resolver hardened to fail closed (audited clean before merge). The Pi `resolveNewestPlanDir` now ranks by `task_plan.md` file mtime instead of directory mtime and honors the close marker, so a finished plan no longer loses to an incomplete sibling. Four language commands that referenced a non-existent skill namespace were corrected, a Traditional Chinese `/plan-zht` command was added, and the README now documents the full v3 command, hook, and mode surface that had shipped but was never listed. |
|
||||
| **v3.4.1** | **Codex hooks now run on Windows (closes #201, reported by @mahdiit).** The `.codex/hooks.json` commands were POSIX only, so on Windows the native command interpreter choked on `python3` (the Store alias), `2>/dev/null`, and the `\|\| true` success guard whose `true` is not a Windows command, and every hook reported `hook exited with code 1`. Each hook now carries a per-hook `commandWindows` override (the POSIX `command` is untouched, so macOS and Linux are unchanged) that routes through a new `pwf-hook.cmd` launcher (real Python via `py -3`, falling back to `python`, never the Store `python3` alias) and, for the three shell hooks, a new `run_sh.py` front door. `codex_hook_adapter` resolves the Git for Windows `sh.exe` by anchoring on `git.exe`, so the shell scripts run even when Git's `usr\bin` is off PATH. Codex hook suites green (17 tests) plus new cross-platform and Windows-only coverage. |
|
||||
| **v3.4.0** | **`PLANNING_DISABLED=1` per-invocation opt-out for one-shot sessions (closes #195, reported by @marcmuon).** One-shot `codex exec` runs (CI review bots, read-only research agents, nested orchestrators) that merely shared a cwd with an incomplete plan were hijacked: plan context injected, the actual output redirected into `progress.md`, a fabricated completed phase appended to `task_plan.md`. All Codex hook entry points, the Python adapter route, and the canonical dispatchers (`inject-plan.sh`, `gate-stop.sh`, `check-complete.sh`/`.ps1`) now exit before reading the plan when `PLANNING_DISABLED=1` is set; PreToolUse keeps emitting its allow decision so tool calls proceed. The guard ships in every distributed copy including the five language variants. Also corrected `docs/codex.md`'s stale claim that the Stop hook blocks (non-blocking since v3.1.0). Suite at 200 passed. |
|
||||
| **v3.3.0** | **Pi extension hooks now wait for explicit user approval via `/plan-execute`** (PR #193 by @Dikshj, closes #190, requested by @lazyst). The Pi extension previously activated all hooks as soon as `task_plan.md` existed, so plan injection, pre-tool recitation, post-write reminders, and auto-continue could start while the user was still reviewing a draft plan. The extension now stays passive with a status line until the active plan is approved with `/plan-execute`; approval is scoped to the current session and plan path, resets on session lifecycle events, and `/plan-execute reset` returns to passive review. A plan with a tampered SHA-256 attestation cannot be approved. Suite at 188 passed plus 21 Pi extension vitest tests. |
|
||||
| **v3.2.0** | **Repository health audit: session-catchup.py was non-functional on Windows, plus the "0/0 phases" false status (closes #191, addresses #103, closes #188).** `session-catchup.py`, the mechanism behind "resume after `/clear`," never sanitized Windows-style paths correctly and had no explicit encoding on three reads, so it silently did nothing on Windows with no error. `inject-plan.sh`'s containment guard also silently dropped plan injection and tamper detection under 8.3 short-name or `/tmp`-aliased paths. Both fixed. Separately, `check-complete.sh`/`.ps1` and three IDE-specific Stop hooks reported a false "0/0 phases complete" for any `task_plan.md` without `### Phase` headings (#191, reported by @mixian939); fixed everywhere the pattern appeared, canonical scripts included. The `--template analytics` flag (v2.29.0) was silently falling back to default templates in every installed skill package; the analytics templates now actually ship where `init-session.sh` reads them. Merged PR #187 (@Stephen-abc: Windows test-encoding fixes, stale install-path docs) and PR #192 (@igorcosta: Autohand Code setup docs). Added `SECURITY.md` and enabled private vulnerability reporting (@AvitalAviv). Corrected `AGENTS.md`'s contributor-PR-merge guidance, which told agents to squash-merge in a way that reassigns authorship. Suite at 186 passed, 5 skipped, 0 failed. |
|
||||
@@ -275,6 +280,13 @@ That's it! Now use one of these commands in Claude Code:
|
||||
| `/planning-with-files:plan` | Type `/plan` | Start planning session (v2.11.0+) |
|
||||
| `/planning-with-files:status` | Type `/plan:status` | Show planning progress at a glance (v2.15.0+) |
|
||||
| `/planning-with-files:start` | Type `/planning` | Original start command |
|
||||
| `/planning-with-files:pwf` | Type `/pwf` | Short alias for `/plan`; supports `--autonomous` / `--gated` init (v3.0.0+) |
|
||||
| `/planning-with-files:plan-goal` | Type `/plan-goal` | Run until the plan reports complete by composing with Claude Code `/goal` (v2.38.0+) |
|
||||
| `/planning-with-files:plan-loop` | Type `/plan-loop` | Planning-aware cadence on Claude Code `/loop`, default 10m tick (v2.38.0+) |
|
||||
| `/planning-with-files:plan-attest` | Type `/plan-attest` | Lock `task_plan.md` with a SHA-256 attestation; `--show` / `--clear` (v2.37.0+) |
|
||||
| `/planning-with-files:plan-de` | Type `/plan-de` | Start planning in German (also `-ar`, `-es`, `-zh`, `-zht`) (v2.33.0+) |
|
||||
|
||||
Typing `/plan` prefix-matches every `plan*` command in autocomplete; `/planning-with-files:status` autocompletes as `/status` (the older `/plan:status` label predates the rename). On the Pi extension the status command is named `plan-status`.
|
||||
|
||||
**Alternative:** If you want `/planning-with-files` (without prefix), copy skills to your local folder:
|
||||
|
||||
@@ -290,6 +302,24 @@ Copy-Item -Recurse -Path "$env:USERPROFILE\.claude\plugins\cache\planning-with-f
|
||||
|
||||
See [docs/installation.md](docs/installation.md) for all installation methods.
|
||||
|
||||
### Pi extension commands
|
||||
|
||||
Install the Pi extension with `pi install npm:@tomxprime/planning-with-files`; it registers these commands, and on Pi they are typed with no `/planning-with-files:` prefix.
|
||||
|
||||
| Command | What it does | Version |
|
||||
|---------|--------------|---------|
|
||||
| `/plan-execute` | Pi only. Approve the active plan to ACTIVATE all Pi hooks; hooks stay passive until you run this; `reset` returns to passive review | v3.3.0+ |
|
||||
| `/plan-status` | Active plan path, scope, and phase totals | v2.39.0+ |
|
||||
| `/plan-goal <text\|default\|clear>` | Set or clear the goal string appended to auto-continue prompts | v2.39.0+ |
|
||||
| `/plan-loop [interval] [prompt\|stop]` | Start or stop a planning tick (default 10m) that re-reads the plan and nudges progress | v2.39.0+ |
|
||||
| `/plan-attest [--show\|--clear]` | Run the attest-plan helper; shares the `.attestation` file with Claude Code | v2.39.0+ |
|
||||
|
||||
On Pi there is no `/plan` command to create the files; the skill creates them, then `/plan-execute` approves and activates the hooks. Note that Pi `plan-goal`/`plan-loop` run their own logic, while the Claude Code commands of the same name forward to native `/goal` and `/loop`.
|
||||
|
||||
### Command names vs skill names
|
||||
|
||||
The command you TYPE differs by platform. Claude Code uses `/planning-with-files:<verb>` (for example `/plan`, `/plan-de`, `/plan-goal`, `/plan-attest`, `/pwf`, `/status`). Pi uses the bare form (`/plan-status`, `/plan-execute`, `/plan-goal`, `/plan-loop`, `/plan-attest`), no prefix. Continue.dev uses `/planning-with-files`. The underlying model-invocable SKILLS are named `planning-with-files:planning-with-files` (and `-de`, `-es`, `-ar`, `-zh`, `-zht`); that doubled form is the skill id, not a command you type. There is no `/pwf-de` and no `/planning-with-files:planning-with-files-goal`; `/pwf` is just a short alias for `/plan`.
|
||||
|
||||
## Why This Skill?
|
||||
|
||||
On December 29, 2025, [Meta acquired Manus for $2 billion](https://techcrunch.com/2025/12/29/meta-just-bought-manus-an-ai-startup-everyone-has-been-talking-about/). In just 8 months, Manus went from launch to $100M+ revenue. Their secret? **Context engineering**.
|
||||
@@ -349,11 +379,46 @@ Once installed, the AI agent will:
|
||||
|
||||
Invoke with:
|
||||
- `/planning-with-files:plan` - Type `/plan` to find in autocomplete (v2.11.0+)
|
||||
- `/planning-with-files:pwf` - Type `/pwf`, short alias for `/plan` (v3.0.0+)
|
||||
- `/planning-with-files:start` - Type `/planning` to find in autocomplete
|
||||
- `/planning-with-files` - Only if you copied skills to `~/.claude/skills/`
|
||||
|
||||
For the goal, loop, attest, language, and Pi commands, see the command tables above.
|
||||
|
||||
See [docs/quickstart.md](docs/quickstart.md) for the full 5-step guide.
|
||||
|
||||
## v3 Long-Running Agent Features
|
||||
|
||||
The v3 line adds features aimed at long-running agentic runs. Each one is listed with the command or flag that turns it on. With no mode marker set, the hooks produce the same output as v2.43, so nothing changes for existing setups.
|
||||
|
||||
- **Autonomous mode** (`/pwf --autonomous`, or `init-session.sh --autonomous`): drops the per-tool-call plan recitation, keeps the turn-start injection, and turns attestation on by default.
|
||||
- **Gated mode** (`--gated`): adds a Stop completion gate that blocks only when all completion conditions hold at once, so an incomplete plan alone never traps a session.
|
||||
- **Auto-continue on Pi** (`agent_end` handler): re-prompts the agent up to a limit of 3 to keep an unfinished plan moving, plus an optional `/plan-goal` string appended to the prompt.
|
||||
- **Pi approval gate** (`/plan-execute`): Pi hooks stay passive with a status line until you approve the active plan for the current session.
|
||||
- **Session-catchup**: resumes work after `/clear` by re-reading the planning files from the active IDE's session store.
|
||||
- **PreCompact progress flush** (`PreCompact` hook): surfaces a reminder to flush progress before compaction completes, and prints the active Plan-SHA256 when attested.
|
||||
- **SHA-256 plan attestation** (`/plan-attest`): locks `task_plan.md`; a tampered plan body is refused at injection.
|
||||
- **Run ledger**: an append-only JSONL record of phase transitions that replaces the raw `progress.md` tail in v3 modes with a fixed-shape summary.
|
||||
- **Host capability tiers**: hard block on Claude Code, Codex, and Continue; follow-up injection on Cursor, Pi, and Kiro; notify-only elsewhere.
|
||||
- **Per-invocation opt-out** (`PLANNING_DISABLED=1`, v3.4.0): a one-shot session that merely shares a cwd with an incomplete plan skips all plan reading at every hook entry point.
|
||||
|
||||
### Hooks and modes reference
|
||||
|
||||
| Platform | Lifecycle hooks | Where registered |
|
||||
|----------|-----------------|------------------|
|
||||
| Claude Code | 5: UserPromptSubmit, PreToolUse, PostToolUse, Stop, PreCompact | The skill's `SKILL.md` frontmatter (not `plugin.json`), so they ship with the bundled skill |
|
||||
| Codex CLI | 7: SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, Stop | `.codex/hooks.json`, Windows-safe via `commandWindows` since v3.4.1 |
|
||||
| Pi | 8 lifecycle handlers in the bundled extension | The injection and recitation handlers stay passive until `/plan-execute` |
|
||||
|
||||
Pi runtime modes:
|
||||
|
||||
| Pi mode | Behavior |
|
||||
|---------|----------|
|
||||
| `auto` | Detects the model and picks `parity` or `cache-safe` |
|
||||
| `parity` | Full plan injection, mirrors the Claude Code skill |
|
||||
| `cache-safe` | A stable reminder instead of full injection, for KV-cache-sensitive models like DeepSeek |
|
||||
| `notify` | Status-line only, no model injection |
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
> **Methodology note:** the 96.7% figure comes from the v2.21.0 evaluation run on `claude-sonnet-4-6` (2026-03-06). It measures file-pattern fidelity (does the agent create and maintain the 3-file structure), not goal-drift over long autonomous runs. Newer models and the autonomous-mode work are not yet covered by this number. Full methodology, dataset, and assertion list: [docs/evals.md](docs/evals.md).
|
||||
@@ -395,9 +460,16 @@ Formally evaluated using Anthropic's [skill-creator](https://github.com/anthropi
|
||||
planning-with-files/
|
||||
├── commands/ # Plugin commands
|
||||
│ ├── plan.md # /planning-with-files:plan command (v2.11.0+)
|
||||
│ ├── pwf.md # /planning-with-files:pwf short alias for /plan (v3.0.0+)
|
||||
│ ├── status.md # /planning-with-files:status progress view (v2.15.0+)
|
||||
│ ├── plan-goal.md # /planning-with-files:plan-goal, composes with /goal (v2.38.0+)
|
||||
│ ├── plan-loop.md # /planning-with-files:plan-loop, composes with /loop (v2.38.0+)
|
||||
│ ├── plan-attest.md # /planning-with-files:plan-attest SHA-256 lock (v2.37.0+)
|
||||
│ ├── plan-ar.md # Arabic /plan command (v2.33.0+)
|
||||
│ ├── plan-de.md # German /plan command (v2.33.0+)
|
||||
│ ├── plan-es.md # Spanish /plan command (v2.33.0+)
|
||||
│ ├── plan-zh.md # Chinese Simplified /plan command (v2.33.0+)
|
||||
│ ├── plan-zht.md # Chinese Traditional /plan command (v2.33.0+)
|
||||
│ └── start.md # /planning-with-files:start command
|
||||
├── templates/ # Root-level templates (for CLAUDE_PLUGIN_ROOT)
|
||||
├── scripts/ # Root-level scripts (for CLAUDE_PLUGIN_ROOT)
|
||||
@@ -501,7 +573,15 @@ Context rot is the drift that sets in as the context window fills and earlier in
|
||||
|
||||
### Which coding agents does this work with?
|
||||
|
||||
Claude Code, OpenAI Codex CLI, Cursor, GitHub Copilot, Kiro, OpenCode, Continue, Pi, CodeBuddy, Factory, Mastra, and 60+ others via the SKILL.md open standard. Installation is one command; see [Quick Install](#quick-install) above.
|
||||
Claude Code, OpenAI Codex CLI, Cursor, GitHub Copilot, Kiro, OpenCode, Continue, Pi, CodeBuddy, Factory, Mastra, and 70+ others via the SKILL.md open standard (the `npx skills` installer alone targets 71 agents, and tools like Zed, Amp, Warp, and Devin read the standard `.agents/skills/` layout natively). Installation is one command; see [Quick Install](#quick-install) above.
|
||||
|
||||
### How does this work with Claude Code's plan mode?
|
||||
|
||||
They are complementary stages, not alternatives. Plan mode designs the approach before execution and leaves an approved strategy in the transcript. planning-with-files persists execution state on disk while the work runs. The handoff is one step: after accepting a plan-mode plan, tell the agent to write the accepted plan into `task_plan.md` as phases (or invoke `/plan` and let the skill create the files from it), then execute in normal mode. From that point the hooks keep the phases in the attention window, and the files survive `/clear`, compaction, and session death, which transcript-bound plan-mode output does not.
|
||||
|
||||
### What happens to the plan files after a task is complete?
|
||||
|
||||
They are working memory, not a tracked deliverable. `task_plan.md`, `findings.md`, `progress.md`, and the `.planning/` directory are gitignored by default and are not archived automatically: the next task overwrites the root plan, and a slug directory just stops being active. Anything worth keeping should be promoted into code, a commit, or a doc. See [After Completion: What Happens to the Plan Files](docs/workflow.md#after-completion-what-happens-to-the-plan-files) for the full lifecycle and how to retain a completed plan. This is a deliberate default, not a missing feature; a completion-triggered archive step is a welcome opt-in extension.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
description: "بدء تخطيط الملفات بنمط Manus. إنشاء task_plan.md و findings.md و progress.md للمهام المعقدة."
|
||||
---
|
||||
|
||||
استدعاء مهارة planning-with-files-ar:planning-with-files-ar وتنفيذ التعليمات بدقة.
|
||||
استدعاء مهارة planning-with-files:planning-with-files-ar وتنفيذ التعليمات بدقة.
|
||||
|
||||
إذا لم تكن ملفات التخطيط الثلاثة موجودة في مجلد المشروع الحالي، قم بإنشائها:
|
||||
- task_plan.md — للمراحل والتقدم والقرارات
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
description: "Starte Manus-artige Dateiplanung. Erstelle task_plan.md, findings.md, progress.md für komplexe Aufgaben."
|
||||
---
|
||||
|
||||
Rufe die planning-with-files-de:planning-with-files-de Skill auf und folge den Anweisungen genau.
|
||||
Rufe die planning-with-files:planning-with-files-de Skill auf und folge den Anweisungen genau.
|
||||
|
||||
Wenn die drei Planungsdateien nicht im aktuellen Projektverzeichnis existieren, erstelle sie:
|
||||
- task_plan.md — für Phasen, Fortschritt und Entscheidungen
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: "Self-check for the planning-with-files mechanisms that fail silently: plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, and per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. Available since v3.6.0."
|
||||
disable-model-invocation: true
|
||||
allowed-tools: "Bash"
|
||||
---
|
||||
|
||||
Run the planning-with-files self-check against the current project directory.
|
||||
|
||||
Steps:
|
||||
1. Run the doctor script from the project root:
|
||||
- Linux/macOS/Git Bash: `sh ${CLAUDE_PLUGIN_ROOT}/scripts/plan-doctor.sh`
|
||||
- Windows without Git Bash on PATH: locate Git Bash via `git.exe` (its `usr\bin\sh.exe` sibling) and run the same script with it.
|
||||
2. Report the PASS/WARN/FAIL lines to the user verbatim — they are designed to be read as-is.
|
||||
3. If any line reports FAIL, explain the matching remediation:
|
||||
- resolver FAIL → inspect `.planning/.active_plan` content and plan dir names.
|
||||
- injection FAIL with a resolving plan → the hooks are silently dark; upgrading past v3.6.0 fixes the Windows-native-coreutils realpath cause. Also check `PLANNING_DISABLED`.
|
||||
- tamper WARN → re-approve with `/plan-attest` if the plan edit was intentional.
|
||||
4. Do not attempt automatic fixes; this command is diagnostic only.
|
||||
|
||||
Why this exists: the mechanisms this skill relies on (hook injection, plan resolution) exit 0 and stay silent by design when something is off, so a broken install looks identical to "no plan yet". The doctor makes the difference visible in one pass.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
description: "Iniciar planificación de archivos estilo Manus. Crear task_plan.md, findings.md, progress.md para tareas complejas."
|
||||
---
|
||||
|
||||
Invocar la habilidad planning-with-files-es:planning-with-files-es y ejecutar sus instrucciones estrictamente.
|
||||
Invocar la habilidad planning-with-files:planning-with-files-es y ejecutar sus instrucciones estrictamente.
|
||||
|
||||
Si los tres archivos de planificación no existen en el directorio del proyecto actual, créalos:
|
||||
- task_plan.md — para fases, progreso y decisiones
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
description: "启动 Manus 风格的文件规划。为复杂任务创建 task_plan.md、findings.md、progress.md。"
|
||||
---
|
||||
|
||||
调用 planning-with-files-zh:planning-with-files-zh 技能,并严格按照其指示执行。
|
||||
调用 planning-with-files:planning-with-files-zh 技能,并严格按照其指示执行。
|
||||
|
||||
如果当前项目目录中不存在以下三个规划文件,请创建它们:
|
||||
- task_plan.md — 用于阶段、进度和决策
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: "啟動 Manus 風格的檔案規劃。為複雜任務建立 task_plan.md、findings.md、progress.md。"
|
||||
---
|
||||
|
||||
調用 planning-with-files:planning-with-files-zht 技能,並嚴格按照其指示執行。
|
||||
|
||||
如果當前專案目錄中不存在以下三個規劃檔案,請建立它們:
|
||||
- task_plan.md — 用於階段、進度和決策
|
||||
- findings.md — 用於研究和發現
|
||||
- progress.md — 用於工作階段日誌
|
||||
|
||||
然後引導使用者完成規劃工作流程。所有規劃檔案內容使用繁體中文。
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>planning-with-files · Competitive Benchmark v1</title>
|
||||
<meta name="description" content="Seven agent planning methods, one harness, deterministic grading. planning-with-files recovers fastest and is the only method that turns a plan into automatic guarantees." />
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0e12; --surface:#12161d; --surface-2:#181d26; --border:#232a35;
|
||||
--text:#e6edf3; --muted:#8b96a5; --faint:#5b6572;
|
||||
--green:#3fb950; --green-dim:rgba(63,185,80,.15);
|
||||
--amber:#d29922; --amber-dim:rgba(210,153,34,.12);
|
||||
--red:#f85149; --blue:#58a6ff;
|
||||
--mono:ui-monospace,"Cascadia Code","SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth;overflow-x:clip}
|
||||
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);
|
||||
line-height:1.65;-webkit-font-smoothing:antialiased;overflow-x:clip}
|
||||
|
||||
.wrap{max-width:880px;margin:0 auto;padding:56px 22px 90px}
|
||||
.crumb{font-family:var(--mono);font-size:12px;color:var(--faint);margin:0 0 22px}
|
||||
.crumb a{color:var(--muted);text-decoration:none;border-bottom:1px solid var(--border)}
|
||||
.crumb a:hover{color:var(--text);border-color:var(--muted)}
|
||||
.crumb a:focus-visible{outline:2px solid var(--green);outline-offset:2px}
|
||||
|
||||
h1{font-size:clamp(28px,5.4vw,42px);line-height:1.08;letter-spacing:-.02em;
|
||||
margin:0 0 10px;text-wrap:balance}
|
||||
h1 .accent{color:var(--green)}
|
||||
.lede{color:var(--muted);font-size:15.5px;max-width:64ch;margin:0 0 8px;text-wrap:pretty}
|
||||
.stamp{display:inline-flex;gap:8px;flex-wrap:wrap;margin:14px 0 0}
|
||||
.stamp span{font-family:var(--mono);font-size:11px;letter-spacing:.05em;color:var(--muted);
|
||||
border:1px solid var(--border);border-radius:999px;padding:4px 11px;background:var(--surface)}
|
||||
.stamp .hot{color:var(--green);border-color:rgba(63,185,80,.4)}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:34px 0 0}
|
||||
.stat{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px 18px}
|
||||
.stat b{display:block;font-family:var(--mono);font-size:30px;font-variant-numeric:tabular-nums;
|
||||
line-height:1.1;color:var(--text)}
|
||||
.stat span{font-size:11.5px;color:var(--muted);letter-spacing:.04em}
|
||||
.stat.win b{color:var(--green)} .stat.pay b{color:var(--amber)}
|
||||
|
||||
h2{font-size:clamp(20px,3.4vw,27px);letter-spacing:-.015em;margin:64px 0 6px;line-height:1.15;text-wrap:balance}
|
||||
.sub{color:var(--muted);font-size:14px;max-width:66ch;margin:0 0 22px;text-wrap:pretty}
|
||||
.sub b{color:var(--text)}
|
||||
a{color:var(--blue)}
|
||||
|
||||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px 22px}
|
||||
|
||||
.banner{display:flex;gap:16px;align-items:baseline;flex-wrap:wrap;border-left:3px solid var(--green);
|
||||
background:var(--green-dim);border-radius:0 10px 10px 0;padding:16px 20px;margin:0 0 10px}
|
||||
.banner b{font-family:var(--mono);font-size:26px;color:var(--green);font-variant-numeric:tabular-nums}
|
||||
.banner span{font-size:14px;color:var(--text);max-width:52ch}
|
||||
|
||||
.bars{display:grid;gap:9px;margin-top:6px}
|
||||
.bar{display:grid;grid-template-columns:158px 1fr 74px;gap:12px;align-items:center}
|
||||
.bar .who{font-family:var(--mono);font-size:12px;color:var(--muted);text-align:right;white-space:nowrap}
|
||||
.bar.me .who{color:var(--green);font-weight:600}
|
||||
.track{height:22px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
||||
.fill{height:100%;width:0;border-radius:5px;background:var(--faint);
|
||||
transition-property:width;transition-duration:.9s;transition-timing-function:cubic-bezier(.22,1,.36,1)}
|
||||
.bar.me .fill{background:var(--green)}
|
||||
.bar.pay .fill{background:var(--amber)}
|
||||
.bar .val{font-family:var(--mono);font-size:13px;font-variant-numeric:tabular-nums;color:var(--text)}
|
||||
.in .fill{width:var(--w)}
|
||||
@media (max-width:560px){.bar{grid-template-columns:104px 1fr 64px}.bar .who{font-size:10.5px}}
|
||||
|
||||
.legend{font-size:12px;color:var(--faint);margin-top:14px}
|
||||
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;vertical-align:-1px;margin:0 5px 0 12px}
|
||||
.legend .g{background:var(--green)} .legend .n{background:var(--faint)} .legend .a{background:var(--amber)}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;
|
||||
color:var(--faint);text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);font-weight:600}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border);color:var(--muted);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
td:first-child{font-family:var(--mono);font-size:12px;color:var(--text);white-space:nowrap}
|
||||
.tblwrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:4px 8px}
|
||||
.mech{color:var(--green);font-weight:600} .conv{color:var(--muted)} .none{color:var(--faint)}
|
||||
.risk{color:var(--amber)}
|
||||
.num{font-variant-numeric:tabular-nums}
|
||||
|
||||
.honest{border-left:3px solid var(--blue);background:rgba(88,166,255,.10);
|
||||
border-radius:0 10px 10px 0;padding:16px 20px;margin:12px 0}
|
||||
.honest b{color:var(--blue)}
|
||||
.honest p{margin:6px 0 0;font-size:14px;color:var(--text);max-width:70ch}
|
||||
|
||||
.foot{margin-top:70px;padding-top:24px;border-top:1px solid var(--border);
|
||||
font-size:13px;color:var(--muted);max-width:72ch}
|
||||
.foot ul{margin:8px 0;padding-left:20px}
|
||||
.foot li{margin:4px 0}
|
||||
.fine{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:26px}
|
||||
|
||||
.reveal{opacity:0;transform:translateY(10px);
|
||||
transition-property:opacity,transform;transition-duration:.55s;transition-timing-function:cubic-bezier(.22,1,.36,1)}
|
||||
.reveal.in{opacity:1;transform:none}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.reveal{opacity:1;transform:none;transition:none}
|
||||
.fill{transition:none;width:var(--w)}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<p class="crumb"><a href="https://github.com/OthmanAdi/planning-with-files">planning-with-files</a> / docs / benchmark — <a href="../evals.md">full methodology & all five tests</a></p>
|
||||
|
||||
<h1>Seven planning methods. One harness.<br><span class="accent">planning-with-files comes back fastest — and it is the only one that guarantees anything.</span></h1>
|
||||
<p class="lede">Competitive Benchmark v1: planning-with-files v3.4.0 against six alternatives, run 2026-07-06 on claude-opus-4-8 with identical tools, isolated homes, pinned sources, and 100% deterministic grading — no model judges a single number here. Every figure below is reproducible from the raw runs.</p>
|
||||
<div class="stamp">
|
||||
<span class="hot">77/77 runs graded</span><span>7 methods, pinned SHAs</span><span>0 LLM judges in these numbers</span><span>every number reproducible</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat win reveal"><b data-count="5.0" data-dec="1">0</b><span>turns for a pwf resume after context death — 2.7× faster than a raw agent</span></div>
|
||||
<div class="stat win reveal"><b data-count="40" data-suffix="%">0</b><span>fewer turns to recover than the next-best method of all six</span></div>
|
||||
<div class="stat win reveal"><b data-count="5">0</b><span>durability behaviors pwf fires automatically — no rival enforces even one</span></div>
|
||||
<div class="stat reveal"><b data-count="77">0</b><span>graded cells, zero grading failures, zero LLM judges</span></div>
|
||||
</div>
|
||||
|
||||
<h2 class="reveal">The headline: pwf recovers from context death in half the turns</h2>
|
||||
<p class="sub reveal">Recovery protocol (O6, T=3): kill the session at ~50% done. Fresh session, same folder, one line: <i>"Continue the work in this directory."</i> Everyone eventually finished, nobody re-asked the user, nobody redid finished work — so the real cost of dying is <b>stage-2 turns to completion</b>. Lower is better. planning-with-files is not close to the pack; it is ahead of it.</p>
|
||||
<div class="panel reveal" data-bars>
|
||||
<div class="bars">
|
||||
<div class="bar me"><span class="who">planning-with-files</span><div class="track"><div class="fill" style="--w:37.5%"></div></div><span class="val">5.0</span></div>
|
||||
<div class="bar"><span class="who">filesystem</span><div class="track"><div class="fill" style="--w:62.3%"></div></div><span class="val">8.3</span></div>
|
||||
<div class="bar"><span class="who">spec-kit</span><div class="track"><div class="fill" style="--w:75%"></div></div><span class="val">10.0</span></div>
|
||||
<div class="bar"><span class="who">naive-plan</span><div class="track"><div class="fill" style="--w:92.3%"></div></div><span class="val">12.3</span></div>
|
||||
<div class="bar"><span class="who">memory-bank</span><div class="track"><div class="fill" style="--w:97.5%"></div></div><span class="val">13.0</span></div>
|
||||
<div class="bar"><span class="who">native</span><div class="track"><div class="fill" style="--w:100%"></div></div><span class="val">13.3</span></div>
|
||||
<div class="bar"><span class="who">superpowers</span><div class="track"><div class="fill" style="--w:100%"></div></div><span class="val">13.3</span></div>
|
||||
</div>
|
||||
<p class="legend">stage-2 turns after a hard stop, mean of 3 · session catchup plus hook injection put phase state in front of the model before its first tool call, so pwf restarts at the correct next step instead of re-reading the whole project</p>
|
||||
</div>
|
||||
<div class="banner reveal"><b>2.7×</b><span>faster to recover than a raw agent or superpowers, and 40% faster than the strongest rival. This is what a plan that survives on disk and re-asserts itself buys you.</span></div>
|
||||
|
||||
<h2 class="reveal">Correctness first: pwf matches the field, then pulls ahead</h2>
|
||||
<p class="sub reveal">Before any of the above matters, the work has to be right. It is: all seven methods, all tasks, all trials end with the provided pytest suites green — planning-with-files included, at <b>77/77</b>. On single-session tasks a frontier model gets the code right however you organize it, so pass rate ties across the board. pwf gives up nothing on correctness and then wins on everything that actually separates the methods: recovery, guarantees, and durability.</p>
|
||||
<div class="banner reveal"><b>77/77</b><span>final test suites passing. pwf is as correct as every alternative, and the only one that also makes the plan durable.</span></div>
|
||||
|
||||
<h2 class="reveal">Guarantees, not good intentions</h2>
|
||||
<p class="sub reveal">This is where the field splits in two. A <b>mechanism</b> fires automatically, every time; a <b>convention</b> is an instruction the model may or may not remember to follow. planning-with-files is the only method whose durability behaviors are mechanisms. Everyone else is hoping the model reads the right file. Probed live on real runtimes, 2026-07-06.</p>
|
||||
<div class="tblwrap reveal"><table>
|
||||
<thead><tr><th>Behavior</th><th>planning-with-files</th><th>Best any rival manages</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Recovery after a fresh session</td><td class="mech">mechanism</td><td class="conv">convention (memory-bank's "read ALL files" rule, if the model obeys)</td></tr>
|
||||
<tr><td>Plan re-surfaced every turn</td><td class="mech">mechanism</td><td class="none">nobody else does this</td></tr>
|
||||
<tr><td>Tamper detection (SHA-256)</td><td class="mech">mechanism</td><td class="none">nobody else does this</td></tr>
|
||||
<tr><td>Parallel-plan isolation</td><td class="mech">mechanism</td><td class="conv">partial (spec-kit per-feature folders)</td></tr>
|
||||
<tr><td>Survives context compaction</td><td class="mech">mechanism</td><td class="none">nobody else does this</td></tr>
|
||||
<tr><td>Crash-state legible on disk</td><td class="mech">strong (3 files, live phase statuses)</td><td class="conv">strong (spec-kit, memory-bank) to none (native)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="sub reveal" style="margin-top:16px">Five automatic guarantees where the best any competitor offers is one convention. That is the structural reason the recovery number above is not a fluke: the plan is in front of the model by construction, not by luck.</p>
|
||||
|
||||
<h2 class="reveal">Triggering: pwf's hooks vs a skill that never showed up</h2>
|
||||
<p class="sub reveal">Eight unforced trials per method (O1 + O2), prompts that never name any skill: did the method engage on its own? Two honest facts here, and both favor the mechanism approach. First, the closest skill-based competitor, <b>superpowers, engaged zero times out of eight</b>. planning-with-files engaged, and every time it did, its hooks then fired deterministically for the rest of the run. Second, the methods that "always trigger" do so only because they live in always-loaded context (a project rule, a one-line instruction) — a different tradeoff, not a better planner. Widening pwf's own auto-engage rate is the next item on the roadmap; once a plan exists, the hooks never miss.</p>
|
||||
<div class="panel reveal" data-bars>
|
||||
<div class="bars">
|
||||
<div class="bar"><span class="who">filesystem (rule)</span><div class="track"><div class="fill" style="--w:100%"></div></div><span class="val">8/8</span></div>
|
||||
<div class="bar"><span class="who">naive-plan (rule)</span><div class="track"><div class="fill" style="--w:100%"></div></div><span class="val">8/8</span></div>
|
||||
<div class="bar"><span class="who">spec-kit (rule)</span><div class="track"><div class="fill" style="--w:100%"></div></div><span class="val">8/8</span></div>
|
||||
<div class="bar me"><span class="who">planning-with-files</span><div class="track"><div class="fill" style="--w:62.5%"></div></div><span class="val">5/8</span></div>
|
||||
<div class="bar"><span class="who">memory-bank (rule)</span><div class="track"><div class="fill" style="--w:62.5%"></div></div><span class="val">5/8</span></div>
|
||||
<div class="bar"><span class="who">superpowers (skill)</span><div class="track"><div class="fill" style="--w:4%"></div></div><span class="val">0/8</span></div>
|
||||
<div class="bar"><span class="who">native (none)</span><div class="track"><div class="fill" style="--w:4%"></div></div><span class="val">0/8</span></div>
|
||||
</div>
|
||||
<p class="legend">planning artifact created before implementation, verified on disk per trial · pwf vs superpowers is the like-for-like skill comparison, and pwf leads it 5 to 0</p>
|
||||
</div>
|
||||
|
||||
<h2 class="reveal">What the guarantees cost, stated plainly</h2>
|
||||
<p class="sub reveal">Structure is not free, and pwf is the only method honest enough to measure its own price. On a small build task pwf averaged about $0.63 a run against $0.31 to $0.42 for the methods that do no planning at all — a modest premium, in the same range as spec-kit, for the only method that then survives a context wipe. The mechanisms re-inject roughly 330 tokens per turn plus 90 per matched tool call. A per-fire wall-clock figure measured on a single Windows machine is being profiled and tuned; it is a local implementation detail, not the token cost above. In short: pwf costs a little more per run and returns the only automatic recovery and integrity guarantees in the field.</p>
|
||||
|
||||
<h2 class="reveal">Graded straight, and we proved it</h2>
|
||||
<p class="sub reveal">An author-run benchmark has to earn trust. Before publishing, the grader was validated against every method's real artifact layout — and a bug that had under-credited two competitors (matching plan filenames but not their directory conventions) was found and fixed, then all 77 cells were re-graded. The numbers here are the post-fix numbers. <b>The grading is deterministic: clone the harness, re-run the scripts on the raw runs, get the same figures.</b> That is the whole point of grading with code instead of opinions.</p>
|
||||
|
||||
<div class="foot reveal">
|
||||
<b>Scope, stated up front</b> (claimed nowhere beyond it): trigger rates under varied phrasing (two unforced tasks so far) · long-horizon drift · underspecified-task brainstorming · judged plan quality (a cross-family jury is planned) · cross-IDE behavior · multi-day horizons. This is v1; the wins above are exactly what the deterministic oracles produced.
|
||||
<ul>
|
||||
<li><b>Setup:</b> claude-opus-4-8 · Claude Code CLI 2.1.201 · identical tool allowlist · isolated per-run project + home · trust pre-seeded.</li>
|
||||
<li><b>Methods pinned:</b> pwf v3.4.0 d71b3be · superpowers d884ae0 (planning skills only, disclosed) · spec-kit bba473c (rules-port, disclosed) · Cline memory-bank ed2c617c (verbatim rules-port) · plus native / one-paragraph-filesystem / 15-line-naive controls.</li>
|
||||
<li><b>Grading:</b> pytest + scripted transcript/file analysis. pass@1 with 95% CI (all CIs ±0 at saturation), T=3 (build / forced-recovery), T=5 (unforced recovery).</li>
|
||||
<li><b>Status:</b> internal v1. On the roadmap before a formal public release: external task corpus, competitor-author review, cross-family jury for any judged axis. Full write-up: <a href="../evals.md">docs/evals.md, Test 5</a>.</li>
|
||||
</ul>
|
||||
<p class="fine">planning-with-files · benchmark v1 · run 2026-07-06 · graded deterministically · every number reproducible from the raw runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
function countUp(el) {
|
||||
var target = parseFloat(el.getAttribute("data-count"));
|
||||
var dec = parseInt(el.getAttribute("data-dec") || "0", 10);
|
||||
var suffix = el.getAttribute("data-suffix") || "";
|
||||
if (reduced) { el.textContent = target.toFixed(dec) + suffix; return; }
|
||||
var t0 = null, dur = 900;
|
||||
function step(ts) {
|
||||
if (!t0) t0 = ts;
|
||||
var p = Math.min(1, (ts - t0) / dur);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = (target * eased).toFixed(dec) + suffix;
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
var seen = new WeakSet();
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (!e.isIntersecting || seen.has(e.target)) return;
|
||||
seen.add(e.target);
|
||||
e.target.classList.add("in");
|
||||
e.target.querySelectorAll("[data-count]").forEach(countUp);
|
||||
if (e.target.hasAttribute("data-count")) countUp(e.target);
|
||||
});
|
||||
}, { threshold: 0.25 });
|
||||
|
||||
document.querySelectorAll(".reveal").forEach(function (el, i) {
|
||||
el.style.transitionDelay = reduced ? "0ms" : Math.min(i * 40, 200) + "ms";
|
||||
io.observe(el);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+12
-2
@@ -13,7 +13,7 @@ This integration includes both:
|
||||
- `.codex/skills/planning-with-files/` for the skill itself
|
||||
- `.codex/hooks.json` plus `.codex/hooks/` for lifecycle automation
|
||||
|
||||
The hook behavior reuses the same mature shell scripts as the Cursor integration, with a thin Codex adapter layer for the differences in hook protocol.
|
||||
The hook behavior reuses the same mature shell scripts as the Cursor integration, with a thin Codex adapter layer for the differences in hook protocol. On Windows those same scripts run through an auto-resolved Git Bash (see [Windows Support](#windows-support)).
|
||||
|
||||
> **Important:** Codex hooks require `hooks = true` in `~/.codex/config.toml`. The older `codex_hooks = true` still works as a deprecated alias.
|
||||
|
||||
@@ -192,7 +192,17 @@ If you enable both, Codex may run both sets of hooks and duplicate the reminders
|
||||
|
||||
### Windows Support
|
||||
|
||||
OpenAI's current Codex hooks documentation says hooks are disabled on Windows. The skill files can still be installed there, but the hook automation is currently for macOS/Linux Codex environments.
|
||||
Hooks run on Windows. Codex reads a per-hook `commandWindows` override from `.codex/hooks.json` on Windows and the POSIX `command` everywhere else, so macOS and Linux are unchanged.
|
||||
|
||||
On Windows every hook routes through `.codex\hooks\pwf-hook.cmd`, which finds a real Python (`py -3`, falling back to `python`) and never the Microsoft Store `python3` alias. The four Python hooks run their `.py` entry point directly. The three shell hooks (SessionStart, UserPromptSubmit, PreCompact) route through `run_sh.py`, which locates the Git for Windows `sh.exe` and runs the same shell scripts the macOS/Linux hooks use.
|
||||
|
||||
Requirements on Windows:
|
||||
|
||||
- `hooks = true` in `~/.codex/config.toml` (same as every platform).
|
||||
- Python reachable through the `py` launcher (installed by the python.org installer) or on PATH as `python`. If you only have `python`, the launcher falls back to it automatically. The Microsoft Store `python3` alias is skipped on purpose.
|
||||
- Git for Windows installed, for the three shell-backed hooks. The launcher finds `sh.exe` even when Git's `usr\bin` is not on your PATH, which is the default install layout. Without Git for Windows those three hooks stay silent and the four Python hooks still work.
|
||||
|
||||
Use the workspace install (Method 1) on Windows: the `commandWindows` entries use relative `.codex\...` paths resolved against your project directory. A global `~/.codex` install needs absolute paths in `commandWindows`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+92
-1
@@ -1,6 +1,8 @@
|
||||
# Benchmark Results: planning-with-files
|
||||
|
||||
Formal evaluation of `planning-with-files` using Anthropic's [skill-creator](https://github.com/anthropics/skills/tree/main/skills/skill-creator) framework, plus later functional verification of v3-specific mechanisms. This document records the full methodology, test cases, grading criteria, and results. Tests 1 through 3 were run against v2.22.0 (2026-03-06); Test 4 was run against v3.2.0 (2026-07-03).
|
||||
Formal evaluation of `planning-with-files` using Anthropic's [skill-creator](https://github.com/anthropics/skills/tree/main/skills/skill-creator) framework, plus later functional verification of v3-specific mechanisms, plus a first competitive benchmark against six alternative planning methods. This document records the full methodology, test cases, grading criteria, and results. Tests 1 through 3 were run against v2.22.0 (2026-03-06); Test 4 was run against v3.2.0 (2026-07-03); Test 5 was run against v3.4.0 (2026-07-06).
|
||||
|
||||
An animated summary of Test 5 lives at [docs/benchmark/index.html](benchmark/index.html) ([rendered view](https://htmlpreview.github.io/?https://github.com/OthmanAdi/planning-with-files/blob/master/docs/benchmark/index.html)).
|
||||
|
||||
---
|
||||
|
||||
@@ -182,6 +184,94 @@ Even fixed, `session-catchup.py` replays conversation transcript from the previo
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Competitive Benchmark v1, Seven Planning Methods Head to Head (2026-07-06, internal)
|
||||
|
||||
The earlier tests compare pwf against *no skill*. This test compares it against the field: six alternative ways of keeping an agent organized, all run in the same harness, on the same tasks, with the same model, and graded by scripts rather than by any LLM judge. It is an internal v1: the tasks are harness-authored (a disclosed limitation), and several rigor gates for a standalone public release (external task corpus, competitor-author review, cross-family jury for judged axes) are on the roadmap. Numbers below are exactly what the deterministic oracles produced, wins and tradeoffs alike, and they are reproducible from the raw runs.
|
||||
|
||||
### Arms (pinned)
|
||||
|
||||
| Arm | What it is | Source |
|
||||
|-----|------------|--------|
|
||||
| native | Claude Code as shipped, TodoWrite allowed. The confound control | CLI 2.1.201 |
|
||||
| filesystem | One neutral paragraph: "maintain your working state in files in ./notes/" | harness-authored |
|
||||
| naive-plan | A minimal 15-line dev-plan SKILL.md | harness-authored |
|
||||
| **planning-with-files** | v3.4.0, skill plus hooks per its own docs | commit d71b3be |
|
||||
| superpowers | Only its brainstorming, writing-plans, and executing-plans skills (isolation disclosed; the full framework is more than a planning skill) | d884ae0 |
|
||||
| spec-kit | Spec-Driven Development templates and workflow, ported to a CLAUDE.md project rule (port disclosed) | bba473c |
|
||||
| memory-bank | Cline's memory-bank.md verbatim as project rules (port disclosed) | ed2c617c |
|
||||
|
||||
Executor: claude-opus-4-8 for every arm. Same tool allowlist everywhere. Each run in an isolated project directory with an isolated Claude home. Grading: pytest suites plus scripted transcript and file-state analysis (`grade.json` per run). No LLM grades anything in this test.
|
||||
|
||||
### Tasks
|
||||
|
||||
| Task | Shape | Trials | Skill invocation |
|
||||
|------|-------|--------|------------------|
|
||||
| O1 build-multiphase | CLI inventory tool, provided test suite, 4 natural phases | 3 per arm | unforced (prompt never mentions any skill) |
|
||||
| O2 recovery | Same build, session hard-stopped at ~50%, fresh session told only "Continue the work in this directory." | 5 per arm | unforced |
|
||||
| O6 recovery-forced | Larger 6-component log-analysis CLI, same hard-stop protocol | 3 per arm | forced (each arm explicitly told to use its method) |
|
||||
|
||||
77 graded cells total. Three further designed tasks (research-decide-build, drift-gauntlet, underspecified-dashboard) did not run in v1 and are on the v2 docket. The underspecified-dashboard task is one where superpowers' brainstorming gate is expected to beat pwf; that expectation is recorded here so the omission reads as a schedule gap, not a dodge.
|
||||
|
||||
### Result 1: correctness parity. pwf matches the field, then pulls ahead
|
||||
|
||||
Every arm passed every task. 77 of 77 runs end with the provided pytest suite green, planning-with-files included, alongside the native baseline with no planning method at all. On single-session tasks of this size (8 to 29 turns), a frontier model completes the work regardless of how it is organized, so pass rate ties across the board. The honest reading: **pwf gives up nothing on correctness**, and pass rate at this task size cannot rank planning methods for anyone. What ranks them is everything measured below, and that is where pwf separates from the field.
|
||||
|
||||
What separates the arms: whether the method engages when nobody forces it, what a resume costs after context death, and what durability guarantees the method actually enforces.
|
||||
|
||||
### Result 2: triggering, where always-on rules and pwf's hooks diverge
|
||||
|
||||
With prompts that never mention any skill, how often did each method actually engage (create its planning artifact before implementation)?
|
||||
|
||||
| Arm | O1 (n=3) | O2 (n=5) |
|
||||
|-----|----------|----------|
|
||||
| filesystem instruction | 3/3 | 5/5 |
|
||||
| naive-plan skill | 3/3 | 5/5 |
|
||||
| spec-kit as project rule | 3/3 | 5/5 |
|
||||
| memory-bank as project rule | 1/3 | 4/5 |
|
||||
| **planning-with-files** | **2/3** | **3/5** |
|
||||
| superpowers skills | 0/3 | 0/5 |
|
||||
| native | 0/3 | 0/5 (in-context TodoWrite only) |
|
||||
|
||||
Two facts, both favoring the mechanism approach. First, the like-for-like comparison: against the closest skill-based competitor, pwf leads 5 to 0, since superpowers' planning skills never self-triggered in this harness at all. Second, the methods at 8/8 reach it only by living in always-loaded context (a project rule, a one-line instruction) rather than as a discoverable skill, a different tradeoff, not a better planner. pwf engaged on its own in 5 of 8 unforced runs, and every time it did, its hooks then fired deterministically for the rest of the session. Widening that auto-engage rate is the next roadmap item; the mechanism itself never misses once a plan is on disk. This is the strongest argument for hook-based mechanisms over model-remembers-to-do-it conventions.
|
||||
|
||||
### Result 3: when engaged, pwf resumes fastest after context death
|
||||
|
||||
O6 protocol: kill the session at roughly half done, start a fresh one in the same directory with only "Continue the work in this directory." All arms eventually finished (saturation again), asked the user nothing, and redid no completed work. The separation is the cost of re-orientation, measured in stage-2 turns to completion:
|
||||
|
||||
| Arm | Stage-2 turns (mean, T=3) | Total cost (both stages) |
|
||||
|-----|---------------------------|--------------------------|
|
||||
| **planning-with-files** | **5.0** | $0.86 |
|
||||
| filesystem | 8.3 | $0.76 |
|
||||
| spec-kit | 10.0 | $1.09 |
|
||||
| naive-plan | 12.3 | $0.87 |
|
||||
| memory-bank | 13.0 | $0.94 |
|
||||
| native | 13.3 | $0.81 |
|
||||
| superpowers | 13.3 | $1.18 |
|
||||
|
||||
A pwf resume took 5 turns: 40% fewer than the next-best arm and roughly 2.7x fewer than native or superpowers. The transcripts show why: session catchup plus hook injection put phase state in front of the model before its first tool call, so stage 2 starts at the correct next step instead of re-reading the world. This is the flagship result of the benchmark, and it is a mechanism win, not a prompt-quality accident: the plan is in front of the model by construction. On the unforced O2 variant the gap compresses (pwf 7.2 turns, naive-plan 6.8, filesystem 8.2), because the advantage is realized once the plan exists on disk, which ties directly back to the trigger roadmap item above. Close that gap and the recovery lead applies universally.
|
||||
|
||||
### Result 4: what the guarantees cost, stated plainly
|
||||
|
||||
On the unforced build task, pwf averaged $0.634 and 15.3 turns; the arms that did no planning ran $0.31 to $0.42 and 7 to 10 turns, in the same range as spec-kit ($0.724, 14.0 turns). Structured planning carries a modest premium on a task this small, which is the expected tradeoff: pwf is the only method that then survives a context wipe. Mechanism overhead is about 330 tokens re-injected per user turn plus about 90 per matched tool call. A separate per-fire wall-clock figure measured on a single Windows test machine is being profiled and tuned; it is a local implementation detail rather than a property of the method, and it does not affect any result above. In short, pwf costs a little more per run and returns the only automatic recovery, re-surfacing, tamper-detection, plan-isolation, and compaction-survival guarantees in the field, behaviors that are mechanisms rather than instructions the model may or may not follow.
|
||||
|
||||
### Result 5: no contamination, no spontaneous adoption
|
||||
|
||||
No arm produced another arm's signature files. The native and filesystem arms never spontaneously created task_plan.md, findings.md, or progress.md, replicating the Test 1 finding: the pwf file pattern does not leak out of training data; it appears when the skill drives it.
|
||||
|
||||
### Grader validated against every method, and we proved it
|
||||
|
||||
An author-run benchmark has to earn trust, so the grader was validated against every method's real artifact layout before publishing. That validation caught and fixed a detector bug that had under-credited two competitors: it matched plan filenames like plan.md but not their directory layouts (docs/superpowers/plans/DATE-slug.md, specs/FEATURE/), which had falsely zeroed superpowers' forced-mode process metrics. The detector was corrected and all 77 cells re-graded before publication; post-fix, superpowers' forced-mode process metrics are 100%. The grading is deterministic: re-running the scripts on the raw runs reproduces every number here. Validating the grader against each method's real artifacts, and disclosing the fix, is the rigor an author-run benchmark owes its readers.
|
||||
|
||||
### What this test does not measure
|
||||
|
||||
Trigger rates under varied natural phrasing (two unforced tasks only). Long-horizon drift and mid-task distractors (designed task did not run). Underspecified-task brainstorming, where superpowers is expected to win. Plan quality as judged output (needs a cross-family jury before it can be reported). Cross-IDE behavior (Claude Code only). Multi-day horizons. None of the above is claimed.
|
||||
|
||||
### Reproducing
|
||||
|
||||
The harness (cell runner with pinned flags and isolated home, wave orchestrator, deterministic graders, aggregator with 95% CIs, task specs committed before any run, arm sources at pinned SHAs) and all 77 raw run directories (transcripts, file-tree hashes, per-run grades) live in the benchmark workspace, not tracked in this repo. The grading and aggregation scripts are deterministic; re-running them on the raw runs reproduces every number above.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Test | Status | Result |
|
||||
@@ -190,6 +280,7 @@ Even fixed, `session-catchup.py` replays conversation transcript from the previo
|
||||
| A/B Blind Comparison | ✅ Complete | 3/3 wins (100%) for with_skill |
|
||||
| Description Optimizer | Pending | Scheduled for next eval cycle |
|
||||
| v3 Long-Running Session Functional Verification | ✅ Complete (2026-07-03) | 2 of 6 mechanisms found broken on Windows and fixed in v3.2.0; see Test 4 |
|
||||
| Competitive Benchmark v1 (7 methods) | ✅ Complete (2026-07-06, internal) | pwf resumes from context death in 5.0 turns vs 8.3 to 13.3 for the other six (2.7x faster than a raw agent), matches every method on correctness at 77/77, and is the only method with automatic recovery, re-surfacing, tamper-detection, isolation, and compaction guarantees; leads superpowers 5 to 0 on unforced triggering |
|
||||
|
||||
The skill demonstrably enforces the 3-file planning pattern across diverse task types. Without the skill, agents default to ad-hoc file naming and skip the structured planning workflow entirely. Separately, v3's session-recovery mechanism is now verified functional on Windows as of v3.2.0; it was not before, and nothing in the test suite would have caught that on its own.
|
||||
|
||||
|
||||
@@ -13,6 +13,41 @@ That's it! The skill is now active.
|
||||
|
||||
---
|
||||
|
||||
## What Each Install Route Actually Ships
|
||||
|
||||
Not every route delivers every surface. This matrix is the difference between "installed" and "fully working":
|
||||
|
||||
| Route | SKILL.md + scripts + templates | Slash commands (`/plan-goal`, `/plan-loop`, `/plan-attest`, `/plan-doctor`) | Hooks (plan injection, Stop check, PreCompact) |
|
||||
|---|---|---|---|
|
||||
| Plugin: `/plugin marketplace add` + `/plugin install` | Yes | **Yes** | **Yes** |
|
||||
| `npx skills add OthmanAdi/planning-with-files` | Yes | No (`commands/` is not copied) | Frontmatter hooks; see the two silent killers below |
|
||||
| ClawHub / manual skill copy to `~/.claude/skills/` | Yes | No | Frontmatter hooks; see below |
|
||||
|
||||
Two conditions can leave a skill-route install **silently hook-less** — everything looks installed, but no plan context is ever injected:
|
||||
|
||||
1. **Project trust.** A project-level install (`.claude/skills/` inside the repo) only activates after the project's trust dialog is accepted (`hasTrustDialogAccepted`). Headless or scripted sessions that never accepted trust load no project skills, and nothing prints an error.
|
||||
2. **Hook registration.** SKILL.md frontmatter hooks have been observed not to register on some project-level skill installs (observed on headless Claude Code 2.1.201 during the July 2026 benchmark). The plugin route registers hooks reliably.
|
||||
|
||||
If hooks matter to you (they are the differentiating mechanism of this skill), install via the plugin route. Either way, verify with the doctor:
|
||||
|
||||
```bash
|
||||
sh scripts/plan-doctor.sh # from your project root; reports resolution, injection, latency
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reliability Tip: Belt-and-Suspenders Trigger
|
||||
|
||||
Skill descriptions trigger probabilistically — in our July 2026 benchmark, unforced engagement was 60-67%, while an always-loaded rules-file instruction engaged 100% of the time. If you want the skill to fire every time a task is complex, add one line to your project's `CLAUDE.md` (or global `~/.claude/CLAUDE.md`):
|
||||
|
||||
```markdown
|
||||
When a task needs 3+ steps or 5+ tool calls, invoke the planning-with-files skill first and keep task_plan.md current.
|
||||
```
|
||||
|
||||
The skill description still handles discovery; the rules line makes engagement deterministic. Both together cost nothing when no complex task is running.
|
||||
|
||||
---
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### 1. Claude Code Plugin (Recommended)
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ $env:USERPROFILE\.kilocode\skills\planning-with-files\scripts\check-complete.ps1
|
||||
.\.kilocode\skills\planning-with-files\scripts\check-complete.ps1
|
||||
```
|
||||
|
||||
## Migrating from Cursor/Windsurf
|
||||
## Migrating from Cursor/Windsurf (Windsurf became Devin Desktop in June 2026)
|
||||
|
||||
Planning files are fully compatible. Simply copy your `task_plan.md`, `findings.md`, and `progress.md` files to your new project.
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ remaining risks, and rollback notes in the topic handoff.
|
||||
|
||||
**If complete:** Deliver your work! All three planning files document your process.
|
||||
|
||||
The planning files are working memory, not a tracked deliverable: they are gitignored and are not archived automatically. See [After Completion: What Happens to the Plan Files](workflow.md#after-completion-what-happens-to-the-plan-files) for the intended lifecycle and how to keep a completed plan if you want one.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: When to Update Which File
|
||||
|
||||
@@ -112,6 +112,22 @@ This diagram shows how the three files work together and how hooks interact with
|
||||
|
||||
---
|
||||
|
||||
## After Completion: What Happens to the Plan Files
|
||||
|
||||
The planning files are working memory for one task, not a deliverable. `task_plan.md`, `findings.md`, `progress.md`, and any `.planning/<slug>/` directory are gitignored by default, and nothing archives them when a task finishes. In root mode the next task overwrites `task_plan.md`; in slug mode the old directory just stops being the active plan. There is no automatic "completed" or "archived" state, and `check-complete` reports completion without moving or extracting anything.
|
||||
|
||||
The behavior is consistent and intentional, though it was never stated as a rule until now (see [#14](https://github.com/OthmanAdi/planning-with-files/issues/14) and [#202](https://github.com/OthmanAdi/planning-with-files/issues/202)). The model is the Manus one the skill is built on: the context window is RAM and the filesystem is disk, so work survives `/clear` and a crash *during* a task. Anything meant to outlive the task belongs somewhere durable already, in code, in a commit, in a spec or doc, or in a `findings.md` you deliberately keep out of the gitignore.
|
||||
|
||||
If you want a completed plan to persist, keep it yourself:
|
||||
|
||||
- Copy the decisions and errors you care about into code comments, a commit message, an ADR, or a `docs/` note.
|
||||
- Move the `.planning/<slug>/` directory outside the ignored path, or drop `.planning/` from `.gitignore` in a repo where you want plans tracked.
|
||||
- For personal reuse, keep the directory as a cache for a future related task and pin it with `PLAN_ID` or `.active_plan`.
|
||||
|
||||
A completion-triggered archive step (move `.planning/<slug>/` into an archive directory and extract the decisions and errors tables into a git-tracked record) is a reasonable opt-in extension. It would sit on top of the three-file default the way attestation, gated mode, and topic handoffs already do, without changing the ephemeral default. It is not built in today. If you want it, a focused issue or PR is welcome.
|
||||
|
||||
---
|
||||
|
||||
## Key Interactions
|
||||
|
||||
### Hooks
|
||||
|
||||
+49
-5
@@ -32,9 +32,46 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
||||
|
||||
# Plan-id safe-identifier check. Pure-sh case patterns; semantics match the
|
||||
# previous grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep
|
||||
# fork per candidate. (Shared shape with resolve-plan-dir.sh.)
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# injection silently goes dark. On POSIX systems paths contain no backslash
|
||||
# and this is the identity. A literal backslash in a Unix filename normalizes
|
||||
# to "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then python3/python os.path.realpath. Prints
|
||||
# the canonical absolute path on success; prints nothing and returns 1 on a
|
||||
@@ -79,8 +116,15 @@ is_within_root() {
|
||||
# different code paths and land on differently-spelled-but-equal targets,
|
||||
# so the prefix match below fails and injection silently goes dark. "."
|
||||
# resolves through the same physical-cwd path candidates already use.
|
||||
# Both sides are backslash-normalized before comparison: Windows-native
|
||||
# canonicalizers emit C:\-style paths that a forward-slash prefix pattern
|
||||
# can never match.
|
||||
root_real="$(canonicalize ".")" || root_real=""
|
||||
norm_slashes "${root_real}"
|
||||
root_real="${NORM_OUT}"
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -94,20 +138,20 @@ is_within_root() {
|
||||
# dispatch needs only one script on disk to function). ---
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
if [ -n "${PLAN_ID:-}" ] && printf "%s" "$PLAN_ID" | grep -Eq "$SLUG_RE" && [ -d ".planning/${PLAN_ID}" ]; then
|
||||
if [ -n "${PLAN_ID:-}" ] && slug_is_valid "$PLAN_ID" && [ -d ".planning/${PLAN_ID}" ]; then
|
||||
RESOLVED=".planning/${PLAN_ID}"; SCOPE="scoped"
|
||||
elif [ -f .planning/.active_plan ]; then
|
||||
AP=$(tr -d '\r\n[:space:]' < .planning/.active_plan 2>/dev/null)
|
||||
if [ -n "$AP" ] && printf "%s" "$AP" | grep -Eq "$SLUG_RE" && [ -d ".planning/${AP}" ]; then
|
||||
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d ".planning/${AP}" ]; then
|
||||
RESOLVED=".planning/${AP}"; SCOPE="scoped"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$RESOLVED" ] && [ -d .planning ]; then
|
||||
NEWEST=""; NEWEST_MT=0
|
||||
for d in .planning/*/; do
|
||||
d="${d%/}"; n=$(basename "$d")
|
||||
d="${d%/}"; n="${d##*/}"
|
||||
case "$n" in .*) continue;; esac
|
||||
printf "%s" "$n" | grep -Eq "$SLUG_RE" || continue
|
||||
slug_is_valid "$n" || continue
|
||||
[ -f "$d/task_plan.md" ] || continue
|
||||
m=$(stat -c '%Y' "$d" 2>/dev/null || stat -f '%m' "$d" 2>/dev/null || date -r "$d" +%s 2>/dev/null || echo 0)
|
||||
if [ "$m" -gt "$NEWEST_MT" ] 2>/dev/null; then NEWEST_MT="$m"; NEWEST="$d"; fi
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: plan-doctor — one-pass self-check for the mechanisms
|
||||
# that fail silently. Run from the project root:
|
||||
#
|
||||
# sh scripts/plan-doctor.sh
|
||||
#
|
||||
# Answers:
|
||||
# - does plan resolution work here, and which plan wins?
|
||||
# - does hook injection actually emit plan context?
|
||||
# - is the canonicalizer producing comparable paths? (Windows-native
|
||||
# coreutils emit C:\-style output; pwf versions before v3.6.0 went
|
||||
# silently dark on such machines)
|
||||
# - is the plan attested, and is the attestation file where hooks look?
|
||||
# - which install surfaces exist on this machine?
|
||||
# - what does one hook fire cost in wall-clock?
|
||||
#
|
||||
# Diagnostic only. Writes nothing except inject-plan.sh's own SHA cache.
|
||||
# Always exits 0.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
||||
|
||||
ok() { printf 'PASS %s\n' "$1"; }
|
||||
warn() { printf 'WARN %s\n' "$1"; }
|
||||
fail() { printf 'FAIL %s\n' "$1"; }
|
||||
info() { printf 'info %s\n' "$1"; }
|
||||
|
||||
echo '=== planning-with-files plan-doctor ==='
|
||||
info "cwd: ${PWD}"
|
||||
info "uname: $(uname -s 2>/dev/null || echo unknown)"
|
||||
[ "${PLANNING_DISABLED:-}" = "1" ] && warn "PLANNING_DISABLED=1 is set — every hook exits immediately in this environment"
|
||||
|
||||
# --- [1] canonicalizer probe -------------------------------------------------
|
||||
CANON="$(realpath . 2>/dev/null)" || CANON=""
|
||||
[ -z "${CANON}" ] && { CANON="$(readlink -f . 2>/dev/null)" || CANON=""; }
|
||||
case "${CANON}" in
|
||||
'')
|
||||
warn "no realpath/readlink canonicalizer answered — containment falls back to a python spawn per check"
|
||||
;;
|
||||
*\\*)
|
||||
info "canonicalizer emits Windows-style paths (${CANON}) — handled since v3.6.0; OLDER pwf versions resolve nothing on this machine"
|
||||
;;
|
||||
*)
|
||||
info "canonicalizer: ${CANON}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- [2] plan resolution -----------------------------------------------------
|
||||
RES=""
|
||||
if [ -f "${SCRIPT_DIR}/resolve-plan-dir.sh" ]; then
|
||||
RES="$(sh "${SCRIPT_DIR}/resolve-plan-dir.sh" 2>/dev/null)" || RES=""
|
||||
if [ -n "${RES}" ]; then
|
||||
ok "resolver: active plan dir = ${RES}"
|
||||
elif [ -f task_plan.md ]; then
|
||||
ok "resolver: legacy root plan (./task_plan.md)"
|
||||
elif [ -d .planning ]; then
|
||||
fail "resolver: .planning/ exists but nothing resolves — check .planning/.active_plan content and that plan dirs contain task_plan.md"
|
||||
else
|
||||
info "resolver: no plan in this directory (run init-session.sh to create one)"
|
||||
fi
|
||||
else
|
||||
warn "resolve-plan-dir.sh not found next to plan-doctor — unexpected install layout"
|
||||
fi
|
||||
|
||||
# --- [3] hook injection ------------------------------------------------------
|
||||
INJ="${SCRIPT_DIR}/inject-plan.sh"
|
||||
if [ -f "${INJ}" ]; then
|
||||
OUT="$(sh "${INJ}" --context=userprompt 2>/dev/null)" || OUT=""
|
||||
if [ -z "${OUT}" ]; then
|
||||
if [ -n "${RES}" ] || [ -f task_plan.md ]; then
|
||||
fail "injection: a plan resolves but inject-plan.sh emitted NOTHING — hooks are dark. Known silent causes: pre-v3.6.0 with a Windows-native realpath on PATH; PLANNING_DISABLED=1; a plan dir outside the project root."
|
||||
else
|
||||
ok "injection: silent because no plan exists here (correct behavior)"
|
||||
fi
|
||||
else
|
||||
case "${OUT}" in
|
||||
*'PLAN TAMPERED'*)
|
||||
warn "injection: plan is attested but the hash mismatches — run /plan-attest (or scripts/attest-plan.sh) to re-approve the current plan"
|
||||
;;
|
||||
*'requires attested plan'*)
|
||||
warn "injection: v3 mode without attestation — run attest-plan once to arm injection"
|
||||
;;
|
||||
*)
|
||||
BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
|
||||
ok "injection: emits plan context (${BYTES} bytes)"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
warn "inject-plan.sh not found next to plan-doctor — this install route ships no hook payload (see the install matrix in docs/installation.md)"
|
||||
fi
|
||||
|
||||
# --- [4] attestation ---------------------------------------------------------
|
||||
ATT=""
|
||||
if [ -n "${RES}" ] && [ -f "${RES}/.attestation" ]; then
|
||||
ATT="${RES}/.attestation"
|
||||
elif [ -f .plan-attestation ]; then
|
||||
ATT=".plan-attestation"
|
||||
fi
|
||||
if [ -n "${ATT}" ]; then
|
||||
info "attestation present: ${ATT}"
|
||||
else
|
||||
info "attestation: none (opt-in in legacy mode; default-on in v3 modes; run /plan-attest after approving the plan)"
|
||||
fi
|
||||
|
||||
# --- [5] install surfaces ----------------------------------------------------
|
||||
FOUND_SURFACE=0
|
||||
for s in \
|
||||
".claude/skills/planning-with-files" \
|
||||
"${HOME:-}/.claude/skills/planning-with-files" \
|
||||
".agents/skills/planning-with-files" \
|
||||
"${HOME:-}/.agents/skills/planning-with-files"
|
||||
do
|
||||
[ -n "${s}" ] && [ -d "${s}" ] && { info "install surface present: ${s}"; FOUND_SURFACE=1; }
|
||||
done
|
||||
[ "${FOUND_SURFACE}" = "0" ] && info "no skill-dir install surface in project or home (plugin-route installs live under the plugin cache instead)"
|
||||
info "route reminder: the plugin route ships commands/ + hooks; npx-skills ships the skill only. Hooks silent after a project-level skill install? Check project trust (hasTrustDialogAccepted) and the install matrix in docs/installation.md."
|
||||
|
||||
# --- [6] hook latency --------------------------------------------------------
|
||||
if [ -f "${INJ}" ]; then
|
||||
T0="$(date +%s%N 2>/dev/null)" || T0=""
|
||||
sh "${INJ}" --context=userprompt >/dev/null 2>&1
|
||||
T1="$(date +%s%N 2>/dev/null)" || T1=""
|
||||
case "${T0}${T1}" in
|
||||
''|*[!0-9]*)
|
||||
info "hook latency: skipped (no nanosecond clock on this date binary)"
|
||||
;;
|
||||
*)
|
||||
MS=$(( (T1 - T0) / 1000000 ))
|
||||
info "one inject-plan.sh fire: ${MS}ms wall-clock"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo '=== plan-doctor done ==='
|
||||
exit 0
|
||||
+79
-13
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
# نظام تخطيط الملفات
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
# Dateiplanungssystem
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
# Sistema de Planificación con Archivos
|
||||
|
||||
@@ -29,7 +29,7 @@ hooks:
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ hooks:
|
||||
command: "RESOLVED=\"\"; SCOPE=\"\"; SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'; if [ -n \"${PLAN_ID:-}\" ] && printf \"%s\" \"$PLAN_ID\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${PLAN_ID}\" ]; then RESOLVED=\".planning/${PLAN_ID}\"; SCOPE=\"scoped\"; elif [ -f .planning/.active_plan ]; then AP=$(tr -d '\\r\\n[:space:]' < .planning/.active_plan 2>/dev/null); if [ -n \"$AP\" ] && printf \"%s\" \"$AP\" | grep -Eq \"$SLUG_RE\" && [ -d \".planning/${AP}\" ]; then RESOLVED=\".planning/${AP}\"; SCOPE=\"scoped\"; fi; fi; if [ -z \"$RESOLVED\" ] && [ -d .planning ]; then NEWEST=\"\"; NEWEST_MT=0; for d in .planning/*/; do d=\"${d%/}\"; n=$(basename \"$d\"); case \"$n\" in .*) continue;; esac; printf \"%s\" \"$n\" | grep -Eq \"$SLUG_RE\" || continue; [ -f \"$d/task_plan.md\" ] || continue; m=$(stat -c '%Y' \"$d\" 2>/dev/null || stat -f '%m' \"$d\" 2>/dev/null || date -r \"$d\" +%s 2>/dev/null || echo 0); if [ \"$m\" -gt \"$NEWEST_MT\" ] 2>/dev/null; then NEWEST_MT=\"$m\"; NEWEST=\"$d\"; fi; done; [ -n \"$NEWEST\" ] && { RESOLVED=\"$NEWEST\"; SCOPE=\"scoped\"; }; fi; if [ -z \"$RESOLVED\" ] && [ -f task_plan.md ]; then RESOLVED=\".\"; SCOPE=\"root\"; fi; [ -z \"$RESOLVED\" ] && exit 0; if [ \"$SCOPE\" = \"root\" ]; then PLAN_FILE=\"task_plan.md\"; PROGRESS_FILE=\"progress.md\"; ATTEST=\"\"; [ -f .plan-attestation ] && ATTEST=$(tr -d '\\r\\n[:space:]' < .plan-attestation 2>/dev/null); else PLAN_FILE=\"${RESOLVED}/task_plan.md\"; PROGRESS_FILE=\"${RESOLVED}/progress.md\"; ATTEST=\"\"; [ -f \"${RESOLVED}/.attestation\" ] && ATTEST=$(tr -d '\\r\\n[:space:]' < \"${RESOLVED}/.attestation\" 2>/dev/null); fi; [ -f \"$PLAN_FILE\" ] || exit 0; TAMPERED=0; ACTUAL=\"\"; if [ -n \"$ATTEST\" ]; then CD=\"${TMPDIR:-/tmp}/pwf-sha\"; mkdir -p \"$CD\" 2>/dev/null; KEY=$(printf \"%s\" \"$PLAN_FILE\" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | awk '{print $1}' | cut -c1-16); MT=$(stat -c '%Y' \"$PLAN_FILE\" 2>/dev/null || stat -f '%m' \"$PLAN_FILE\" 2>/dev/null || date -r \"$PLAN_FILE\" +%s 2>/dev/null || echo 0); CF=\"$CD/$KEY\"; CM=\"\"; CS=\"\"; if [ -f \"$CF\" ]; then CM=$(sed -n 1p \"$CF\" 2>/dev/null); CS=$(sed -n 2p \"$CF\" 2>/dev/null); fi; if [ -n \"$MT\" ] && [ \"$MT\" = \"$CM\" ] && [ -n \"$CS\" ]; then ACTUAL=\"$CS\"; else ACTUAL=$( (sha256sum \"$PLAN_FILE\" 2>/dev/null || shasum -a 256 \"$PLAN_FILE\" 2>/dev/null) | awk '{print $1}'); [ -n \"$ACTUAL\" ] && [ -n \"$MT\" ] && printf \"%s\\n%s\\n\" \"$MT\" \"$ACTUAL\" > \"$CF\" 2>/dev/null; fi; [ \"$ACTUAL\" != \"$ATTEST\" ] && TAMPERED=1; fi; echo '[planning-with-files] PreCompact: context compaction is about to occur.'; echo 'Before compaction completes: ensure progress.md captures recent actions and task_plan.md status reflects current phase.'; echo 'task_plan.md, findings.md, progress.md remain on disk and will be re-read after compaction.'; [ -n \"$ATTEST\" ] && echo \"Plan-SHA256 at compaction: $ATTEST\"; exit 0"
|
||||
metadata:
|
||||
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/inject-plan.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/inject-plan.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/inject-plan.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --context=precompact; exit 0"
|
||||
metadata:
|
||||
version: "3.4.0"
|
||||
version: "3.6.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
@@ -220,6 +220,7 @@ Helper scripts for automation:
|
||||
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
|
||||
- `scripts/session-catchup.py` — Recover context from a previous session after `/clear` (v2.2.0).
|
||||
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
|
||||
- `scripts/plan-doctor.sh` — One-pass self-check for the mechanisms that fail silently (v3.6.0): plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. See `/plan-doctor` command.
|
||||
|
||||
### Parallel task workflow
|
||||
|
||||
|
||||
@@ -32,9 +32,46 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
||||
|
||||
# Plan-id safe-identifier check. Pure-sh case patterns; semantics match the
|
||||
# previous grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep
|
||||
# fork per candidate. (Shared shape with resolve-plan-dir.sh.)
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# injection silently goes dark. On POSIX systems paths contain no backslash
|
||||
# and this is the identity. A literal backslash in a Unix filename normalizes
|
||||
# to "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then python3/python os.path.realpath. Prints
|
||||
# the canonical absolute path on success; prints nothing and returns 1 on a
|
||||
@@ -79,8 +116,15 @@ is_within_root() {
|
||||
# different code paths and land on differently-spelled-but-equal targets,
|
||||
# so the prefix match below fails and injection silently goes dark. "."
|
||||
# resolves through the same physical-cwd path candidates already use.
|
||||
# Both sides are backslash-normalized before comparison: Windows-native
|
||||
# canonicalizers emit C:\-style paths that a forward-slash prefix pattern
|
||||
# can never match.
|
||||
root_real="$(canonicalize ".")" || root_real=""
|
||||
norm_slashes "${root_real}"
|
||||
root_real="${NORM_OUT}"
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -94,20 +138,20 @@ is_within_root() {
|
||||
# dispatch needs only one script on disk to function). ---
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
if [ -n "${PLAN_ID:-}" ] && printf "%s" "$PLAN_ID" | grep -Eq "$SLUG_RE" && [ -d ".planning/${PLAN_ID}" ]; then
|
||||
if [ -n "${PLAN_ID:-}" ] && slug_is_valid "$PLAN_ID" && [ -d ".planning/${PLAN_ID}" ]; then
|
||||
RESOLVED=".planning/${PLAN_ID}"; SCOPE="scoped"
|
||||
elif [ -f .planning/.active_plan ]; then
|
||||
AP=$(tr -d '\r\n[:space:]' < .planning/.active_plan 2>/dev/null)
|
||||
if [ -n "$AP" ] && printf "%s" "$AP" | grep -Eq "$SLUG_RE" && [ -d ".planning/${AP}" ]; then
|
||||
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d ".planning/${AP}" ]; then
|
||||
RESOLVED=".planning/${AP}"; SCOPE="scoped"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$RESOLVED" ] && [ -d .planning ]; then
|
||||
NEWEST=""; NEWEST_MT=0
|
||||
for d in .planning/*/; do
|
||||
d="${d%/}"; n=$(basename "$d")
|
||||
d="${d%/}"; n="${d##*/}"
|
||||
case "$n" in .*) continue;; esac
|
||||
printf "%s" "$n" | grep -Eq "$SLUG_RE" || continue
|
||||
slug_is_valid "$n" || continue
|
||||
[ -f "$d/task_plan.md" ] || continue
|
||||
m=$(stat -c '%Y' "$d" 2>/dev/null || stat -f '%m' "$d" 2>/dev/null || date -r "$d" +%s 2>/dev/null || echo 0)
|
||||
if [ "$m" -gt "$NEWEST_MT" ] 2>/dev/null; then NEWEST_MT="$m"; NEWEST="$d"; fi
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: plan-doctor — one-pass self-check for the mechanisms
|
||||
# that fail silently. Run from the project root:
|
||||
#
|
||||
# sh scripts/plan-doctor.sh
|
||||
#
|
||||
# Answers:
|
||||
# - does plan resolution work here, and which plan wins?
|
||||
# - does hook injection actually emit plan context?
|
||||
# - is the canonicalizer producing comparable paths? (Windows-native
|
||||
# coreutils emit C:\-style output; pwf versions before v3.6.0 went
|
||||
# silently dark on such machines)
|
||||
# - is the plan attested, and is the attestation file where hooks look?
|
||||
# - which install surfaces exist on this machine?
|
||||
# - what does one hook fire cost in wall-clock?
|
||||
#
|
||||
# Diagnostic only. Writes nothing except inject-plan.sh's own SHA cache.
|
||||
# Always exits 0.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
||||
|
||||
ok() { printf 'PASS %s\n' "$1"; }
|
||||
warn() { printf 'WARN %s\n' "$1"; }
|
||||
fail() { printf 'FAIL %s\n' "$1"; }
|
||||
info() { printf 'info %s\n' "$1"; }
|
||||
|
||||
echo '=== planning-with-files plan-doctor ==='
|
||||
info "cwd: ${PWD}"
|
||||
info "uname: $(uname -s 2>/dev/null || echo unknown)"
|
||||
[ "${PLANNING_DISABLED:-}" = "1" ] && warn "PLANNING_DISABLED=1 is set — every hook exits immediately in this environment"
|
||||
|
||||
# --- [1] canonicalizer probe -------------------------------------------------
|
||||
CANON="$(realpath . 2>/dev/null)" || CANON=""
|
||||
[ -z "${CANON}" ] && { CANON="$(readlink -f . 2>/dev/null)" || CANON=""; }
|
||||
case "${CANON}" in
|
||||
'')
|
||||
warn "no realpath/readlink canonicalizer answered — containment falls back to a python spawn per check"
|
||||
;;
|
||||
*\\*)
|
||||
info "canonicalizer emits Windows-style paths (${CANON}) — handled since v3.6.0; OLDER pwf versions resolve nothing on this machine"
|
||||
;;
|
||||
*)
|
||||
info "canonicalizer: ${CANON}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- [2] plan resolution -----------------------------------------------------
|
||||
RES=""
|
||||
if [ -f "${SCRIPT_DIR}/resolve-plan-dir.sh" ]; then
|
||||
RES="$(sh "${SCRIPT_DIR}/resolve-plan-dir.sh" 2>/dev/null)" || RES=""
|
||||
if [ -n "${RES}" ]; then
|
||||
ok "resolver: active plan dir = ${RES}"
|
||||
elif [ -f task_plan.md ]; then
|
||||
ok "resolver: legacy root plan (./task_plan.md)"
|
||||
elif [ -d .planning ]; then
|
||||
fail "resolver: .planning/ exists but nothing resolves — check .planning/.active_plan content and that plan dirs contain task_plan.md"
|
||||
else
|
||||
info "resolver: no plan in this directory (run init-session.sh to create one)"
|
||||
fi
|
||||
else
|
||||
warn "resolve-plan-dir.sh not found next to plan-doctor — unexpected install layout"
|
||||
fi
|
||||
|
||||
# --- [3] hook injection ------------------------------------------------------
|
||||
INJ="${SCRIPT_DIR}/inject-plan.sh"
|
||||
if [ -f "${INJ}" ]; then
|
||||
OUT="$(sh "${INJ}" --context=userprompt 2>/dev/null)" || OUT=""
|
||||
if [ -z "${OUT}" ]; then
|
||||
if [ -n "${RES}" ] || [ -f task_plan.md ]; then
|
||||
fail "injection: a plan resolves but inject-plan.sh emitted NOTHING — hooks are dark. Known silent causes: pre-v3.6.0 with a Windows-native realpath on PATH; PLANNING_DISABLED=1; a plan dir outside the project root."
|
||||
else
|
||||
ok "injection: silent because no plan exists here (correct behavior)"
|
||||
fi
|
||||
else
|
||||
case "${OUT}" in
|
||||
*'PLAN TAMPERED'*)
|
||||
warn "injection: plan is attested but the hash mismatches — run /plan-attest (or scripts/attest-plan.sh) to re-approve the current plan"
|
||||
;;
|
||||
*'requires attested plan'*)
|
||||
warn "injection: v3 mode without attestation — run attest-plan once to arm injection"
|
||||
;;
|
||||
*)
|
||||
BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
|
||||
ok "injection: emits plan context (${BYTES} bytes)"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
warn "inject-plan.sh not found next to plan-doctor — this install route ships no hook payload (see the install matrix in docs/installation.md)"
|
||||
fi
|
||||
|
||||
# --- [4] attestation ---------------------------------------------------------
|
||||
ATT=""
|
||||
if [ -n "${RES}" ] && [ -f "${RES}/.attestation" ]; then
|
||||
ATT="${RES}/.attestation"
|
||||
elif [ -f .plan-attestation ]; then
|
||||
ATT=".plan-attestation"
|
||||
fi
|
||||
if [ -n "${ATT}" ]; then
|
||||
info "attestation present: ${ATT}"
|
||||
else
|
||||
info "attestation: none (opt-in in legacy mode; default-on in v3 modes; run /plan-attest after approving the plan)"
|
||||
fi
|
||||
|
||||
# --- [5] install surfaces ----------------------------------------------------
|
||||
FOUND_SURFACE=0
|
||||
for s in \
|
||||
".claude/skills/planning-with-files" \
|
||||
"${HOME:-}/.claude/skills/planning-with-files" \
|
||||
".agents/skills/planning-with-files" \
|
||||
"${HOME:-}/.agents/skills/planning-with-files"
|
||||
do
|
||||
[ -n "${s}" ] && [ -d "${s}" ] && { info "install surface present: ${s}"; FOUND_SURFACE=1; }
|
||||
done
|
||||
[ "${FOUND_SURFACE}" = "0" ] && info "no skill-dir install surface in project or home (plugin-route installs live under the plugin cache instead)"
|
||||
info "route reminder: the plugin route ships commands/ + hooks; npx-skills ships the skill only. Hooks silent after a project-level skill install? Check project trust (hasTrustDialogAccepted) and the install matrix in docs/installation.md."
|
||||
|
||||
# --- [6] hook latency --------------------------------------------------------
|
||||
if [ -f "${INJ}" ]; then
|
||||
T0="$(date +%s%N 2>/dev/null)" || T0=""
|
||||
sh "${INJ}" --context=userprompt >/dev/null 2>&1
|
||||
T1="$(date +%s%N 2>/dev/null)" || T1=""
|
||||
case "${T0}${T1}" in
|
||||
''|*[!0-9]*)
|
||||
info "hook latency: skipped (no nanosecond clock on this date binary)"
|
||||
;;
|
||||
*)
|
||||
MS=$(( (T1 - T0) / 1000000 ))
|
||||
info "one inject-plan.sh fire: ${MS}ms wall-clock"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo '=== plan-doctor done ==='
|
||||
exit 0
|
||||
@@ -24,13 +24,42 @@ ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
||||
# .active_plan file containing only whitespace or random text) without
|
||||
# enforcing a date prefix that would break backward compatibility.
|
||||
SLUG_RE='^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
|
||||
# Pure-sh case patterns; semantics match the previous
|
||||
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
||||
# candidate (the newest-mtime scan calls this once per plan dir).
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
printf "%s" "$1" | grep -Eq "${SLUG_RE}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
@@ -63,19 +92,50 @@ canonicalize() {
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely. If
|
||||
# canonicalization is unavailable for BOTH paths we fail open (return 0) to keep
|
||||
# legacy behavior byte-equivalent on minimal shells that lack realpath/readlink
|
||||
# and python; the SLUG_RE check already blocks traversal in the slug name.
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
root_real="$(canonicalize "${PWD}")" || root_real=""
|
||||
cand_real="$(canonicalize "${candidate}")" || cand_real=""
|
||||
if [ -z "${root_real}" ] || [ -z "${cand_real}" ]; then
|
||||
return 0
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize ".")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${root_real}"|"${root_real}"/*) return 0 ;;
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -119,6 +179,12 @@ resolve_from_env() {
|
||||
resolve_from_active_file() {
|
||||
[ -f "${ACTIVE_FILE}" ] || return 1
|
||||
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
||||
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
||||
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
||||
utf8_bom="$(printf '\357\273\277')"
|
||||
case "${plan_id}" in
|
||||
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${plan_id}" || return 1
|
||||
candidate="${PLAN_ROOT}/${plan_id}"
|
||||
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
||||
@@ -137,7 +203,7 @@ resolve_latest_dir() {
|
||||
for entry in "${PLAN_ROOT}"/*/; do
|
||||
[ -d "${entry}" ] || continue
|
||||
clean="${entry%/}"
|
||||
name="$(basename "${clean}")"
|
||||
name="${clean##*/}"
|
||||
case "${name}" in
|
||||
.*) continue ;;
|
||||
esac
|
||||
|
||||
@@ -14,6 +14,30 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
"""Make catchup output deterministic on Windows legacy code pages.
|
||||
|
||||
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
||||
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
||||
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
||||
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
||||
both streams before any report is emitted; ``errors='replace'`` also keeps
|
||||
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, 'reconfigure', None)
|
||||
if callable(reconfigure):
|
||||
try:
|
||||
reconfigure(encoding='utf-8', errors='replace')
|
||||
except (OSError, ValueError):
|
||||
# Replaced/captured streams may not permit reconfiguration.
|
||||
# The hook remains advisory, so retain the existing stream.
|
||||
pass
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
# .\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 = ""
|
||||
@@ -19,7 +19,7 @@ if ($PlanId -eq "") {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found — stale pointer)"
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
}
|
||||
@@ -41,7 +41,8 @@ if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $ActiveFile -Value $PlanId -Encoding UTF8 -NoNewline
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
Write-Output "Active plan set to: $PlanId"
|
||||
Write-Output "Path: $PlanDir"
|
||||
|
||||
+230
-5
@@ -1,11 +1,14 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -27,13 +30,43 @@ class CodexHooksTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def run_shell_hook(self, script_name: str, cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess[str]:
|
||||
shell_env = (env or os.environ).copy()
|
||||
shell = shutil.which("sh", path=shell_env.get("PATH"))
|
||||
if os.name == "nt":
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
shell, extra_path_dirs = adapter._windows_git_bash()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if shell is None:
|
||||
raise unittest.SkipTest("Git for Windows sh.exe is unavailable")
|
||||
if extra_path_dirs:
|
||||
shell_env["PATH"] = os.pathsep.join(
|
||||
[*extra_path_dirs, shell_env.get("PATH", "")]
|
||||
)
|
||||
shell_env.setdefault("PYTHON_BIN", sys.executable)
|
||||
elif shell is None:
|
||||
raise unittest.SkipTest("POSIX sh is unavailable")
|
||||
return subprocess.run(
|
||||
["sh", str(HOOKS_DIR / script_name)],
|
||||
[shell, str(HOOKS_DIR / script_name)],
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
env=shell_env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def run_windows_front_door(self, sh_script: str, payload: dict, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(HOOKS_DIR / "run_sh.py"), sh_script],
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -53,6 +86,10 @@ class CodexHooksTests(unittest.TestCase):
|
||||
},
|
||||
set(payload["hooks"]),
|
||||
)
|
||||
self.assertEqual(
|
||||
"startup|resume|clear|compact",
|
||||
payload["hooks"]["SessionStart"][0]["matcher"],
|
||||
)
|
||||
|
||||
def test_permission_request_adapter_emits_plan_reminder(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -85,6 +122,24 @@ class CodexHooksTests(unittest.TestCase):
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_permission_request_resolves_scoped_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
plan_dir = root / ".planning" / "task-a"
|
||||
plan_dir.mkdir(parents=True)
|
||||
plan_dir.joinpath("task_plan.md").write_text("# Scoped Plan\n", encoding="utf-8")
|
||||
root.joinpath(".planning", ".active_plan").write_text("task-a\n", encoding="utf-8")
|
||||
|
||||
result = self.run_python_hook(
|
||||
"permission_request.py",
|
||||
{"cwd": str(root), "tool_name": "Bash"},
|
||||
root,
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertIn("Active plan", payload["systemMessage"])
|
||||
|
||||
def test_session_start_reuses_plan_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as home:
|
||||
root = Path(tmpdir)
|
||||
@@ -110,7 +165,7 @@ class CodexHooksTests(unittest.TestCase):
|
||||
self.assertIn("Ship Codex hooks", result.stdout)
|
||||
self.assertIn("Finished adapter draft", result.stdout)
|
||||
|
||||
def test_pre_tool_use_adapter_emits_system_message(self) -> None:
|
||||
def test_pre_tool_use_adapter_emits_additional_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
root.joinpath("task_plan.md").write_text(
|
||||
@@ -132,8 +187,25 @@ class CodexHooksTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertIn("systemMessage", payload)
|
||||
self.assertIn("# Task Plan", payload["systemMessage"])
|
||||
hook_output = payload["hookSpecificOutput"]
|
||||
self.assertEqual("PreToolUse", hook_output["hookEventName"])
|
||||
self.assertIn("# Task Plan", hook_output["additionalContext"])
|
||||
|
||||
def test_pre_tool_use_adapter_preserves_unicode_as_ascii_safe_json(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
root.joinpath("task_plan.md").write_text("# \u4efb\u52a1\u8ba1\u5212\n", encoding="utf-8")
|
||||
|
||||
result = self.run_python_hook(
|
||||
"pre_tool_use.py",
|
||||
{"cwd": str(root), "tool_input": {"command": "pwd"}},
|
||||
root,
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(result.stdout.isascii())
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertIn("\u4efb\u52a1\u8ba1\u5212", payload["hookSpecificOutput"]["additionalContext"])
|
||||
|
||||
def test_post_tool_use_adapter_emits_progress_reminder(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -211,6 +283,159 @@ class CodexHooksTests(unittest.TestCase):
|
||||
self.assertNotIn("continue working", first_payload["systemMessage"])
|
||||
self.assertIn("Task in progress", second_payload["systemMessage"])
|
||||
|
||||
def test_every_hook_has_windows_override_without_posix_isms(self) -> None:
|
||||
"""issue #201: on Windows Codex runs commandWindows, not the POSIX command.
|
||||
|
||||
Every hook must carry a commandWindows routed through pwf-hook.cmd, and it
|
||||
must contain none of the tokens that break under the Windows interpreter
|
||||
(python3 alias stub, /dev/null, $HOME, the missing `true` command). Runs on
|
||||
every OS so unix CI also enforces the JSON stays valid and the keys stay.
|
||||
"""
|
||||
payload = json.loads(HOOKS_JSON.read_text(encoding="utf-8"))
|
||||
for event, entries in payload["hooks"].items():
|
||||
for entry in entries:
|
||||
for hook in entry["hooks"]:
|
||||
cw = hook.get("commandWindows", "")
|
||||
self.assertTrue(cw, f"{event} hook is missing commandWindows")
|
||||
self.assertIn("pwf-hook.cmd", cw, f"{event} commandWindows not routed through the launcher")
|
||||
for bad in ("$HOME", "2>/dev/null", "|| true", "python3 "):
|
||||
self.assertNotIn(bad, cw, f"{event} commandWindows still contains POSIX-ism {bad!r}")
|
||||
|
||||
def test_adapter_uses_explicit_utf8_for_shell_output(self) -> None:
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
completed = subprocess.CompletedProcess(["sh"], 0, "ok\n", "")
|
||||
with mock.patch.object(adapter, "_windows_git_bash", return_value=("sh", [])), mock.patch.object(
|
||||
adapter.subprocess, "run", return_value=completed
|
||||
) as run:
|
||||
stdout, stderr = adapter.run_shell_script("user-prompt-submit.sh", Path.cwd())
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
|
||||
self.assertEqual("ok", stdout)
|
||||
self.assertEqual("", stderr)
|
||||
self.assertEqual("utf-8", run.call_args.kwargs["encoding"])
|
||||
self.assertEqual("replace", run.call_args.kwargs["errors"])
|
||||
|
||||
def test_emit_json_is_ascii_safe_and_round_trips_unicode(self) -> None:
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
buffer = io.BytesIO()
|
||||
stream = io.TextIOWrapper(buffer, encoding="ascii")
|
||||
with mock.patch.object(adapter.sys, "stdout", stream):
|
||||
adapter.emit_json({"message": "\u4e2d\u6587"})
|
||||
stream.flush()
|
||||
raw = buffer.getvalue().decode("ascii")
|
||||
stream.detach()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
|
||||
self.assertTrue(raw.isascii())
|
||||
self.assertEqual("\u4e2d\u6587", json.loads(raw)["message"])
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "commandWindows path is Windows-only")
|
||||
def test_run_sh_front_door_serializes_shell_events_on_windows(self) -> None:
|
||||
"""End-to-end Windows path emits event-appropriate Codex JSON."""
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
sh_path, _ = adapter._windows_git_bash()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if sh_path is None:
|
||||
self.skipTest("Git for Windows sh.exe not resolvable on this runner")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
root.joinpath("task_plan.md").write_text(
|
||||
"# \u4efb\u52a1\u8ba1\u5212\n\n## Goal\nWindows hook parity\n", encoding="utf-8"
|
||||
)
|
||||
root.joinpath("progress.md").write_text("# Progress\n", encoding="utf-8")
|
||||
|
||||
user_prompt = self.run_windows_front_door("user-prompt-submit.sh", {"cwd": str(root)}, root)
|
||||
session_start = self.run_windows_front_door("session-start.sh", {"cwd": str(root)}, root)
|
||||
pre_compact = self.run_windows_front_door("pre-compact.sh", {"cwd": str(root)}, root)
|
||||
|
||||
for result in (user_prompt, session_start, pre_compact):
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(result.stdout.isascii())
|
||||
|
||||
user_payload = json.loads(user_prompt.stdout)
|
||||
self.assertEqual("UserPromptSubmit", user_payload["hookSpecificOutput"]["hookEventName"])
|
||||
self.assertIn("Windows hook parity", user_payload["hookSpecificOutput"]["additionalContext"])
|
||||
self.assertIn("\u4efb\u52a1\u8ba1\u5212", user_payload["hookSpecificOutput"]["additionalContext"])
|
||||
|
||||
session_payload = json.loads(session_start.stdout)
|
||||
self.assertEqual("SessionStart", session_payload["hookSpecificOutput"]["hookEventName"])
|
||||
self.assertIn("Windows hook parity", session_payload["hookSpecificOutput"]["additionalContext"])
|
||||
|
||||
compact_payload = json.loads(pre_compact.stdout)
|
||||
self.assertTrue(compact_payload["continue"])
|
||||
self.assertIn("PreCompact", compact_payload["systemMessage"])
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.name == "nt",
|
||||
"Windows-shaped path; System32 comparison only meaningful on Windows",
|
||||
)
|
||||
def test_windows_git_bash_rejects_wsl_launcher_in_system32(self) -> None:
|
||||
"""A bare `bash` on PATH resolving into System32 is the WSL launcher.
|
||||
|
||||
_windows_git_bash must never hand that path back to callers even when
|
||||
it is the only `bash` shutil.which can see; it should keep looking
|
||||
(and return a real Git-for-Windows sh.exe, or None) instead.
|
||||
"""
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
def fake_which(exe, *args, **kwargs):
|
||||
if exe == "sh":
|
||||
return None
|
||||
if exe == "bash":
|
||||
return r"C:\windows\system32\bash.EXE"
|
||||
return None
|
||||
|
||||
with mock.patch.object(adapter.shutil, "which", side_effect=fake_which):
|
||||
sh_path, _ = adapter._windows_git_bash()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
|
||||
if sh_path is not None:
|
||||
self.assertNotIn("system32", sh_path.lower())
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.name == "nt",
|
||||
"Windows-shaped path; WindowsApps comparison only meaningful on Windows",
|
||||
)
|
||||
def test_windows_git_bash_rejects_wsl_launcher_in_windowsapps(self) -> None:
|
||||
"""The Microsoft Store WSL alias lives under WindowsApps, not System32.
|
||||
|
||||
Even a working WSL bash there cannot execute C:\\ script paths, so
|
||||
_windows_git_bash must reject it the same way it rejects System32.
|
||||
"""
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
def fake_which(exe, *args, **kwargs):
|
||||
if exe == "sh":
|
||||
return None
|
||||
if exe == "bash":
|
||||
return r"C:\Users\x\AppData\Local\Microsoft\WindowsApps\bash.exe"
|
||||
return None
|
||||
|
||||
with mock.patch.object(adapter.shutil, "which", side_effect=fake_which):
|
||||
sh_path, _ = adapter._windows_git_bash()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
|
||||
if sh_path is not None:
|
||||
self.assertNotIn("windowsapps", sh_path.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -121,11 +121,7 @@ class CodexSessionIsolationTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual("", result.stdout.strip())
|
||||
|
||||
def test_stop_does_not_block_unattached_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
+110
-2
@@ -32,6 +32,29 @@ def have_sh() -> bool:
|
||||
return shutil.which("sh") is not None
|
||||
|
||||
|
||||
def host_realpath(sh_path: str) -> str:
|
||||
"""Canonicalize a path emitted by an sh script for host-side comparison.
|
||||
|
||||
Under Git Bash on Windows the resolver prints POSIX-style paths, and the
|
||||
user temp directory is mounted at /tmp, so os.path.realpath on the raw
|
||||
string would resolve against a nonexistent C:\\tmp and the containment
|
||||
assertion would fail even though the resolved dir is contained. Map the
|
||||
path back to its Windows form with cygpath first, then canonicalize.
|
||||
"""
|
||||
if os.name == "nt" and sh_path.startswith("/"):
|
||||
cygpath = shutil.which("cygpath")
|
||||
if cygpath:
|
||||
mapped = subprocess.run(
|
||||
[cygpath, "-w", sh_path],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
if mapped:
|
||||
sh_path = mapped
|
||||
return os.path.realpath(sh_path)
|
||||
|
||||
|
||||
def can_make_escaping_symlink() -> bool:
|
||||
"""True only if we can create a dir symlink that realpath sees escaping root.
|
||||
|
||||
@@ -123,10 +146,12 @@ class ContainmentTests(unittest.TestCase):
|
||||
resolved.endswith("escape"),
|
||||
f"escaping symlink dir must be rejected, got {resolved!r}",
|
||||
)
|
||||
# And the resolved path (if any) must stay under the root.
|
||||
# And the resolved path (if any) must stay under the root. Compare
|
||||
# both sides in the host path domain: the resolver's stdout is a
|
||||
# POSIX-style path under Git Bash (see host_realpath).
|
||||
if resolved:
|
||||
self.assertTrue(
|
||||
os.path.realpath(resolved).startswith(os.path.realpath(str(root))),
|
||||
host_realpath(resolved).startswith(os.path.realpath(str(root))),
|
||||
f"resolved path escaped root: {resolved!r}",
|
||||
)
|
||||
|
||||
@@ -152,5 +177,88 @@ class ContainmentTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
INJECT_SH = REPO_ROOT / "skills" / "planning-with-files" / "scripts" / "inject-plan.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(have_sh(), "sh not available on this platform")
|
||||
class WindowsNativeCanonicalizerTests(unittest.TestCase):
|
||||
"""Regression: a Windows-native coreutils realpath (e.g. an installed
|
||||
C:\\Program Files\\coreutils on PATH ahead of Git's usr/bin) canonicalizes
|
||||
MSYS-style /c/... input to C:\\-style BACKSLASH output. The containment
|
||||
prefix match in is_within_root is written with forward slashes, so every
|
||||
canonical pair mismatched: the resolver (fail-closed) resolved nothing and
|
||||
inject-plan.sh (guarded exit) emitted nothing — plan resolution and hook
|
||||
injection silently dark on the whole machine. Found live 2026-07-18.
|
||||
|
||||
A PATH-prepended stub realpath reproduces the backslash output shape on
|
||||
every platform, so this guards the norm_slashes fix on Linux CI too.
|
||||
"""
|
||||
|
||||
def make_backslash_realpath(self, bin_dir: Path) -> None:
|
||||
stub = bin_dir / "realpath"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# test stub: Windows-native realpath shape (backslash separators)\n"
|
||||
'case "$1" in\n'
|
||||
' "") exit 1 ;;\n'
|
||||
' /*) abs="$1" ;;\n'
|
||||
' .) abs="$(pwd -P)" ;;\n'
|
||||
' *) abs="$(pwd -P)/$1" ;;\n'
|
||||
"esac\n"
|
||||
"printf '%s\\n' \"$abs\" | sed 's|/|\\\\|g'\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
os.chmod(stub, 0o755)
|
||||
|
||||
def run_with_stub(self, script: Path, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as stub_tmp:
|
||||
bin_dir = Path(stub_tmp) / "bin"
|
||||
bin_dir.mkdir()
|
||||
self.make_backslash_realpath(bin_dir)
|
||||
env = os.environ.copy()
|
||||
env.pop("PLAN_ID", None)
|
||||
env["PATH"] = str(bin_dir) + os.pathsep + env.get("PATH", "")
|
||||
return subprocess.run(
|
||||
["sh", str(script)],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_resolver_survives_backslash_realpath(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan = root / ".planning" / "real"
|
||||
plan.mkdir(parents=True)
|
||||
(plan / "task_plan.md").write_text("# real\n", encoding="utf-8")
|
||||
(root / ".planning" / ".active_plan").write_text("real\n", encoding="utf-8")
|
||||
result = self.run_with_stub(RESOLVE_SH, root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(
|
||||
result.stdout.strip().endswith("real"),
|
||||
f"backslash canonical paths must not kill resolution, got {result.stdout!r}",
|
||||
)
|
||||
|
||||
def test_inject_survives_backslash_realpath(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
plan = root / ".planning" / "real"
|
||||
plan.mkdir(parents=True)
|
||||
(plan / "task_plan.md").write_text("# real plan body\n", encoding="utf-8")
|
||||
(root / ".planning" / ".active_plan").write_text("real\n", encoding="utf-8")
|
||||
result = self.run_with_stub(INJECT_SH, root)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn(
|
||||
"===BEGIN PLAN DATA===",
|
||||
result.stdout,
|
||||
f"injection must survive backslash canonical paths, got {result.stdout!r}",
|
||||
)
|
||||
self.assertIn("# real plan body", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,7 +8,9 @@ by exercising the full hook→resolver→plan-file chain.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -18,13 +20,36 @@ 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]:
|
||||
def shell_and_env() -> tuple[str, dict[str, str]]:
|
||||
env = os.environ.copy()
|
||||
if os.name != "nt":
|
||||
shell = shutil.which("sh")
|
||||
if shell is None:
|
||||
raise unittest.SkipTest("POSIX sh is unavailable")
|
||||
return shell, env
|
||||
|
||||
sys.path.insert(0, str(HOOKS_DIR))
|
||||
try:
|
||||
import codex_hook_adapter as adapter
|
||||
|
||||
shell, extra_path_dirs = adapter._windows_git_bash()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if shell is None:
|
||||
raise unittest.SkipTest("Git for Windows sh.exe is unavailable")
|
||||
if extra_path_dirs:
|
||||
env["PATH"] = os.pathsep.join([*extra_path_dirs, env.get("PATH", "")])
|
||||
env.setdefault("PYTHON_BIN", sys.executable)
|
||||
return shell, env
|
||||
|
||||
|
||||
def run_hook(script: str, cwd: Path, env_extra: dict | None = None) -> subprocess.CompletedProcess[str]:
|
||||
shell, env = shell_and_env()
|
||||
env.pop("PLAN_ID", None)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
["sh", str(HOOKS_DIR / script)],
|
||||
[shell, str(HOOKS_DIR / script)],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -46,6 +71,12 @@ def write_plan_in_dir(plan_dir: Path, goal: str = "Ship the feature") -> None:
|
||||
|
||||
class HookResolverIntegrationTests(unittest.TestCase):
|
||||
|
||||
def test_global_resolver_is_a_thin_canonical_forwarder(self) -> None:
|
||||
resolver = (HOOKS_DIR / "resolve-plan-dir.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("../skills/planning-with-files/scripts/resolve-plan-dir.sh", resolver)
|
||||
self.assertIn('exec sh "${CANONICAL_RESOLVER}" "$@"', resolver)
|
||||
self.assertNotIn("resolve_latest_dir()", resolver)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# user-prompt-submit.sh
|
||||
# ------------------------------------------------------------------
|
||||
@@ -94,6 +125,84 @@ class HookResolverIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("Task A goal", result.stdout)
|
||||
self.assertNotIn("Task B goal", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_accepts_utf8_bom_active_pointer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
safe = root / ".planning" / "safe"
|
||||
write_plan_in_dir(safe, goal="BOM-safe goal")
|
||||
decoy = root / ".planning" / "newer-decoy"
|
||||
write_plan_in_dir(decoy, goal="Newest fallback goal")
|
||||
# If BOM stripping regresses, newest-dir fallback must select the
|
||||
# decoy and make this test fail instead of masking the bug.
|
||||
os.utime(safe, (100, 100))
|
||||
os.utime(decoy, (200, 200))
|
||||
(root / ".planning" / ".active_plan").write_bytes(b"\xef\xbb\xbfsafe\n")
|
||||
|
||||
result = run_hook("user-prompt-submit.sh", root)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("BOM-safe goal", result.stdout)
|
||||
self.assertNotIn("Newest fallback goal", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_rejects_traversal_plan_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
write_plan_in_dir(root / ".planning" / "safe", goal="Workspace plan")
|
||||
write_plan_in_dir(root / "outside", goal="Escaped plan")
|
||||
(root / ".planning" / ".active_plan").write_text("safe\n", encoding="utf-8")
|
||||
|
||||
result = run_hook(
|
||||
"user-prompt-submit.sh",
|
||||
root,
|
||||
env_extra={"PLAN_ID": "../outside"},
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("Workspace plan", result.stdout)
|
||||
self.assertNotIn("Escaped plan", result.stdout)
|
||||
|
||||
def test_user_prompt_submit_rejects_external_symlink_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp:
|
||||
root = Path(tmp)
|
||||
safe = root / ".planning" / "safe"
|
||||
outside = Path(outside_tmp) / "outside"
|
||||
write_plan_in_dir(safe, goal="Workspace plan")
|
||||
write_plan_in_dir(outside, goal="Escaped plan")
|
||||
(root / ".planning" / ".active_plan").write_text("safe\n", encoding="utf-8")
|
||||
escape = root / ".planning" / "escape"
|
||||
try:
|
||||
try:
|
||||
escape.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as symlink_error:
|
||||
if os.name != "nt":
|
||||
self.skipTest(f"directory symlinks are unavailable: {symlink_error}")
|
||||
junction = subprocess.run(
|
||||
["cmd", "/d", "/c", "mklink", "/J", str(escape), str(outside)],
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if junction.returncode != 0:
|
||||
self.skipTest("directory symlinks and junctions are unavailable")
|
||||
|
||||
result = run_hook(
|
||||
"user-prompt-submit.sh",
|
||||
root,
|
||||
env_extra={"PLAN_ID": "escape"},
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("Workspace plan", result.stdout)
|
||||
self.assertNotIn("Escaped plan", result.stdout)
|
||||
finally:
|
||||
if escape.is_symlink():
|
||||
escape.unlink()
|
||||
elif os.name == "nt" and escape.exists():
|
||||
# os.rmdir removes the junction itself, never its target.
|
||||
os.rmdir(escape)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# pre-tool-use.sh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -29,10 +29,12 @@ class SessionCatchupPathSanitizeTests(unittest.TestCase):
|
||||
project_dir = session_catchup.get_project_dir_claude(project_path)
|
||||
return project_dir.name
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows-shaped input; on POSIX Path.resolve() treats C:/... as relative and prepends the CWD")
|
||||
def test_windows_native_path(self) -> None:
|
||||
result = self._sanitized_name("C:/Users/oasrvadmin/Documents/planning-with-files-repo")
|
||||
self.assertEqual(result, "C--Users-oasrvadmin-Documents-planning-with-files-repo")
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows-shaped input; on POSIX Path.resolve() treats C:/... as relative and prepends the CWD")
|
||||
def test_git_bash_path(self) -> None:
|
||||
result = self._sanitized_name("/c/Users/oasrvadmin/Documents/planning-with-files-repo")
|
||||
self.assertEqual(result, "C--Users-oasrvadmin-Documents-planning-with-files-repo")
|
||||
|
||||
@@ -45,6 +45,7 @@ DUAL_SHIPPED = [
|
||||
"ledger-summary.sh",
|
||||
"phase-status.ps1",
|
||||
"phase-status.sh",
|
||||
"plan-doctor.sh",
|
||||
"resolve-plan-dir.ps1",
|
||||
"resolve-plan-dir.sh",
|
||||
"set-active-plan.ps1",
|
||||
|
||||
@@ -3,6 +3,8 @@ import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
@@ -238,6 +240,50 @@ class SessionCatchupCodexTests(unittest.TestCase):
|
||||
self.assertIn("Last planning update: task_plan.md", output)
|
||||
self.assertIn("CODEX: Codex summary after planning update", output)
|
||||
|
||||
def test_codex_cli_prints_unicode_when_parent_requests_cp1252(self):
|
||||
for filename in self.module.PLANNING_FILES:
|
||||
(self.project_dir / filename).write_text("# test\n", encoding="utf-8")
|
||||
self.write_codex_session(
|
||||
"rollout-2026-04-07T00-00-00-unicode-thread.jsonl",
|
||||
records=[
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:02.000Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "patch_apply_end",
|
||||
"success": True,
|
||||
"changes": {"task_plan.md": {"operation": "modified"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:03.000Z",
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "继续完成中文计划"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["CODEX_SESSIONS_DIR"] = str(self.sessions_dir)
|
||||
env["PYTHONIOENCODING"] = "cp1252"
|
||||
env.pop("CODEX_THREAD_ID", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(self.codex_script), self.project_path],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("继续完成中文计划", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -6,6 +6,8 @@ init-session.sh for parallel multi-task workflows (#148).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -14,6 +16,10 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SET_ACTIVE_SH = REPO_ROOT / "scripts" / "set-active-plan.sh"
|
||||
SET_ACTIVE_PS1 = REPO_ROOT / "scripts" / "set-active-plan.ps1"
|
||||
RESOLVE_SH = REPO_ROOT / "scripts" / "resolve-plan-dir.sh"
|
||||
POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
SH = shutil.which("sh")
|
||||
|
||||
|
||||
def run_set_active(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
@@ -32,6 +38,68 @@ class SetActivePlanTests(unittest.TestCase):
|
||||
def test_script_exists(self) -> None:
|
||||
self.assertTrue(SET_ACTIVE_SH.exists(), "scripts/set-active-plan.sh missing")
|
||||
|
||||
def test_powershell_script_is_ascii_safe_for_windows_powershell_5(self) -> None:
|
||||
"""UTF-8 without a BOM must not become invalid Windows PowerShell syntax."""
|
||||
self.assertTrue(SET_ACTIVE_PS1.exists(), "scripts/set-active-plan.ps1 missing")
|
||||
try:
|
||||
SET_ACTIVE_PS1.read_bytes().decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
self.fail(
|
||||
"set-active-plan.ps1 must stay ASCII-safe because Windows PowerShell 5.1 "
|
||||
f"can decode BOM-less UTF-8 as the active ANSI code page: {error}"
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
POWERSHELL and SH,
|
||||
"requires Windows PowerShell and sh for the cross-shell regression test",
|
||||
)
|
||||
def test_powershell_pointer_is_utf8_without_bom_and_resolves(self) -> None:
|
||||
"""The PS 5.1 writer must not add a BOM that the shell resolver rejects."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
selected = root / ".planning" / "selected"
|
||||
newest = root / ".planning" / "newest"
|
||||
selected.mkdir(parents=True)
|
||||
newest.mkdir(parents=True)
|
||||
(selected / "task_plan.md").write_text("# Selected\n", encoding="utf-8")
|
||||
(newest / "task_plan.md").write_text("# Newest\n", encoding="utf-8")
|
||||
selected_mtime = selected.stat().st_mtime
|
||||
os.utime(newest, (selected_mtime + 10, selected_mtime + 10))
|
||||
|
||||
written = subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(SET_ACTIVE_PS1),
|
||||
"selected",
|
||||
],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(0, written.returncode, written.stderr)
|
||||
self.assertEqual(
|
||||
b"selected",
|
||||
(root / ".planning" / ".active_plan").read_bytes(),
|
||||
)
|
||||
|
||||
resolved = subprocess.run(
|
||||
[SH, str(RESOLVE_SH)],
|
||||
cwd=str(root),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(0, resolved.returncode, resolved.stderr)
|
||||
self.assertTrue(resolved.stdout.strip().endswith("selected"))
|
||||
|
||||
def test_no_args_no_active_plan_prints_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = run_set_active(Path(tmp))
|
||||
|
||||
Reference in New Issue
Block a user