Compare commits

...
62 Commits
Author SHA1 Message Date
OthmanAdi 51c1caa27f release: v3.21.0 2026-09-27 04:43:01 +02:00
OthmanAdi 683d591a79 fix: validate explicit attestation targets 2026-09-27 04:42:31 +02:00
TayfurYldz fca9b04f95 feat: add explicit plan attestation target
Signed-off-by: TayfurYldz <238304586+TayfurYldz@users.noreply.github.com>
2026-09-27 04:42:31 +02:00
TayfurYldz 049943aa08 fix(powershell): retry pointer pre-check races 2026-09-27 04:19:09 +02:00
OthmanAdi 4d24d9a8a2 release: v3.20.8 2026-09-25 17:35:37 +02:00
OthmanAdi 21f25c090f fix: mark the Cursor and Gemini hook scripts executable
Gemini CLI runs every hook command through `bash -c <command>`, and
Cursor's hook documentation requires script hooks to be executable. The
four Gemini hooks and the Cursor pre-tool-use, post-tool-use and stop
hooks were committed as 100644, so in a macOS or Linux clone they failed
with "Permission denied" and did nothing. Mark every .sh under
.cursor/hooks and .gemini/hooks executable, and add a test that checks
the git mode of each script the two manifests run by path.
2026-09-25 17:33:41 +02:00
OthmanAdi b0ee2cdd24 fix(cursor): keep the stop hook silent when every phase is complete
Cursor submits a stop hook's followup_message as the next user message.
When every phase of the plan was complete, stop.sh and stop.ps1 still
returned an "ALL PHASES COMPLETE" followup_message, so each stop in a
project with a finished plan triggered automatic follow-up turns, up to
the manifest's loop_limit of 3. This was live on the Windows route and
would reach macOS and Linux once the shell hooks are executable. Exit
without output instead, as completed plans do on the other routes; an
incomplete plan still asks Cursor to continue. Both branches are tested
under sh, Windows PowerShell and pwsh.
2026-09-25 17:33:41 +02:00
OthmanAdi 4c2b269d30 fix(powershell): keep a successful pointer switch successful
The cleanup after replacement returned failure on the first failed
inspection. With concurrent selectors the pointer is briefly missing
while another writer replaces it, so a call whose own Replace had
already succeeded exited 1 and left its .replace-backup behind: about 1
in 10 calls in an 8-writer stress run on Windows PowerShell 5.1 and
pwsh 7. Because the pointer was already switched, init-session.ps1 then
skipped the gated-mode files for a plan that was active.

Retry transient inspection failures instead of giving up. After a
successful Replace, only delete the owned backup: it holds the
superseded value, so it is never moved back and never gated on the
current pointer, and failing to delete it is a warning, not an error.
The regression test holds the backup open after a successful Replace
and expects exit 0, the new pointer and the warning.
2026-09-25 16:40:55 +02:00
kuei51307-hub 94131052d6 fix(powershell): own pointer replacement backups 2026-09-25 16:40:55 +02:00
OthmanAdi a22e4865c6 test(cursor): read the stale-pointer root plan from sessionStart
test_stale_pointer_falls_through_to_the_root_plan still expected the
plan text on preToolUse output, which now carries only the permission
answer. It failed on ubuntu and macOS CI and on Windows. Assert the root
plan in the sessionStart additional_context and the allow response from
preToolUse instead.
2026-09-25 15:46:18 +02:00
OthmanAdi b3ed5256a5 fix(cursor): isolate the session-start hook's Python
session-start.sh called python3 without -I, so a json.py in the project
directory was imported and executed when a Cursor session started. On
Windows the Microsoft Store python3 alias also made the hook return {}.
Try python3, then python, each with -I -X utf8, and keep the {} response
when neither runs. Cover the isolation with a planted json.py test and a
check that every interpreter call in the hook is isolated; the
failing-Python test now stubs both names.
2026-09-25 15:46:18 +02:00
Som Samantray 605f51e09e fix(cursor): align hooks with current schema 2026-09-25 15:46:18 +02:00
OthmanAdi 4c4d0fc74c fix(gemini): list the registered hook events in SKILL.md metadata
The Gemini skill metadata still named SessionStart, BeforeTool, AfterTool
and BeforeModel after the hooks moved to BeforeAgent and SessionEnd.
List the events .gemini/settings.json registers, and assert in the Gemini
contract test that the metadata line and the settings keys agree.
2026-09-25 15:35:21 +02:00
Shaun Lin 77d56b3d00 test(gemini): keep schema fix fork-safe 2026-09-25 15:35:21 +02:00
Shaun Lin 67bfa9971f fix(gemini): align hook context schema 2026-09-25 15:35:21 +02:00
Maciej MisiurekandClaude Fable 5.1 37d6442d7e check-complete: automatic gate check stays silent when no plan exists
The Stop hook runs check-complete --gate on every turn end. In a cwd with
no task_plan.md it echoed "No task_plan.md found — no active planning
session.", which the Claude Code hook surfaced as a system notice after
every reply in every session that never opted into planning.

The script header already promises that plans stay silent in --gate mode;
this makes the no-plan branch honor it. The explicit report (no --gate,
/status) keeps the notice. Same change in check-complete.ps1, the five
language variants, and the synced IDE copies; regression test in
tests/test_quiet_completion_hooks.py covers both checkers and all three
hook routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 15:28:43 +02:00
OthmanAdi 3b7690eafd release: v3.20.7 2026-09-23 22:13:44 +02:00
OthmanAdi f473fa8930 fix: disclose Pi package planning capabilities 2026-09-23 22:09:00 +02:00
OthmanAdi 3827d3c018 release: v3.20.6 2026-09-23 21:58:59 +02:00
OthmanAdi b7edae42dc fix: retry transient PowerShell pointer inspection 2026-09-23 21:51:09 +02:00
OthmanAdi 10c64d464e fix: compare canonical active plan pointer roots 2026-09-23 21:16:28 +02:00
TayfurYldz a82c5a4588 fix: keep DSH core in sync 2026-09-23 21:16:28 +02:00
TayfurYldz 16b6a452bc fix: replace OpenCode active pointer safely 2026-09-23 21:16:28 +02:00
TayfurYldz abd39f7e9b fix: retry concurrent PowerShell pointer replacement 2026-09-23 20:55:49 +02:00
TayfurYldz 388b591f77 fix: recover PowerShell bracketed project cwd 2026-09-23 20:55:43 +02:00
TayfurYldz 87b2553fa1 test: scope read-only pointer case to Windows 2026-09-23 20:55:38 +02:00
TayfurYldz a3bd42954b fix: reject read-only active plan pointers 2026-09-23 20:55:38 +02:00
TayfurYldz 5165ffe847 fix: replace Hermes active pointer safely 2026-09-23 20:55:22 +02:00
kuei51307-hub f7b9be9d58 fix(locking): claim phase status lock atomically 2026-09-23 20:54:59 +02:00
OthmanAdi 0827eeaecf docs: add official skills.sh install badge 2026-09-22 22:07:58 +02:00
OthmanAdi 09d2254d76 docs: restore ClawHub chart below star history 2026-09-22 21:58:33 +02:00
OthmanAdi 78e5bf7b5b docs: simplify star history to the graph 2026-09-22 21:57:34 +02:00
OthmanAdi 27ab93c15d docs: show full star history below reference dropdown 2026-09-22 21:49:40 +02:00
OthmanAdi 4a40450390 docs: remove workflow caption 2026-09-22 19:51:19 +02:00
OthmanAdi 0bd06d344b docs: refresh README layout and visual story 2026-09-22 17:38:29 +02:00
OthmanAdi 5ac39bcb41 docs: align v3.20.5 citation date and replay description 2026-09-21 21:00:22 +02:00
OthmanAdi b81c488d46 release: v3.20.5 2026-09-21 20:59:55 +02:00
Shaun Lin f869dfb4d0 fix: fail PowerShell init on denied writes 2026-09-21 20:57:40 +02:00
kuei51307-hub 428b96c982 fix(init): report attestation failures 2026-09-21 20:57:36 +02:00
kuei51307-hub cd0bc3d202 fix(templates): add analytics next step 2026-09-21 20:56:57 +02:00
TayfurYldz 5ad5452276 fix: tolerate malformed OpenCode replay parts 2026-09-21 20:56:53 +02:00
OthmanAdi 726da18dc7 release: v3.20.4
PowerShell route on OneDrive (#275: pointer checks and the attester refuse only symlinks, junctions and name-surrogate reparse points) and the session-catchup copy guard checks tracked copies only (#274). Parity set bumped with scripts/bump-version.py; CHANGELOG, README releases row.
2026-09-19 20:39:00 +02:00
OthmanAdi 3a4f2077be fix(powershell): attest-plan.ps1 refuses only name-surrogate reparse points
ValidateRegular in the attester's native helper threw "Refusing a
reparse-point file" for any file with the ReparsePoint attribute, so in a
project under OneDrive (Files On-Demand marks every synced file, tag
0x9000601A) attestation, --show and --clear all failed, and init-session.ps1
still printed "attested" because it swallows the attester's failure. The
check now refuses only name-surrogate tags (bit 29 set: symlinks, junctions
and any unknown surrogate), which is exactly the set Windows path parsing
follows, so the security property is unchanged and placeholder files read
as the regular files they are. Every mirror synced (#275).
2026-09-19 20:35:25 +02:00
OthmanAdi 864577857d test: keep the exact session-catchup.py basename on the git pathspec 2026-09-19 20:35:25 +02:00
OthmanAdi a33fc3eb1a fix(powershell): the .active_plan pointer check is LinkType, not the ReparsePoint attribute
resolve-plan-dir.ps1, the Cursor hook resolve-plan-context.ps1 and the
set-active-plan.ps1 writer refused an .active_plan pointer that carried the
ReparsePoint attribute. OneDrive Files On-Demand marks every synced file as
a reparse point, so in a project under OneDrive the Cursor hooks stopped
with the unsafe-pointer notice, the resolver printed nothing for
attest-plan.ps1 and check-complete.ps1, and set-active-plan.ps1 refused to
write, while every other route read the pointer normally. The three checks
now refuse a container or a LinkType of SymbolicLink or Junction, the same
predicate v3.20.3 gave the directory checks and the `[ -L ]` of the shell
scripts. Every mirror synced. Tests cover the plain pointer and the
symlinked pointer (skipped where file symlinks need a privilege). Fixes #275.
2026-09-19 20:18:27 +02:00
OthmanAdi 85dfd5dcdd test: check only tracked session-catchup.py copies
The exact-basename guard walked the whole checkout, so any untracked copy
of session-catchup.py, such as a stale clone kept under the gitignored
.planning/ directory on a maintainer machine, was loaded as a shipped copy
and failed the two tests with 120 subtests while CI stayed green. Shipped
means tracked: the copies now come from git ls-files, with the filtered
walk kept only for a checkout without git, where .planning and .git are
excluded as well. Fixes #274.
2026-09-19 20:18:27 +02:00
OthmanAdi 57fe376da9 release: v3.20.3
A symlinked or junctioned directory under .planning/ is never a plan on
any route (#270, PR #271 by @ShaunLinTW plus three maintainer commits).

Parity set bumped to 3.20.3 with scripts/bump-version.py. CHANGELOG,
CONTRIBUTORS.md (PR #271 for Shaun Lin), README releases row.
2026-09-19 11:24:08 +02:00
OthmanAdi 663908180e docs(hermes): name the routes that share the linked-directory rule 2026-09-19 11:23:53 +02:00
OthmanAdi f54f8144c2 fix(scripts): match symlinks and junctions only, and keep linked directories out of set-active-plan
Review of the previous two commits found the PowerShell resolver testing the
ReparsePoint attribute, which every OneDrive Files On-Demand directory
carries: a project under OneDrive would have lost every plan on the Cursor
route. The resolver now tests LinkType for SymbolicLink or Junction, the
exact `[ -L ]` of the shell scripts and is_link of the Python twin, and the
Codex counter uses the same symlink-or-junction tag rule instead of the
adapter's any-reparse helper.

set-active-plan.sh and set-active-plan.ps1 refuse to point the shared
default at a linked directory and leave it out of --list, since no route
selects it; the sh/py/ps1 suites cover PLAN_ID naming a link (refused with
the existing notice) and the pointer naming one (falls through to the real
plan like a stale pointer). Hermes docstring names the routes precisely (#270).
2026-09-19 11:13:08 +02:00
OthmanAdi 95cf6f499b fix(adapters): skip linked plan directories in the Codex, OpenCode and DSH plan counters
The Codex adapter's session_plan_requires_binding and the shared
countSelectablePlans of the OpenCode and DeepSeek Harness plugins counted a
symlinked or junctioned plan directory although their selection paths (and
the shell family since the previous commit) refuse it. A linked directory
never counts now, so the several-plans notice fires on the same trees on
every route. The two TypeScript cores stay byte-identical (#270).
2026-09-19 10:44:07 +02:00
OthmanAdi 0308a2f819 fix(scripts): a linked plan directory is never selectable, on every shell-family route
PR #271 made the several-plans counters in inject-plan.sh and
resolve-plan-dir.sh skip symlinked and junctioned plan directories, matching
the Hermes plugin. Skipping the link in the counter alone left the selection
paths following it through containment: one real plan plus a newer linked
one counted as one plan and the newest-mtime scan then selected the linked
directory, the mtime guess the #240 rule exists to prevent. The PowerShell
twin reproduced that after the counter port.

The rule now holds end to end in the shell family: resolve-plan-dir.sh,
inject-plan.sh, resolve-plan-dir.ps1 and the Python twin inject-plan.py
refuse a linked plan directory in the PLAN_ID, .active_plan and
newest-mtime branches and never count it. Every shipped copy is synced.

Tests: the sh/py parity suite gains the linked-directory fixture (the twin
contract), and test_unarmed_plan_ambiguity drives both resolvers and both
injectors over a real plan plus a junction or symlink (#270).
2026-09-19 10:43:59 +02:00
Shaun Lin 0bbc43cff3 fix: skip linked plan dirs in ambiguity counts 2026-09-19 10:36:51 +02:00
OthmanAdi f01a88a12b release: v3.20.2
Hermes plugin 0.2.2: the launch-directory notice for the Hermes 0.21.3
first-turn TERMINAL_CWD rewrite, the /pwf-status and /pwf diagnostics,
and PWF_PLAN_ROOT honored by the slash commands (#272).

Parity set bumped to 3.20.2 with scripts/bump-version.py. CHANGELOG,
CONTRIBUTORS.md (@ericshunhinglee-cloud), README releases row,
CITATION.cff date.
2026-09-19 09:59:30 +02:00
OthmanAdi 9a3843b31d docs(hermes): describe the Hermes 0.21.3 working-directory rewrite and the pin
Adds the section on the first-turn TERMINAL_CWD rewrite: the mechanism,
what was reproduced with the 0.21.0 source, what the plugin says since
v3.20.2, the PWF_PLAN_ROOT pin for project-scoped CLI sessions, the
shell-hook route as the unaffected alternative, and the home-directory
inverse risk (#272).
2026-09-19 09:57:44 +02:00
OthmanAdi 1528b31d7f fix(hermes): name the launch directory plan when the host re-homes the working directory
On Hermes 0.21.3 the first turn of a CLI session rewrites the
process-global TERMINAL_CWD to the home directory (agent/relay_runtime.py
imports gateway/run.py lazily, whose import-time bridge applies the
Path.home() fallback), so resolve_agent_cwd() names HOME, the hooks find
no plan there and inject nothing. The rewrite runs before the first
plugin hook, so the plugin cannot recover the root and does not guess.

pre_llm_call now injects one line naming the resolved directory, the
launch directory that holds a plan, and the PWF_PLAN_ROOT pin instead of
returning nothing. CLI only; silent for hermes -w worktree sessions
(TERMINAL_CWD at <repo>/.worktrees/<name> without a chdir is intended),
for a launch directory without planning state, and for a launch
directory whose session isolation refuses the session.

/pwf and /pwf-status honor PWF_PLAN_ROOT like the hooks and report a pin
that does not resolve instead of acting in the directory Hermes named.
/pwf-status prints the same line under "No planning files found", and
/pwf appends it when the plan it created went somewhere other than a
launch directory that already holds one.

Fixes #272. Upstream: NousResearch/hermes-agent#86411, #95577.
2026-09-19 09:57:38 +02:00
OthmanAdi faf1a15a7d release: v3.20.1 2026-09-18 17:32:57 +02:00
OthmanAdi c05821a1cf fix(init): bind the attest selectors in slug mode only, clear them in root mode
Binding PWF_PLAN_ROOT for the attestation call in both modes (#265) made
root-mode --autonomous and --gated plans unattested on the shell and the
PowerShell route: the attester only falls back to the legacy ./task_plan.md
when no selector is set and refuses the root plan under an explicit pin,
while init still reported the plan as attested and every hook then refused
to inject it. Slug mode keeps the binding from #265 (PWF_PLAN_ROOT and
PLAN_ID name the plan just created); root mode now clears both selectors,
which also stops an inherited PLAN_ID from redirecting the shell attester
to a sibling slug plan, as the PowerShell twin already did (#261).

Regression tests cover root-mode attestation for --autonomous and --gated
with and without an inherited PWF_PLAN_ROOT and PLAN_ID, on both routes.
2026-09-18 17:32:57 +02:00
kuei51307-hub 52572e3301 docs(hermes): clarify multiple-plan selection 2026-09-18 17:32:09 +02:00
kuei51307-hub 1df6f5808b fix(hermes): require selector for multiple plans 2026-09-18 17:32:09 +02:00
TayfurYldz d03002bcee fix: normalize multiline plan slugs 2026-09-18 17:31:54 +02:00
TayfurYldz 1bea197584 fix: bind init attestation to current project 2026-09-18 17:31:38 +02:00
Shaun Lin e33359977c fix(catchup): skip malformed OpenCode parts 2026-09-18 17:31:18 +02:00
312 changed files with 11332 additions and 1863 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ hooks:
- type: command
command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.0"
version: "3.21.0"
---
# Planning with Files
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -845,7 +845,10 @@ class Injector(object):
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
# A linked plan directory is never selectable (#270): the same
# `[ ! -L ]` the reference applies on every branch below.
if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
and not is_link(plan_prefix + ".planning/" + plan_id)):
resolved = plan_prefix + ".planning/" + plan_id
scope = "scoped"
explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
active = b""
if active and slug_is_valid(active):
slug = active.decode("ascii")
if is_dir(plan_prefix + ".planning/" + slug):
if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
resolved = plan_prefix + ".planning/" + slug
scope = "scoped"
if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
candidate = plan_prefix + ".planning/" + name
if not is_dir(candidate):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
candidate_dir = plan_root + "/" + name
# A linked plan directory is not selectable, so it never counts (#270):
# the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
if is_link(candidate_dir):
continue
if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
count += 1
if count > 1:
return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
if plan_id:
if slug_is_valid(plan_id):
candidate = fs_root + "/" + plan_id
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(plan_id)
return ("", "")
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
if slug_is_valid(active):
slug = active.decode("ascii")
candidate = fs_root + "/" + slug
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(slug)
if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
continue
if name.startswith("."):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -263,8 +263,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -295,7 +296,9 @@ if [ -n "${PLAN_ID:-}" ]; then
# printing on those would spam the transcript with the same line. The
# userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
# still sees and reports the state.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
# A linked plan directory is never selectable (#270): same `-L` rule as
# the counter above and as resolve-plan-dir.sh, on every branch below.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ] && [ ! -L "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
else
if [ "$CONTEXT" = "userprompt" ]; then
@@ -305,7 +308,7 @@ if [ -n "${PLAN_ID:-}" ]; then
fi
elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ] && [ ! -L "${PLAN_PREFIX}.planning/${AP}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${AP}"; SCOPE="scoped"
fi
fi
@@ -314,6 +317,7 @@ if [ -z "$RESOLVED" ] && [ -d "${PLAN_PREFIX}.planning" ]; then
for d in "${PLAN_PREFIX}".planning/*/; do
d="${d%/}"; n="${d##*/}"
case "$n" in .*) continue;; esac
[ -L "$d" ] && 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)
@@ -7,9 +7,10 @@
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
a read-modify-write under the portable
<plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
temp-file + move swap so a torn write can never leave a half-rewritten plan
on disk (architecture C4).
<plan-dir>\.pwf-locks\phase-status.lock namespace. Ownership is granted by
atomically creating its .owner file, rather than trusting an external mkdir
exit status. The plan rewrite uses an atomic temp-file + move swap so a torn
write can never leave a half-rewritten plan on disk (architecture C4).
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
phase boundaries (see attest-plan.ps1).
@@ -102,18 +103,44 @@ function Enter-PwfDirectoryLock {
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($wait.Elapsed.TotalSeconds -lt 5) {
$createdByUs = $false
try {
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
$createdByUs = $true
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
[void][System.IO.Directory]::CreateDirectory($LockDir)
} catch {
Start-Sleep -Milliseconds 100
continue
}
$stream = $null
$createdOwner = $false
$claimed = $false
try {
# FileMode.CreateNew is the cross-runtime ownership decision. It
# fails atomically when either the shell or PowerShell writer has
# already claimed .owner, even if both observed mkdir success.
$stream = [System.IO.File]::Open(
$ownerFile,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
$createdOwner = $true
$bytes = $utf8NoBom.GetBytes($token + "`n")
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
$claimed = $true
return [PSCustomObject]@{
Directory = $LockDir
OwnerFile = $ownerFile
Token = $token
}
} catch {
if ($createdByUs) {
# Existing ownership is the normal contention path. Other failures
# remain fail-closed and are retried only within the bounded wait.
} finally {
if ($stream) {
$stream.Dispose()
}
if ($createdOwner -and -not $claimed) {
try {
if ([System.IO.File]::Exists($ownerFile)) {
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
@@ -121,13 +148,12 @@ function Enter-PwfDirectoryLock {
[System.IO.File]::Delete($ownerFile)
}
}
[System.IO.Directory]::Delete($LockDir, $false)
} catch {
# Leave any directory we cannot prove is still ours intact.
# Leave any owner file we cannot prove is still ours intact.
}
}
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 100
}
return $null
@@ -174,7 +200,7 @@ $planDir = Split-Path -Parent $planFile
$lockRoot = Join-Path $planDir ".pwf-locks"
$lockDir = Join-Path $lockRoot "phase-status.lock"
# Atomic directory creation is the common lock primitive used by both the sh
# Exclusive .owner creation is the common lock primitive used by both the sh
# and PowerShell implementations. Failure to acquire within about five seconds
# is fail-closed: no plan read/rewrite is attempted.
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
@@ -4,9 +4,12 @@
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
# All status edits go through this read-modify-write under the portable
# <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
# temp-file + mv swap so a torn write can never leave a half-rewritten plan on
# disk (architecture C4).
# <plan-dir>/.pwf-locks/phase-status.lock namespace. Lock ownership is the
# atomic no-clobber creation of its .owner file; the directory's mkdir exit
# status is only provisional because some Windows-native implementations can
# report success to two simultaneous creators. The plan rewrite still uses an
# atomic temp-file + mv swap so a torn write can never leave a half-rewritten
# plan on disk (architecture C4).
#
# Note: editing task_plan.md changes its SHA, so the orchestrator must
# re-attest at phase boundaries (see attest-plan.sh).
@@ -106,6 +109,22 @@ release_lock() {
LOCK_ACQUIRED=0
}
claim_lock_owner() {
owner_file="${LOCK_DIR}/.owner"
# POSIX noclobber makes this redirection an exclusive create. This is the
# actual ownership decision: a Windows-native mkdir may report success
# after another process won the directory-creation race (issue #281).
if (
umask 077
set -C
printf '%s\n' "${LOCK_TOKEN}" > "${owner_file}"
) 2>/dev/null; then
LOCK_ACQUIRED=1
return 0
fi
return 1
}
acquire_lock() {
mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
@@ -114,7 +133,13 @@ acquire_lock() {
LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
started_at="$(date +%s 2>/dev/null || printf 0)"
attempts=0
while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
while :; do
# Directory creation is intentionally idempotent. The exclusive owner
# file below, not this external utility's exit status, grants the lock.
mkdir "${LOCK_DIR}" 2>/dev/null || true
if [ -d "${LOCK_DIR}" ] && claim_lock_owner; then
return 0
fi
attempts=$((attempts + 1))
now="$(date +%s 2>/dev/null || printf 0)"
if { [ "${started_at}" -gt 0 ] 2>/dev/null \
@@ -125,13 +150,6 @@ acquire_lock() {
fi
sleep 0.1
done
if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
rmdir "${LOCK_DIR}" 2>/dev/null || true
printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
return 1
fi
LOCK_ACQUIRED=1
return 0
}
trap 'release_lock' EXIT
@@ -139,6 +139,24 @@ function Test-WithinRoot {
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -158,6 +176,9 @@ if (-not $env:PLAN_ID) {
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
@@ -175,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -185,12 +206,14 @@ if ($env:PLAN_ID) {
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
@@ -198,7 +221,7 @@ if ($activeItem) {
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -211,6 +234,7 @@ if ($activeItem) {
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
@@ -246,10 +246,16 @@ mtime_of() {
printf "0\n"
}
# A linked plan directory (symlink or junction; `-L` sees both under Git
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
# newest-mtime scan, and it never counts below (#270). Containment alone let
# a link that stays inside the root be selected while the counter skipped
# it, so one real plan plus a newer linked one became an mtime guess again.
resolve_from_env() {
plan_id="${PLAN_ID:-}"
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
esac
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
case "${name}" in
.*) continue ;;
esac
[ -L "${clean}" ] && continue
slug_is_valid "${name}" || continue
[ -f "${clean}/task_plan.md" ] || continue
is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ or {}
input_ = input_ if isinstance(input_, dict) else {}
outcome = _opencode_state_annotation(state)
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
"""
SELECT time_created, data FROM part
WHERE session_id = ?
AND json_valid(data)
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
@@ -121,12 +121,65 @@ function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
# Reading may follow a verified in-project link; writing must not. A link
# is a symlink or junction by LinkType, never the bare ReparsePoint
# attribute: OneDrive Files On-Demand marks every synced file as a
# reparse point, and the pointer must stay writable there (#275).
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -204,6 +257,8 @@ function Show-PlanList {
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
# A linked plan directory is never a plan (#270): no resolver selects it.
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +336,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
@@ -294,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -305,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -272,6 +272,9 @@ list_plans() {
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
# A linked plan directory is never a plan (#270): no resolver selects
# it, so listing it would advertise a PLAN_ID every route refuses.
[ -L "${_dir}" ] && continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if [ -L "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "planning-with-files",
"source": "./",
"description": "Persistent project planning files with lifecycle hooks that inject selected plan context. Automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs commands declared in Markdown. No network upload path. Installs across 60+ agents.",
"version": "3.20.0"
"version": "3.21.0"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.0",
"version": "3.21.0",
"description": "Persistent project planning files with lifecycle hooks that inject selected plan context. Automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs commands declared in Markdown. No network upload path. Works across 60+ agents and includes Arabic, German, Spanish, Simplified Chinese, and Traditional Chinese.",
"author": {
"name": "OthmanAdi",
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"${CODEBUDDY_PLUGIN_ROOT}/scripts/skill-hook.sh\" \"$HOME/.codebuddy/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.0"
version: "3.21.0"
---
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -845,7 +845,10 @@ class Injector(object):
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
# A linked plan directory is never selectable (#270): the same
# `[ ! -L ]` the reference applies on every branch below.
if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
and not is_link(plan_prefix + ".planning/" + plan_id)):
resolved = plan_prefix + ".planning/" + plan_id
scope = "scoped"
explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
active = b""
if active and slug_is_valid(active):
slug = active.decode("ascii")
if is_dir(plan_prefix + ".planning/" + slug):
if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
resolved = plan_prefix + ".planning/" + slug
scope = "scoped"
if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
candidate = plan_prefix + ".planning/" + name
if not is_dir(candidate):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
candidate_dir = plan_root + "/" + name
# A linked plan directory is not selectable, so it never counts (#270):
# the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
if is_link(candidate_dir):
continue
if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
count += 1
if count > 1:
return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
if plan_id:
if slug_is_valid(plan_id):
candidate = fs_root + "/" + plan_id
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(plan_id)
return ("", "")
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
if slug_is_valid(active):
slug = active.decode("ascii")
candidate = fs_root + "/" + slug
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(slug)
if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
continue
if name.startswith("."):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -263,8 +263,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -295,7 +296,9 @@ if [ -n "${PLAN_ID:-}" ]; then
# printing on those would spam the transcript with the same line. The
# userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
# still sees and reports the state.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
# A linked plan directory is never selectable (#270): same `-L` rule as
# the counter above and as resolve-plan-dir.sh, on every branch below.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ] && [ ! -L "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
else
if [ "$CONTEXT" = "userprompt" ]; then
@@ -305,7 +308,7 @@ if [ -n "${PLAN_ID:-}" ]; then
fi
elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ] && [ ! -L "${PLAN_PREFIX}.planning/${AP}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${AP}"; SCOPE="scoped"
fi
fi
@@ -314,6 +317,7 @@ if [ -z "$RESOLVED" ] && [ -d "${PLAN_PREFIX}.planning" ]; then
for d in "${PLAN_PREFIX}".planning/*/; do
d="${d%/}"; n="${d##*/}"
case "$n" in .*) continue;; esac
[ -L "$d" ] && 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)
@@ -139,6 +139,24 @@ function Test-WithinRoot {
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -158,6 +176,9 @@ if (-not $env:PLAN_ID) {
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
@@ -175,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -185,12 +206,14 @@ if ($env:PLAN_ID) {
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
@@ -198,7 +221,7 @@ if ($activeItem) {
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -211,6 +234,7 @@ if ($activeItem) {
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
@@ -246,10 +246,16 @@ mtime_of() {
printf "0\n"
}
# A linked plan directory (symlink or junction; `-L` sees both under Git
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
# newest-mtime scan, and it never counts below (#270). Containment alone let
# a link that stays inside the root be selected while the counter skipped
# it, so one real plan plus a newer linked one became an mtime guess again.
resolve_from_env() {
plan_id="${PLAN_ID:-}"
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
esac
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
case "${name}" in
.*) continue ;;
esac
[ -L "${clean}" ] && continue
slug_is_valid "${name}" || continue
[ -f "${clean}/task_plan.md" ] || continue
is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ or {}
input_ = input_ if isinstance(input_, dict) else {}
outcome = _opencode_state_annotation(state)
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
"""
SELECT time_created, data FROM part
WHERE session_id = ?
AND json_valid(data)
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
@@ -121,12 +121,65 @@ function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
# Reading may follow a verified in-project link; writing must not. A link
# is a symlink or junction by LinkType, never the bare ReparsePoint
# attribute: OneDrive Files On-Demand marks every synced file as a
# reparse point, and the pointer must stay writable there (#275).
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -204,6 +257,8 @@ function Show-PlanList {
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
# A linked plan directory is never a plan (#270): no resolver selects it.
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +336,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
@@ -294,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -305,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -272,6 +272,9 @@ list_plans() {
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
# A linked plan directory is never a plan (#270): no resolver selects
# it, so listing it would advertise a PLAN_ID every route refuses.
[ -L "${_dir}" ] && continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if [ -L "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.0",
"version": "3.21.0",
"description": "Persistent project planning for Codex. Opt-in lifecycle hooks inject selected plan context; automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs Markdown-declared commands. No network upload path.",
"author": {
"name": "OthmanAdi",
+25
View File
@@ -90,6 +90,27 @@ def _is_reparse_or_link(path: Path) -> bool:
return bool(attrs & reparse)
_LINK_REPARSE_TAGS = (0xA000000C, 0xA0000003) # IO_REPARSE_TAG_SYMLINK, _MOUNT_POINT
def _is_linked_directory(path: Path) -> bool:
"""`[ -L ]` of the shell scripts: a symlink, or a junction on Windows.
Narrower than _is_reparse_or_link on purpose: OneDrive Files On-Demand
marks every synced directory as a reparse point, and those are plans.
"""
try:
info = path.lstat()
except OSError:
return False
if stat.S_ISLNK(info.st_mode):
return True
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
if getattr(info, "st_file_attributes", 0) & reparse:
return getattr(info, "st_reparse_tag", 0) in _LINK_REPARSE_TAGS
return False
def _is_trusted_darwin_system_alias(path: Path) -> bool:
"""Admit only macOS's fixed root aliases after verifying their targets."""
if sys.platform != "darwin":
@@ -243,6 +264,10 @@ def session_plan_requires_binding(root: Path) -> bool:
for child in planning_dir.iterdir():
if not _PLAN_SLUG.fullmatch(child.name) or not child.is_dir():
continue
# A linked plan directory is not selectable and never counts,
# matching `[ -L ]` in the shell counters (#270).
if _is_linked_directory(child):
continue
if (child / "task_plan.md").is_file():
candidates += 1
if candidates > 1:
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.codex/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.0"
version: "3.21.0"
---
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -845,7 +845,10 @@ class Injector(object):
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
# A linked plan directory is never selectable (#270): the same
# `[ ! -L ]` the reference applies on every branch below.
if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
and not is_link(plan_prefix + ".planning/" + plan_id)):
resolved = plan_prefix + ".planning/" + plan_id
scope = "scoped"
explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
active = b""
if active and slug_is_valid(active):
slug = active.decode("ascii")
if is_dir(plan_prefix + ".planning/" + slug):
if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
resolved = plan_prefix + ".planning/" + slug
scope = "scoped"
if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
candidate = plan_prefix + ".planning/" + name
if not is_dir(candidate):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
candidate_dir = plan_root + "/" + name
# A linked plan directory is not selectable, so it never counts (#270):
# the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
if is_link(candidate_dir):
continue
if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
count += 1
if count > 1:
return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
if plan_id:
if slug_is_valid(plan_id):
candidate = fs_root + "/" + plan_id
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(plan_id)
return ("", "")
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
if slug_is_valid(active):
slug = active.decode("ascii")
candidate = fs_root + "/" + slug
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(slug)
if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
continue
if name.startswith("."):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -263,8 +263,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -295,7 +296,9 @@ if [ -n "${PLAN_ID:-}" ]; then
# printing on those would spam the transcript with the same line. The
# userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
# still sees and reports the state.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
# A linked plan directory is never selectable (#270): same `-L` rule as
# the counter above and as resolve-plan-dir.sh, on every branch below.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ] && [ ! -L "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
else
if [ "$CONTEXT" = "userprompt" ]; then
@@ -305,7 +308,7 @@ if [ -n "${PLAN_ID:-}" ]; then
fi
elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ] && [ ! -L "${PLAN_PREFIX}.planning/${AP}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${AP}"; SCOPE="scoped"
fi
fi
@@ -314,6 +317,7 @@ if [ -z "$RESOLVED" ] && [ -d "${PLAN_PREFIX}.planning" ]; then
for d in "${PLAN_PREFIX}".planning/*/; do
d="${d%/}"; n="${d##*/}"
case "$n" in .*) continue;; esac
[ -L "$d" ] && 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)
@@ -139,6 +139,24 @@ function Test-WithinRoot {
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -158,6 +176,9 @@ if (-not $env:PLAN_ID) {
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
@@ -175,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -185,12 +206,14 @@ if ($env:PLAN_ID) {
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
@@ -198,7 +221,7 @@ if ($activeItem) {
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -211,6 +234,7 @@ if ($activeItem) {
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
@@ -246,10 +246,16 @@ mtime_of() {
printf "0\n"
}
# A linked plan directory (symlink or junction; `-L` sees both under Git
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
# newest-mtime scan, and it never counts below (#270). Containment alone let
# a link that stays inside the root be selected while the counter skipped
# it, so one real plan plus a newer linked one became an mtime guess again.
resolve_from_env() {
plan_id="${PLAN_ID:-}"
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
esac
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
case "${name}" in
.*) continue ;;
esac
[ -L "${clean}" ] && continue
slug_is_valid "${name}" || continue
[ -f "${clean}/task_plan.md" ] || continue
is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ or {}
input_ = input_ if isinstance(input_, dict) else {}
outcome = _opencode_state_annotation(state)
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
"""
SELECT time_created, data FROM part
WHERE session_id = ?
AND json_valid(data)
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
@@ -121,12 +121,65 @@ function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
# Reading may follow a verified in-project link; writing must not. A link
# is a symlink or junction by LinkType, never the bare ReparsePoint
# attribute: OneDrive Files On-Demand marks every synced file as a
# reparse point, and the pointer must stay writable there (#275).
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -204,6 +257,8 @@ function Show-PlanList {
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
# A linked plan directory is never a plan (#270): no resolver selects it.
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +336,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
@@ -294,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -305,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -272,6 +272,9 @@ list_plans() {
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
# A linked plan directory is never a plan (#270): no resolver selects
# it, so listing it would advertise a PLAN_ID every route refuses.
[ -L "${_dir}" ] && continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if [ -L "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -139,6 +139,24 @@ function Test-WithinRoot {
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -158,6 +176,9 @@ if (-not $env:PLAN_ID) {
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
@@ -175,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -185,12 +206,14 @@ if ($env:PLAN_ID) {
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
@@ -198,7 +221,7 @@ if ($activeItem) {
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -211,6 +234,7 @@ if ($activeItem) {
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
@@ -246,10 +246,16 @@ mtime_of() {
printf "0\n"
}
# A linked plan directory (symlink or junction; `-L` sees both under Git
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
# newest-mtime scan, and it never counts below (#270). Containment alone let
# a link that stays inside the root be selected while the counter skipped
# it, so one real plan plus a newer linked one became an mtime guess again.
resolve_from_env() {
plan_id="${PLAN_ID:-}"
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
esac
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
case "${name}" in
.*) continue ;;
esac
[ -L "${clean}" ] && continue
slug_is_valid "${name}" || continue
[ -f "${clean}/task_plan.md" ] || continue
is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ or {}
input_ = input_ if isinstance(input_, dict) else {}
outcome = _opencode_state_annotation(state)
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
"""
SELECT time_created, data FROM part
WHERE session_id = ?
AND json_valid(data)
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
@@ -121,12 +121,65 @@ function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
# Reading may follow a verified in-project link; writing must not. A link
# is a symlink or junction by LinkType, never the bare ReparsePoint
# attribute: OneDrive Files On-Demand marks every synced file as a
# reparse point, and the pointer must stay writable there (#275).
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -204,6 +257,8 @@ function Show-PlanList {
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
# A linked plan directory is never a plan (#270): no resolver selects it.
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +336,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
@@ -294,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -305,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -272,6 +272,9 @@ list_plans() {
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
# A linked plan directory is never a plan (#270): no resolver selects
# it, so listing it would advertise a PLAN_ID every route refuses.
[ -L "${_dir}" ] && continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if [ -L "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
+2 -2
View File
@@ -1,9 +1,9 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": ".cursor/hooks/user-prompt-submit.sh",
"command": ".cursor/hooks/session-start.sh",
"timeout": 5
}
],
+5 -5
View File
@@ -1,29 +1,29 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/user-prompt-submit.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/session-start.ps1",
"timeout": 5
}
],
"preToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"matcher": "Write|Edit|Shell|Read",
"timeout": 5
}
],
"postToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"matcher": "Write|Edit",
"timeout": 5
}
],
"stop": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"timeout": 10,
"loop_limit": 3
}
+23 -11
View File
@@ -1,24 +1,36 @@
# planning-with-files: Post-tool-use hook for Cursor (PowerShell)
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Write-Output "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
$response = @{
additional_context = "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
}
Write-Output ($response | ConvertTo-Json -Compress)
} else {
Write-Output '{}'
}
} catch {
Write-Output '{}'
}
exit 0
Regular → Executable
+5 -3
View File
@@ -1,12 +1,14 @@
#!/bin/bash
# planning-with-files: Post-tool-use hook for Cursor
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && exit 0
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{}'; exit 0; }
if [ -f task_plan.md ]; then
echo "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
echo '{"additional_context":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}'
else
echo '{}'
fi
exit 0
+4 -35
View File
@@ -1,37 +1,6 @@
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{"decision": "allow"}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
# The protocol response is printed from finally: a missing helper or a
# profile that sets $ErrorActionPreference = 'Stop' must not end the hook
# without it. This hook never blocks a tool.
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Get-Content -LiteralPath $PlanFile -TotalCount 30 | Write-Host
}
} catch {
# A planning error is never a reason to hold a tool.
} finally {
Write-Output '{"decision": "allow"}'
}
Write-Output '{"permission":"allow"}'
exit 0
Regular → Executable
+3 -14
View File
@@ -1,18 +1,7 @@
#!/bin/bash
# planning-with-files: Pre-tool-use hook for Cursor
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{"decision": "allow"}'; exit 0; }
PLAN_FILE="task_plan.md"
if [ -f "$PLAN_FILE" ]; then
# Log plan context to stderr (visible in Cursor's hook logs)
head -30 "$PLAN_FILE" >&2
fi
echo '{"decision": "allow"}'
echo '{"permission":"allow"}'
exit 0
+5 -1
View File
@@ -88,9 +88,13 @@ function Resolve-CursorPlanContext {
# inject-plan.sh is. A stale pointer text, a dot-named directory or an
# invalid slug is ignored the way inject-plan.sh ignores it, so the legacy
# root applies, as on every other route.
# A pointer that is a directory or a symlink is unsafe. LinkType, not the
# ReparsePoint attribute: OneDrive Files On-Demand marks every synced file
# as a reparse point and such a pointer is a plain file to every other
# route; the attribute test made this hook go silent on OneDrive (#275).
$activeItem = Get-Item -LiteralPath (Join-Path $planRoot '.active_plan') -Force -ErrorAction SilentlyContinue
if ($activeItem -and ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))) {
(([string]$activeItem.LinkType) -in @('SymbolicLink', 'Junction')))) {
return (New-CursorPlanContext $null 'invalid' 'unsafe-pointer')
}
if ($planRootExists) {
+18
View File
@@ -0,0 +1,18 @@
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
try {
$context = & (Join-Path $PSScriptRoot 'user-prompt-submit.ps1') | Out-String
if ([string]::IsNullOrWhiteSpace($context)) {
Write-Output '{}'
exit 0
}
$response = @{ additional_context = $context.TrimEnd() }
Write-Output ($response | ConvertTo-Json -Compress)
} catch {
Write-Output '{}'
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if [ "${PLANNING_DISABLED:-}" = "1" ]; then
echo '{}'
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTEXT="$(sh "${SCRIPT_DIR}/user-prompt-submit.sh")"
if [ -z "${CONTEXT}" ]; then
echo '{}'
exit 0
fi
# JSON-escape the context with the first interpreter that actually runs:
# python3 can be a stub (the Windows Store alias), so python is tried next.
# -I keeps a json.py in the project directory from being imported.
RESPONSE=""
for PYTHON in "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do
[ -n "${PYTHON}" ] || continue
RESPONSE="$(printf '%s' "${CONTEXT}" | "$PYTHON" -I -X utf8 -c 'import json,sys; print(json.dumps({"additional_context": sys.stdin.buffer.read().decode("utf-8", "replace")}))' 2>/dev/null)" || RESPONSE=""
[ -n "${RESPONSE}" ] && break
done
# Keep the response valid when no Python runs; planning scripts require
# Python for their normal context-injection path as well.
if [ -n "${RESPONSE}" ]; then
printf '%s\n' "${RESPONSE}"
else
echo '{}'
fi
exit 0
+2 -1
View File
@@ -48,7 +48,8 @@ if ($TOTAL -eq 0) {
}
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
Write-Host "{`"followup_message`": `"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.`"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
} else {
Write-Host "{`"followup_message`": `"[planning-with-files] Task incomplete ($COMPLETE/$TOTAL phases done). Update progress.md, then read task_plan.md and continue working on the remaining phases.`"}"
Regular → Executable
+2 -2
View File
@@ -43,8 +43,8 @@ if [ "$TOTAL" -eq 0 ]; then
fi
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
# All phases complete — provide re-entry guidance
echo "{\"followup_message\": \"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.\"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
else
# Phases incomplete — auto-continue via followup_message
+4 -4
View File
@@ -4,7 +4,7 @@
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') { return }
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
@@ -33,7 +33,7 @@ if (-not $PlanContext.Directory) {
Write-Output "[planning-with-files] The selected plan could not be resolved safely ($($PlanContext.Detail)). Check PLAN_ID, PWF_PLAN_ROOT, and .active_plan; nothing injected."
}
}
exit 0
return
}
$planFile = Join-Path $PlanContext.Directory "task_plan.md"
@@ -69,7 +69,7 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
if ($nestedRoots.Count -gt 0) {
$nestedList = (@($nestedRoots | Select-Object -First 3)) -join ", "
Write-Output "[planning-with-files] Ambiguous plan: this cwd has an active plan and a nested project below it has its own ($nestedList). Nothing injected. Pin the thread with PWF_PLAN_ROOT=<absolute path> or PLAN_ID=<slug>."
exit 0
return
}
}
@@ -90,4 +90,4 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
Write-Output ""
Write-Output "[planning-with-files] Read findings.md for research context. Continue from the current phase."
}
exit 0
return
View File
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.cursor/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.0"
version: "3.21.0"
---
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -845,7 +845,10 @@ class Injector(object):
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
# A linked plan directory is never selectable (#270): the same
# `[ ! -L ]` the reference applies on every branch below.
if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
and not is_link(plan_prefix + ".planning/" + plan_id)):
resolved = plan_prefix + ".planning/" + plan_id
scope = "scoped"
explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
active = b""
if active and slug_is_valid(active):
slug = active.decode("ascii")
if is_dir(plan_prefix + ".planning/" + slug):
if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
resolved = plan_prefix + ".planning/" + slug
scope = "scoped"
if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
candidate = plan_prefix + ".planning/" + name
if not is_dir(candidate):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
candidate_dir = plan_root + "/" + name
# A linked plan directory is not selectable, so it never counts (#270):
# the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
if is_link(candidate_dir):
continue
if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
count += 1
if count > 1:
return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
if plan_id:
if slug_is_valid(plan_id):
candidate = fs_root + "/" + plan_id
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(plan_id)
return ("", "")
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
if slug_is_valid(active):
slug = active.decode("ascii")
candidate = fs_root + "/" + slug
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(slug)
if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
continue
if name.startswith("."):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -263,8 +263,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -295,7 +296,9 @@ if [ -n "${PLAN_ID:-}" ]; then
# printing on those would spam the transcript with the same line. The
# userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
# still sees and reports the state.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
# A linked plan directory is never selectable (#270): same `-L` rule as
# the counter above and as resolve-plan-dir.sh, on every branch below.
if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ] && [ ! -L "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
else
if [ "$CONTEXT" = "userprompt" ]; then
@@ -305,7 +308,7 @@ if [ -n "${PLAN_ID:-}" ]; then
fi
elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ] && [ ! -L "${PLAN_PREFIX}.planning/${AP}" ]; then
RESOLVED="${PLAN_PREFIX}.planning/${AP}"; SCOPE="scoped"
fi
fi
@@ -314,6 +317,7 @@ if [ -z "$RESOLVED" ] && [ -d "${PLAN_PREFIX}.planning" ]; then
for d in "${PLAN_PREFIX}".planning/*/; do
d="${d%/}"; n="${d##*/}"
case "$n" in .*) continue;; esac
[ -L "$d" ] && 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)
@@ -139,6 +139,24 @@ function Test-WithinRoot {
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -158,6 +176,9 @@ if (-not $env:PLAN_ID) {
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
@@ -175,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -185,12 +206,14 @@ if ($env:PLAN_ID) {
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
@@ -198,7 +221,7 @@ if ($activeItem) {
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
@@ -211,6 +234,7 @@ if ($activeItem) {
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
@@ -246,10 +246,16 @@ mtime_of() {
printf "0\n"
}
# A linked plan directory (symlink or junction; `-L` sees both under Git
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
# newest-mtime scan, and it never counts below (#270). Containment alone let
# a link that stays inside the root be selected while the counter skipped
# it, so one real plan plus a newer linked one became an mtime guess again.
resolve_from_env() {
plan_id="${PLAN_ID:-}"
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
esac
slug_is_valid "${plan_id}" || return 1
candidate="${PLAN_ROOT}/${plan_id}"
[ -L "${candidate}" ] && return 1
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
printf "%s\n" "${candidate}"
return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
case "${name}" in
.*) continue ;;
esac
[ -L "${clean}" ] && continue
slug_is_valid "${name}" || continue
[ -f "${clean}/task_plan.md" ] || continue
is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
[ -L "$plan_candidate_dir" ] && continue
[ -f "$plan_candidate" ] || continue
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ or {}
input_ = input_ if isinstance(input_, dict) else {}
outcome = _opencode_state_annotation(state)
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
"""
SELECT time_created, data FROM part
WHERE session_id = ?
AND json_valid(data)
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
@@ -121,12 +121,65 @@ function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
# Reading may follow a verified in-project link; writing must not. A link
# is a symlink or junction by LinkType, never the bare ReparsePoint
# attribute: OneDrive Files On-Demand marks every synced file as a
# reparse point, and the pointer must stay writable there (#275).
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -204,6 +257,8 @@ function Show-PlanList {
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
# A linked plan directory is never a plan (#270): no resolver selects it.
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +336,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
@@ -294,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -305,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -272,6 +272,9 @@ list_plans() {
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
# A linked plan directory is never a plan (#270): no resolver selects
# it, so listing it would advertise a PLAN_ID every route refuses.
[ -L "${_dir}" ] && continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if [ -L "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
@@ -188,8 +188,14 @@ describe("resolver", () => {
fs.writeFileSync(path.join(planning, ".active_plan"), "2026-09-02-evil\n")
expect(resolvePlan(root, {}, env).planDir).toBeNull()
expect(resolvePlan(root, {}, { ...env, PLAN_ID: "2026-09-02-evil" }).planDir).toBeNull()
// a linked plan directory never counts toward the several-plans rule (#270)
expect(countSelectablePlans(root)).toBe(0)
const real = path.join(planning, "2026-09-02-real")
fs.mkdirSync(real)
fs.writeFileSync(path.join(real, "task_plan.md"), "# REAL\n")
expect(countSelectablePlans(root)).toBe(1)
expect(resolvePlan(root, {}, env).planDir).toBe(real)
fs.rmSync(path.join(real, "task_plan.md"))
try {
fs.symlinkSync(path.join(outside, "task_plan.md"), path.join(real, "task_plan.md"), "file")
} catch {
@@ -366,6 +372,41 @@ describe("init and status", () => {
expect(buildContext(root, dir)).toContain("[planning-with-files] plan: ")
})
it("updates a regular active plan pointer through a directory junction project root", () => {
const linkedRoot = `${root}-junction`
fs.symlinkSync(root, linkedRoot, "junction")
try {
const first = initPlan(linkedRoot, { name: "First" }, env)
expect(first.ok).toBe(true)
const second = initPlan(linkedRoot, { name: "Second" }, env)
expect(second.ok).toBe(true)
const pointer = path.join(root, ".planning", ".active_plan")
expect(fs.lstatSync(pointer).isFile()).toBe(true)
expect(fs.readFileSync(pointer, "utf8")).toBe(`${second.plan_id}\n`)
} finally {
fs.rmSync(linkedRoot, { recursive: true, force: true })
}
})
it("refuses a linked planning directory without changing its external pointer", () => {
const outside = `${root}-outside`
const planning = path.join(root, ".planning")
fs.mkdirSync(outside)
const pointer = path.join(outside, ".active_plan")
fs.writeFileSync(pointer, "external-plan\n")
fs.symlinkSync(outside, planning, "junction")
try {
const result = initPlan(root, { name: "Blocked" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(pointer, "utf8")).toBe("external-plan\n")
expect(fs.readdirSync(outside)).toEqual([".active_plan"])
} finally {
fs.rmSync(planning, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
it("uses a real skill template when one is discoverable, and rejects unknown modes", () => {
const skill = path.join(root, ".agents", "skills", "planning-with-files", "templates")
fs.mkdirSync(skill, { recursive: true })
@@ -86,6 +86,40 @@ function writeText(target: string, text: string): void {
fs.writeFileSync(target, text, { encoding: "utf8" })
}
function activePlanPointerError(planningRoot: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const realRoot = realpathOrNull(planningRoot)
if (!isRealDir(planningRoot) || !realRoot) return `refusing to replace unsafe active plan pointer: ${pointer}`
if (!fs.existsSync(pointer)) return null
const st = lstatSafe(pointer)
const real = realpathOrNull(pointer)
return !st || !st.isFile() || st.isSymbolicLink() || st.nlink > 1 || !real || !isInside(real, realRoot)
? `refusing to replace unsafe active plan pointer: ${pointer}`
: null
}
function writeActivePlan(planningRoot: string, planId: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const unsafe = activePlanPointerError(planningRoot)
if (unsafe) return unsafe
const tmp = path.join(planningRoot, `.active_plan.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`)
let fd: number | null = null
try {
fd = fs.openSync(tmp, "wx")
fs.writeFileSync(fd, `${planId}\n`, { encoding: "utf8" })
fs.closeSync(fd)
fd = null
fs.renameSync(tmp, pointer)
return null
} catch (err) {
return `could not safely update active plan pointer: ${err instanceof Error ? err.message : String(err)}`
} finally {
if (fd !== null) fs.closeSync(fd)
try { fs.unlinkSync(tmp) } catch { /* already renamed or absent */ }
}
}
function realpathOrNull(target: string): string | null {
try {
return fs.realpathSync(target)
@@ -205,6 +239,9 @@ export function countSelectablePlans(root: string): number {
}
for (const entry of entries) {
if (!slugIsValid(entry)) continue
// A linked plan directory is not selectable and never counts (#270),
// matching the shell counters, the Hermes plugin and resolvePlan below.
if (!isRealDir(path.join(planningRoot, entry))) continue
if (!isRegularFile(path.join(planningRoot, entry, "task_plan.md"))) continue
count += 1
if (count > 1) break
@@ -733,8 +770,11 @@ export function initPlan(
fs.mkdirSync(planningRoot, { recursive: true })
planId = `${new Date().toISOString().slice(0, 10)}-${slug}`
planDir = path.join(planningRoot, planId)
const pointerError = activePlanPointerError(planningRoot)
if (pointerError) return { ok: false, error: pointerError, project_dir: root }
fs.mkdirSync(planDir, { recursive: true })
writeText(path.join(planningRoot, ".active_plan"), `${planId}\n`)
const writeError = writeActivePlan(planningRoot, planId)
if (writeError) return { ok: false, error: writeError, project_dir: root }
}
const created = copyTemplates(planDir, templatesDir, template)
const result: InitResult = {
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.factory/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.0"
version: "3.21.0"
---
# Planning with Files
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
handle, FileAttributeTagInfo, out tag,
(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
throw new Win32Exception(Marshal.GetLastWin32Error());
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
// Refuse only name-surrogate reparse points (symlinks, junctions, and any
// unknown tag with bit 29 set): those are what path parsing follows. A
// OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
// that every other route reads; refusing it broke attestation, --show
// and --clear in every project under OneDrive (#275).
if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
(tag.ReparseTag & 0x20000000) != 0)
throw new IOException("Refusing a reparse-point file.");
if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
throw new IOException("Refusing a directory where a regular file is required.");
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -350,42 +425,94 @@ if ($Mode -ne "") {
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
# attester instead. Bind slug mode to the plan we just created so an
# inherited PLAN_ID cannot redirect attestation to another plan.
# attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
# we just created so an inherited pin or slug cannot redirect attestation
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
try {
if ($UsePlanDir) {
$env:PWF_PLAN_ROOT = (Get-Location).Path
$env:PLAN_ID = $PlanId
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
} else {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
if ($HadPlanRoot) {
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
} else {
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
}
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -103,6 +103,7 @@ slugify() {
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| tr '\r\n' '--' \
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
| cut -c1-40
}
@@ -185,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
# (c) auto-attest the plan (attestation default-on in v3 modes, security
# strand rec 1). attest-plan.sh resolves the same way init-session just
# pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
# mode it is empty and the script falls back to ./task_plan.md at root.
# Run from the project root (CWD here) so both resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
# pinned things. Slug mode binds both selectors to the plan that was
# just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
# redirect attestation to another project or plan (#261, #237). Root
# mode clears both instead: the attester only falls back to the legacy
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -845,7 +845,10 @@ class Injector(object):
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
# A linked plan directory is never selectable (#270): the same
# `[ ! -L ]` the reference applies on every branch below.
if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
and not is_link(plan_prefix + ".planning/" + plan_id)):
resolved = plan_prefix + ".planning/" + plan_id
scope = "scoped"
explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
active = b""
if active and slug_is_valid(active):
slug = active.decode("ascii")
if is_dir(plan_prefix + ".planning/" + slug):
if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
resolved = plan_prefix + ".planning/" + slug
scope = "scoped"
if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
candidate = plan_prefix + ".planning/" + name
if not is_dir(candidate):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
candidate_dir = plan_root + "/" + name
# A linked plan directory is not selectable, so it never counts (#270):
# the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
if is_link(candidate_dir):
continue
if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
count += 1
if count > 1:
return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
if plan_id:
if slug_is_valid(plan_id):
candidate = fs_root + "/" + plan_id
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(plan_id)
return ("", "")
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
if slug_is_valid(active):
slug = active.decode("ascii")
candidate = fs_root + "/" + slug
if is_dir(candidate) and within(candidate):
if is_dir(candidate) and not is_link(candidate) and within(candidate):
return found(slug)
if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
continue
if name.startswith("."):
continue
if is_link(candidate):
continue
if not slug_is_valid(name):
continue
if not is_file(candidate + "/task_plan.md"):

Some files were not shown because too many files have changed in this diff Show More