planning-with-files 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -220,7 +220,7 @@ Helper scripts for automation:
220
220
 
221
221
  - `scripts/init-session.sh` — Initialize planning files. With a name arg, creates an isolated plan under `.planning/YYYY-MM-DD-<slug>/` for parallel task workflows. Without args, writes `task_plan.md` at project root (legacy mode, backward-compatible).
222
222
  - `scripts/set-active-plan.sh` — Switch the active plan pointer (`.planning/.active_plan`). Run with a plan ID to switch; run without args to show which plan is current.
223
- - `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. Checks `$PLAN_ID` env var first, then `.planning/.active_plan`, then newest plan dir by mtime, then falls back to project root (legacy). Used internally by hooks.
223
+ - `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, checks `.planning/.active_plan`, then newest plan dir by mtime, then falls back to project root (legacy). Used internally by hooks.
224
224
  - `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
225
225
  - `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history.
226
226
  - `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
@@ -133,7 +133,7 @@ describe("slug validation and containment parity with the sh resolver (v3.8.1)",
133
133
  }
134
134
  });
135
135
 
136
- it("rejects a PLAN_ID containing whitespace", () => {
136
+ it("stops on a PLAN_ID containing whitespace instead of resolving another plan (#237)", () => {
137
137
  const root = makeWorkspace();
138
138
  writeScopedPlan(root, "plan a", "# spaced");
139
139
  writeScopedPlan(root, "plan-fallback", "# fallback");
@@ -142,7 +142,25 @@ describe("slug validation and containment parity with the sh resolver (v3.8.1)",
142
142
  process.env.PLAN_ID = "plan a";
143
143
  try {
144
144
  const paths = resolvePlanPaths(root);
145
- expect(paths.planId).toBe("plan-fallback");
145
+ expect(paths.scope).toBe("none");
146
+ expect(paths.planId).toBeUndefined();
147
+ } finally {
148
+ if (previous === undefined) delete process.env.PLAN_ID;
149
+ else process.env.PLAN_ID = previous;
150
+ }
151
+ });
152
+
153
+ it("stops when a valid-shape PLAN_ID names no directory, ignoring .active_plan (#237)", () => {
154
+ const root = makeWorkspace();
155
+ writeScopedPlan(root, "plan-active", "# active");
156
+ writeFileSync(join(root, ".planning", ".active_plan"), "plan-active");
157
+
158
+ const previous = process.env.PLAN_ID;
159
+ process.env.PLAN_ID = "plan-actve";
160
+ try {
161
+ const paths = resolvePlanPaths(root);
162
+ expect(paths.scope).toBe("none");
163
+ expect(paths.planId).toBeUndefined();
146
164
  } finally {
147
165
  if (previous === undefined) delete process.env.PLAN_ID;
148
166
  else process.env.PLAN_ID = previous;
@@ -1,17 +1,17 @@
1
- {
2
- "name": "planning-with-files-pi-extension",
3
- "version": "1.2.4",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "test": "vitest run"
8
- },
9
- "devDependencies": {
10
- "@types/node": "^22.10.1",
11
- "typescript": "^5.7.2",
12
- "vitest": "^2.1.8"
13
- },
14
- "peerDependencies": {
15
- "@earendil-works/pi-coding-agent": "*"
16
- }
17
- }
1
+ {
2
+ "name": "planning-with-files-pi-extension",
3
+ "version": "1.2.5",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "vitest run"
8
+ },
9
+ "devDependencies": {
10
+ "@types/node": "^22.10.1",
11
+ "typescript": "^5.7.2",
12
+ "vitest": "^2.1.8"
13
+ },
14
+ "peerDependencies": {
15
+ "@earendil-works/pi-coding-agent": "*"
16
+ }
17
+ }
@@ -144,12 +144,35 @@ export function resolvePlanPaths(sessionCwd: string): PlanPaths {
144
144
  attestationCandidates: [join(cwd, ".plan-attestation")],
145
145
  });
146
146
 
147
+ const makeNone = (): PlanPaths => ({
148
+ cwd,
149
+ scope: "none",
150
+ attestationCandidates: [join(cwd, ".plan-attestation")],
151
+ });
152
+
153
+ // A set PLAN_ID is a BINDING, not a hint (issue #237).
154
+ //
155
+ // A slug that resolves to a contained scoped plan wins. One that does NOT
156
+ // resolve ends resolution right here. Falling through to .active_plan, the
157
+ // newest slug and the root plan turned a one-character typo into a silent
158
+ // switch: the operator asked for plan A, the pointer or newest-by-mtime
159
+ // answered with plan B, and B was what got attested and injected at rc=0.
160
+ // Every rejection route ends the same way, whether the selector failed
161
+ // SLUG_RE (traversal shapes included), named no plan directory, or failed
162
+ // containment. The session gets the "none" scope and takes its own
163
+ // fail-closed path rather than a plan nobody selected.
164
+ //
165
+ // An EMPTY or unset PLAN_ID still means "no selector": resolution continues
166
+ // below exactly as before, which is what the legacy root path depends on.
147
167
  const planId = process.env.PLAN_ID?.trim();
148
- if (planId && SLUG_RE.test(planId)) {
149
- const candidate = join(planRoot, planId);
150
- if (existsSync(join(candidate, "task_plan.md")) && isWithinRoot(cwd, candidate)) {
151
- return makeScoped(candidate);
168
+ if (planId) {
169
+ if (SLUG_RE.test(planId)) {
170
+ const candidate = join(planRoot, planId);
171
+ if (existsSync(join(candidate, "task_plan.md")) && isWithinRoot(cwd, candidate)) {
172
+ return makeScoped(candidate);
173
+ }
152
174
  }
175
+ return makeNone();
153
176
  }
154
177
 
155
178
  const activePlanFile = join(planRoot, ".active_plan");
@@ -176,11 +199,7 @@ export function resolvePlanPaths(sessionCwd: string): PlanPaths {
176
199
  return rootPlan;
177
200
  }
178
201
 
179
- return {
180
- cwd,
181
- scope: "none",
182
- attestationCandidates: [join(cwd, ".plan-attestation")],
183
- };
202
+ return makeNone();
184
203
  }
185
204
 
186
205
  export function readPlanStatus(cwd: string): PlanStatus {
package/package.json CHANGED
@@ -1,56 +1,56 @@
1
- {
2
- "name": "planning-with-files",
3
- "version": "3.14.0",
4
- "description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
5
- "keywords": [
6
- "pi-package",
7
- "pi-skill",
8
- "planning",
9
- "manus",
10
- "agent",
11
- "agent-skills",
12
- "claude-code",
13
- "claude-skills",
14
- "coding-agent",
15
- "context-engineering",
16
- "session-recovery",
17
- "long-running-agents"
18
- ],
19
- "pi": {
20
- "skills": [
21
- "SKILL.md"
22
- ],
23
- "extensions": [
24
- "extensions/planning-with-files/index.ts"
25
- ]
26
- },
27
- "scripts": {
28
- "prepack": "node scripts/verify-shell-line-endings.mjs"
29
- },
30
- "files": [
31
- "README.md",
32
- "SKILL.md",
33
- "examples.md",
34
- "reference.md",
35
- "scripts/",
36
- "templates/",
37
- "extensions/",
38
- "!**/node_modules",
39
- "!**/__pycache__",
40
- "!**/*.pyc",
41
- "!extensions/planning-with-files/package-lock.json"
42
- ],
43
- "peerDependencies": {
44
- "@earendil-works/pi-coding-agent": "*"
45
- },
46
- "repository": {
47
- "type": "git",
48
- "url": "git+https://github.com/OthmanAdi/planning-with-files.git"
49
- },
50
- "author": "Ahmad Othman Ammar Adi",
51
- "license": "MIT",
52
- "bugs": {
53
- "url": "https://github.com/OthmanAdi/planning-with-files/issues"
54
- },
55
- "homepage": "https://github.com/OthmanAdi/planning-with-files#readme"
56
- }
1
+ {
2
+ "name": "planning-with-files",
3
+ "version": "3.16.0",
4
+ "description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-skill",
8
+ "planning",
9
+ "manus",
10
+ "agent",
11
+ "agent-skills",
12
+ "claude-code",
13
+ "claude-skills",
14
+ "coding-agent",
15
+ "context-engineering",
16
+ "session-recovery",
17
+ "long-running-agents"
18
+ ],
19
+ "pi": {
20
+ "skills": [
21
+ "SKILL.md"
22
+ ],
23
+ "extensions": [
24
+ "extensions/planning-with-files/index.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "prepack": "node scripts/verify-shell-line-endings.mjs"
29
+ },
30
+ "files": [
31
+ "README.md",
32
+ "SKILL.md",
33
+ "examples.md",
34
+ "reference.md",
35
+ "scripts/",
36
+ "templates/",
37
+ "extensions/",
38
+ "!**/node_modules",
39
+ "!**/__pycache__",
40
+ "!**/*.pyc",
41
+ "!extensions/planning-with-files/package-lock.json"
42
+ ],
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/OthmanAdi/planning-with-files.git"
49
+ },
50
+ "author": "Ahmad Othman Ammar Adi",
51
+ "license": "MIT",
52
+ "bugs": {
53
+ "url": "https://github.com/OthmanAdi/planning-with-files/issues"
54
+ },
55
+ "homepage": "https://github.com/OthmanAdi/planning-with-files#readme"
56
+ }
@@ -108,7 +108,18 @@ case "${1:-}" in
108
108
  esac
109
109
 
110
110
  plan_file="$(resolve_plan_file)" || {
111
- printf "[plan-attest] No task_plan.md found. Create a plan first.\n" >&2
111
+ # Name the actual cause. "No task_plan.md found" is true but misleading
112
+ # when the plan exists and an explicit selector was rejected: before #237
113
+ # a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
114
+ # who now sees a generic not-found is likely to go looking for the wrong
115
+ # problem. The selectors are bindings, so say which one refused.
116
+ if [ -n "${PLAN_ID:-}" ]; then
117
+ 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
118
+ elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
119
+ 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
120
+ else
121
+ printf "[plan-attest] No task_plan.md found. Create a plan first.\n" >&2
122
+ fi
112
123
  exit 1
113
124
  }
114
125
 
@@ -107,13 +107,27 @@ if (-not $Gate) {
107
107
 
108
108
  # ---- Gate path (-Gate). Resolves to advisory unless every guard says block. ----
109
109
 
110
- # Guard 1: gated mode. The .mode file must contain "gate".
110
+ # Guard 1: gated mode. A .mode file must contain "gate".
111
+ #
112
+ # The project's root .mode is a FLOOR, not a default that slug scope replaces
113
+ # (issue #238). Reading only <plan-dir>\.mode let a slug plan with no .mode
114
+ # drop a project-committed gate. "gate" from EITHER file arms the gate; a slug
115
+ # may raise strictness, never lower it. In root scope $PlanDir already IS the
116
+ # project root, so the second source is skipped and behavior is unchanged.
111
117
  $modeFile = Join-Path $PlanDir ".mode"
118
+ $rootForMode = if ($env:PWF_PLAN_ROOT) { $env:PWF_PLAN_ROOT } else { "." }
119
+ $rootModeFile = $null
120
+ if ($PlanDir -ne $rootForMode -and $PlanDir -ne ".") {
121
+ $rootModeFile = Join-Path $rootForMode ".mode"
122
+ }
112
123
  $gatedMode = $false
113
- if (Test-Path $modeFile) {
114
- $modeContent = Get-Content $modeFile -Raw -ErrorAction SilentlyContinue
124
+ foreach ($candidateMode in @($modeFile, $rootModeFile)) {
125
+ if (-not $candidateMode) { continue }
126
+ if (-not (Test-Path $candidateMode)) { continue }
127
+ $modeContent = Get-Content $candidateMode -Raw -ErrorAction SilentlyContinue
115
128
  if ($null -ne $modeContent -and $modeContent -match "gate") {
116
129
  $gatedMode = $true
130
+ break
117
131
  }
118
132
  }
119
133
  if (-not $gatedMode) {
@@ -60,6 +60,14 @@ else
60
60
  if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
61
61
  PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
62
62
  PLAN_DIR="${RESOLVED_DIR}"
63
+ elif [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
64
+ # Explicit selectors are bindings, not hints (issue #237). The shared
65
+ # resolver rejected one, so the legacy cwd fallback below must not run:
66
+ # answering a mistyped pin with the ROOT plan's completion state is the
67
+ # same wrong-plan harm the binding removes, and here it would decide
68
+ # whether an autonomous run is allowed to stop.
69
+ echo "[planning-with-files] An explicit PLAN_ID or PWF_PLAN_ROOT did not resolve to a plan; no completion state was read and no other plan was substituted."
70
+ exit 0
63
71
  else
64
72
  PLAN_FILE="task_plan.md"
65
73
  PLAN_DIR="."
@@ -131,10 +139,30 @@ fi
131
139
 
132
140
  # ---- Gate path (--gate). Resolves to advisory unless every guard says block. ----
133
141
 
134
- # Guard 1: gated mode. The .mode file must contain "gate". Absent or other
142
+ # Guard 1: gated mode. A .mode file must contain "gate". Absent or other
135
143
  # content means advisory mode (legacy behavior preserved).
144
+ #
145
+ # The project's root .mode is a FLOOR, not a default that slug scope replaces
146
+ # (issue #238). Reading only <plan-dir>/.mode let a slug plan with no .mode
147
+ # drop a project-committed gate, the same way it dropped the attestation
148
+ # requirement in inject-plan.sh. "gate" from EITHER file arms the gate; a slug
149
+ # may raise strictness, never lower it. In root scope PLAN_DIR already IS the
150
+ # project root, so the second source stays empty and behavior is unchanged.
136
151
  MODE_FILE="${PLAN_DIR}/.mode"
137
- if [ ! -f "${MODE_FILE}" ] || ! grep -q "gate" "${MODE_FILE}" 2>/dev/null; then
152
+ ROOT_MODE_FILE=""
153
+ _root_for_mode="${PWF_PLAN_ROOT:-.}"
154
+ if [ "${PLAN_DIR}" != "${_root_for_mode}" ] && [ "${PLAN_DIR}" != "." ]; then
155
+ ROOT_MODE_FILE="${_root_for_mode}/.mode"
156
+ fi
157
+ GATED=0
158
+ if [ -f "${MODE_FILE}" ] && grep -q "gate" "${MODE_FILE}" 2>/dev/null; then
159
+ GATED=1
160
+ fi
161
+ if [ "${GATED}" -eq 0 ] && [ -n "${ROOT_MODE_FILE}" ] && [ -f "${ROOT_MODE_FILE}" ] \
162
+ && grep -q "gate" "${ROOT_MODE_FILE}" 2>/dev/null; then
163
+ GATED=1
164
+ fi
165
+ if [ "${GATED}" -eq 0 ]; then
138
166
  advisory_report
139
167
  exit 0
140
168
  fi
@@ -152,6 +152,33 @@ gen_nonce() {
152
152
  # $1 = plan dir (absolute or relative); dotfiles live directly inside it.
153
153
  # $2 = plan file path (task_plan.md) used for auto-attestation resolution.
154
154
  # No-op when MODE is empty (legacy path stays byte-equivalent to v2.43.0).
155
+ # Raise MODE to the project's committed floor before the side effects run
156
+ # (issue #238). A project that ships a root .mode has made that setting a
157
+ # reviewed part of the repo; a new slug plan must not start below it. Without
158
+ # this, `init-session.sh <name>` created a plan with no .mode at all, and the
159
+ # project's attestation requirement became a flag the agent chose at plan
160
+ # creation time.
161
+ #
162
+ # inject-plan.sh enforces the same floor at read time, so this is not the
163
+ # guard. It exists so the effective policy is VISIBLE in the plan directory
164
+ # rather than only inside the resolver, and so the new plan gets the nonce and
165
+ # the auto-attestation that autonomous mode needs to inject at all.
166
+ #
167
+ # An explicit --autonomous/--gated is never lowered: gated stays gated.
168
+ inherit_root_mode() {
169
+ _root_mode="${PWD}/.mode"
170
+ [ -f "${_root_mode}" ] || return 0
171
+ [ "$MODE" = "gated" ] && return 0
172
+ if grep -q 'gate' "${_root_mode}" 2>/dev/null; then
173
+ MODE='gated'
174
+ return 0
175
+ fi
176
+ if grep -q 'autonomous' "${_root_mode}" 2>/dev/null; then
177
+ MODE='autonomous'
178
+ fi
179
+ return 0
180
+ }
181
+
155
182
  apply_v3_mode() {
156
183
  _mode_dir="$1"
157
184
  _mode_plan="$2"
@@ -367,6 +394,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
367
394
  echo "PLAN_ID=$PLAN_ID"
368
395
  create_files_in "$PLAN_DIR"
369
396
  printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
397
+ inherit_root_mode
370
398
  apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
371
399
  echo ""
372
400
  echo "Active plan recorded: ${PLAN_ROOT}/.active_plan"
@@ -254,8 +254,28 @@ SCOPE=""
254
254
  EXPLICIT=0
255
255
  [ -n "$PLAN_PREFIX" ] && EXPLICIT=1
256
256
  [ "$SESSION_ATTACHED" = "1" ] && EXPLICIT=1
257
- if [ -n "${PLAN_ID:-}" ] && slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
258
- RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
257
+ if [ -n "${PLAN_ID:-}" ]; then
258
+ # A set PLAN_ID is a BINDING, not a hint (issue #237). This inline resolver
259
+ # is the one the hooks actually run, so it carries the same rule as
260
+ # resolve-plan-dir.sh: a selector that names no directory, fails slug
261
+ # validation, or fails containment refuses instead of falling through to
262
+ # .active_plan and newest-by-mtime. The fall-through is what let a
263
+ # one-character typo inject a DIFFERENT plan while attest-plan.sh locked
264
+ # that same wrong plan at rc=0.
265
+ #
266
+ # Unlike the PWF_PLAN_ROOT refusal above, the notice is userprompt-only.
267
+ # pretool fires per tool call and precompact carries no plan body, so
268
+ # printing on those would spam the transcript with the same line. The
269
+ # userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
270
+ # still sees and reports the state.
271
+ if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
272
+ RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
273
+ else
274
+ if [ "$CONTEXT" = "userprompt" ]; then
275
+ echo "[planning-with-files] PLAN_ID does not name a plan directory under .planning: ${PLAN_ID} — nothing injected. Fix or unset the pin; a broken pin fails closed rather than selecting another plan."
276
+ fi
277
+ exit 0
278
+ fi
259
279
  elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
260
280
  AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
261
281
  if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
@@ -476,12 +496,16 @@ if [ "$SCOPE" = "root" ]; then
476
496
  PROGRESS_FILE="${PLAN_PREFIX}progress.md"
477
497
  ATTEST_FILE="${PLAN_PREFIX}.plan-attestation"
478
498
  MODE_FILE="${PLAN_PREFIX}.mode"
499
+ ROOT_MODE_FILE=""
479
500
  NONCE_FILE="${PLAN_PREFIX}.nonce"
480
501
  else
481
502
  PLAN_FILE="${RESOLVED}/task_plan.md"
482
503
  PROGRESS_FILE="${RESOLVED}/progress.md"
483
504
  ATTEST_FILE="${RESOLVED}/.attestation"
484
505
  MODE_FILE="${RESOLVED}/.mode"
506
+ # The project's own .mode, when it has one (issue #238). In root scope
507
+ # MODE_FILE already IS that file, so the second source stays empty.
508
+ ROOT_MODE_FILE="${PLAN_PREFIX}.mode"
485
509
  NONCE_FILE="${RESOLVED}/.nonce"
486
510
  fi
487
511
  [ -f "$PLAN_FILE" ] || exit 0
@@ -853,11 +877,52 @@ fi
853
877
  # gated mode to legacy behavior (platform-critical: per-tool-call injection not
854
878
  # suppressed, oracle re-hash skipped, raw progress tail injected). Use a grep
855
879
  # token test, the same pattern check-complete.sh guard 1 uses.
880
+
881
+ # --- Root .mode is a FLOOR, not a default that slug scope replaces (#238). ---
882
+ # A project makes attestation mandatory by committing a root .mode, which is a
883
+ # reviewed project setting. Slug scope used to read ONLY the slug's .mode, and
884
+ # init-session.sh writes no .mode unless --autonomous or --gated was passed, so
885
+ # `init-session.sh <name>` produced a plan with no mode, no attestation
886
+ # requirement and full injection: one agent-invocable command turned the
887
+ # project's policy off.
888
+ #
889
+ # mode_has answers for a strictness-RAISING token: present in EITHER file. A
890
+ # slug may opt into autonomous/gated where the root left it unset; it can no
891
+ # longer opt out of what the root committed.
892
+ #
893
+ # mode_relax_allowed answers for the one strictness-LOWERING token
894
+ # (plan-guard-off): the slug must carry it AND, when the project committed a
895
+ # root .mode, that file must carry it too. A slug alone cannot switch off a
896
+ # protection the project kept on.
897
+ #
898
+ # With no root .mode present ROOT_MODE_FILE is either empty (root scope) or
899
+ # names a missing file, so the effective token set is exactly the slug's and
900
+ # existing projects are byte-identical.
901
+ mode_has() {
902
+ _mh_token="$1"
903
+ if [ -f "$MODE_FILE" ] && grep -q "$_mh_token" "$MODE_FILE" 2>/dev/null; then
904
+ return 0
905
+ fi
906
+ if [ -n "$ROOT_MODE_FILE" ] && [ -f "$ROOT_MODE_FILE" ] \
907
+ && grep -q "$_mh_token" "$ROOT_MODE_FILE" 2>/dev/null; then
908
+ return 0
909
+ fi
910
+ return 1
911
+ }
912
+
913
+ mode_relax_allowed() {
914
+ _mr_token="$1"
915
+ [ -f "$MODE_FILE" ] || return 1
916
+ grep -q "$_mr_token" "$MODE_FILE" 2>/dev/null || return 1
917
+ if [ -n "$ROOT_MODE_FILE" ] && [ -f "$ROOT_MODE_FILE" ]; then
918
+ grep -q "$_mr_token" "$ROOT_MODE_FILE" 2>/dev/null || return 1
919
+ fi
920
+ return 0
921
+ }
922
+
856
923
  MODE=""
857
- if [ -f "$MODE_FILE" ]; then
858
- grep -q 'autonomous' "$MODE_FILE" 2>/dev/null && MODE='autonomous'
859
- grep -q 'gate' "$MODE_FILE" 2>/dev/null && MODE='gated'
860
- fi
924
+ mode_has 'autonomous' && MODE='autonomous'
925
+ mode_has 'gate' && MODE='gated'
861
926
 
862
927
  # In autonomous/gated mode the per-tool-call injection is dropped (recitation
863
928
  # policy): strong models do not need the plan re-recited before every tool call,
@@ -881,7 +946,7 @@ fi
881
946
  SMART=0
882
947
  if [ "${PWF_INJECT:-}" = "smart" ]; then
883
948
  SMART=1
884
- elif [ -f "$MODE_FILE" ] && grep -q 'inject-smart' "$MODE_FILE" 2>/dev/null; then
949
+ elif mode_has 'inject-smart'; then
885
950
  SMART=1
886
951
  fi
887
952
 
@@ -1149,7 +1214,7 @@ esac
1149
1214
  # holding the stale copy. Per-session keying needs PWF_SESSION_ID, which most
1150
1215
  # hosts never set.
1151
1216
  GUARD=1
1152
- [ -f "$MODE_FILE" ] && grep -q 'plan-guard-off' "$MODE_FILE" 2>/dev/null && GUARD=0
1217
+ mode_relax_allowed 'plan-guard-off' && GUARD=0
1153
1218
  [ "${PWF_PLAN_GUARD:-}" = "0" ] && GUARD=0
1154
1219
  if [ "$GUARD" = "1" ]; then
1155
1220
  # Same user-private cache root and same absolute-path key as the attestation
@@ -55,9 +55,14 @@ $validEvents = @("progress", "phase_complete", "error", "gate_block", "attest",
55
55
  function Resolve-PlanDir {
56
56
  $planRoot = Join-Path (Get-Location) ".planning"
57
57
 
58
+ # A set PLAN_ID is a BINDING, not a hint (issue #237). This script WRITES
59
+ # ledger rows into the directory it picks, so falling through to
60
+ # .active_plan, newest-by-mtime and finally the cwd after a mistyped pin
61
+ # files another plan's run history.
58
62
  if ($env:PLAN_ID) {
59
63
  $candidate = Join-Path $planRoot $env:PLAN_ID
60
64
  if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
65
+ return $null
61
66
  }
62
67
 
63
68
  $activePointer = Join-Path $planRoot ".active_plan"
@@ -136,6 +141,10 @@ if (-not $agentClean) { $agentClean = "main" }
136
141
  if ($Summary.Length -gt 200) { $Summary = $Summary.Substring(0, 200) }
137
142
 
138
143
  $planDir = Resolve-PlanDir
144
+ if (-not $planDir) {
145
+ Write-Error "[ledger-append] An explicit PLAN_ID did not resolve to a plan directory; nothing was written and no other plan was substituted."
146
+ exit 1
147
+ }
139
148
  $ledgerFile = Join-Path $planDir ("ledger-" + $agentClean + ".jsonl")
140
149
  $lockFile = Join-Path $planDir ".ledger_lock"
141
150
 
@@ -53,6 +53,12 @@ resolve_plan_dir() {
53
53
  printf "%s\n" "${plan_dir}"
54
54
  return 0
55
55
  fi
56
+ # Explicit selectors are bindings, not hints (issue #237). This script
57
+ # WRITES ledger rows into the plan dir it picks, so a legacy cwd fallback
58
+ # after a rejected selector files another plan's run history.
59
+ if [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
60
+ return 1
61
+ fi
56
62
  # Legacy single-file mode: ledger lives beside ./task_plan.md at root.
57
63
  printf "%s\n" "."
58
64
  return 0
@@ -274,7 +280,10 @@ AGENT="$(sanitize_agent "${AGENT}")"
274
280
  SUMMARY="$(printf '%s' "${SUMMARY}" | cut -c1-200)"
275
281
  SUMMARY="$(utf8_trim_incomplete "${SUMMARY}")"
276
282
 
277
- PLAN_DIR="$(resolve_plan_dir)"
283
+ PLAN_DIR="$(resolve_plan_dir)" || {
284
+ printf "[ledger-append] An explicit PLAN_ID or PWF_PLAN_ROOT did not resolve to a plan directory; nothing was written and no other plan was substituted.\n" >&2
285
+ exit 1
286
+ }
278
287
  LEDGER_FILE="${PLAN_DIR}/ledger-${AGENT}.jsonl"
279
288
  LOCK_FILE="${PLAN_DIR}/.ledger_lock"
280
289
 
@@ -31,9 +31,15 @@ $ErrorActionPreference = "Stop"
31
31
  function Resolve-PlanDir {
32
32
  $planRoot = Join-Path (Get-Location) ".planning"
33
33
 
34
+ # A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that
35
+ # names no plan directory stops resolution instead of falling through to
36
+ # .active_plan, newest-by-mtime and finally the cwd: summarizing another
37
+ # plan's ledger under a mistyped pin is the same wrong-plan harm that let
38
+ # a typo attest the wrong file.
34
39
  if ($env:PLAN_ID) {
35
40
  $candidate = Join-Path $planRoot $env:PLAN_ID
36
41
  if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
42
+ return $null
37
43
  }
38
44
 
39
45
  $activePointer = Join-Path $planRoot ".active_plan"
@@ -58,6 +64,15 @@ function Resolve-PlanDir {
58
64
  }
59
65
 
60
66
  $planDir = Resolve-PlanDir
67
+ if (-not $planDir) {
68
+ # Loud degradation, same contract as ledger-summary.sh's emit_unavailable:
69
+ # a rejected PLAN_ID binding must not report the ROOT plan's phase counts,
70
+ # because an autonomous loop reads those counts as its termination signal.
71
+ Write-Output "=== RUN LEDGER ==="
72
+ Write-Output "ledger: unavailable (explicit PLAN_ID did not resolve)"
73
+ Write-Output "=================="
74
+ exit 0
75
+ }
61
76
  $planFile = Join-Path $planDir "task_plan.md"
62
77
 
63
78
  # --- Phase counts: same patterns as check-complete.ps1 ---
@@ -68,6 +68,15 @@ if [ -n "${ARG_DIR}" ]; then
68
68
  elif [ -f "${RESOLVER}" ]; then
69
69
  PLAN_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
70
70
  if [ -z "${PLAN_DIR}" ] || [ ! -d "${PLAN_DIR}" ]; then
71
+ # Explicit selectors are bindings, not hints (issue #237). A rejected
72
+ # PLAN_ID or PWF_PLAN_ROOT must not fall back to the cwd: this summary
73
+ # is injected into autonomous turns, so reporting the ROOT plan's
74
+ # phase counts under a mistyped pin feeds the loop another plan's
75
+ # termination signal. Degrade loudly, the same way a missing resolver
76
+ # already does.
77
+ if [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
78
+ emit_unavailable "explicit PLAN_ID or PWF_PLAN_ROOT did not resolve"
79
+ fi
71
80
  PLAN_DIR="."
72
81
  fi
73
82
  else
@@ -42,10 +42,16 @@ $ErrorActionPreference = "Stop"
42
42
  function Resolve-PlanFile {
43
43
  $planRoot = Join-Path (Get-Location) ".planning"
44
44
 
45
+ # A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that
46
+ # names no plan directory stops resolution instead of falling through to
47
+ # .active_plan and newest-by-mtime: this script reports phase state, and
48
+ # answering a mistyped pin with a DIFFERENT plan's phases is the same
49
+ # wrong-plan harm that let a typo attest the wrong file.
45
50
  if ($env:PLAN_ID) {
46
51
  $candidate = Join-Path $planRoot $env:PLAN_ID
47
52
  $planFile = Join-Path $candidate "task_plan.md"
48
53
  if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
54
+ return $null
49
55
  }
50
56
 
51
57
  $activePointer = Join-Path $planRoot ".active_plan"
@@ -156,7 +162,11 @@ if ($validStatus -notcontains $Status) {
156
162
 
157
163
  $planFile = Resolve-PlanFile
158
164
  if (-not $planFile) {
159
- Write-Error "[phase-status] No task_plan.md found. Create a plan first."
165
+ if ($env:PLAN_ID) {
166
+ Write-Error "[phase-status] PLAN_ID names no plan directory under .planning; nothing was written and no other plan was substituted."
167
+ } else {
168
+ Write-Error "[phase-status] No task_plan.md found. Create a plan first."
169
+ }
160
170
  exit 1
161
171
  }
162
172
 
@@ -40,6 +40,12 @@ resolve_plan_file() {
40
40
  printf "%s\n" "${plan_dir}/task_plan.md"
41
41
  return 0
42
42
  fi
43
+ # Explicit selectors are bindings, not hints (issue #237). This script
44
+ # WRITES a phase status into the plan it picks, so a cwd fallback after a
45
+ # rejected selector edits a different plan than the operator named.
46
+ if [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
47
+ return 1
48
+ fi
43
49
  if [ -f "./task_plan.md" ]; then
44
50
  printf "%s\n" "./task_plan.md"
45
51
  return 0
@@ -73,7 +79,11 @@ case "${NEW_STATUS}" in
73
79
  esac
74
80
 
75
81
  PLAN_FILE="$(resolve_plan_file)" || {
76
- printf "[phase-status] No task_plan.md found. Create a plan first.\n" >&2
82
+ if [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
83
+ printf "[phase-status] An explicit PLAN_ID or PWF_PLAN_ROOT did not resolve to a plan; nothing was written and no other plan was substituted.\n" >&2
84
+ else
85
+ printf "[phase-status] No task_plan.md found. Create a plan first.\n" >&2
86
+ fi
77
87
  exit 1
78
88
  }
79
89
 
@@ -74,27 +74,49 @@ if [ -f "${INJ}" ]; then
74
74
  ok "injection: silent because no plan exists here (correct behavior)"
75
75
  fi
76
76
  else
77
+ # Classify on the DATA FRAMING first, never on substrings of the whole
78
+ # blob (issue #236). ${OUT} carries the plan body VERBATIM inside
79
+ # ===BEGIN-PWF-DATA=== fences, so a bare substring test also matches
80
+ # plan prose: a phase line reading "fix the false PLAN TAMPERED
81
+ # warning" made the doctor report a hash mismatch on a correctly
82
+ # attested plan.
83
+ #
84
+ # Every refusal path in inject-plan.sh prints its banner and exits
85
+ # before frame_file runs, so a frame in the output proves injection
86
+ # happened and rules out every refusal. Output WITHOUT a frame is by
87
+ # construction a notice, which is why the banner arms sit under the
88
+ # else side and the default arm warns instead of passing. A banner
89
+ # whose wording drifts then degrades to a generic warning rather than
90
+ # to a silent PASS: that is exactly how the stale
91
+ # "PWF_PLAN_ROOT is not a directory" literal (which was never a
92
+ # substring of what inject-plan.sh emits) reported PASS on a fully
93
+ # dark-hooks state.
77
94
  case "${OUT}" in
78
- *'PLAN TAMPERED'*)
95
+ *'===BEGIN-PWF-DATA'*)
96
+ BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
97
+ ok "injection: emits plan context (${BYTES} bytes)"
98
+ ;;
99
+ *'[PLAN TAMPERED'*)
79
100
  warn "injection: plan is attested but the hash mismatches — run /plan-attest (or scripts/attest-plan.sh) to re-approve the current plan"
80
101
  ;;
81
102
  *'requires attested plan'*)
82
103
  warn "injection: v3 mode without attestation — run attest-plan once to arm injection"
83
104
  ;;
84
105
  *'Session isolation is armed'*)
85
- # Refusal notice, not plan context: reporting its byte count as
86
- # PASS told a dark user their hooks were fine.
87
106
  warn "injection: session isolation refuses this session — attach it with PWF_SESSION_ID=<id> plus .planning/sessions/<id>.attached, or delete the .planning/sessions/ dir (stale ones survive earlier Codex use and copied project trees) to turn isolation off"
88
107
  ;;
89
108
  *'Ambiguous plan'*)
90
109
  warn "injection: nested-plan ambiguity — a project directly below this cwd carries its own plan, so hooks refuse to guess. Pin the thread with PWF_PLAN_ROOT=<absolute project root> or PLAN_ID=<slug>"
91
110
  ;;
92
- *'PWF_PLAN_ROOT is not a directory'*)
93
- warn "injection: PWF_PLAN_ROOT points at a missing directory — fix or unset the pin; a broken pin fails closed and injects nothing"
111
+ *'PWF_PLAN_ROOT is not a supported absolute local directory'*)
112
+ warn "injection: PWF_PLAN_ROOT points at something that is not an absolute local directory — fix or unset the pin; a broken pin fails closed and injects nothing"
113
+ ;;
114
+ *'PLAN_ID does not name a plan directory'*)
115
+ warn "injection: PLAN_ID names no plan directory under .planning — fix or unset the pin; a set PLAN_ID is a binding and fails closed rather than selecting another plan"
94
116
  ;;
95
117
  *)
96
118
  BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
97
- ok "injection: emits plan context (${BYTES} bytes)"
119
+ warn "injection: inject-plan.sh emitted ${BYTES} bytes but no ===BEGIN-PWF-DATA frame, so no plan context reached the model. This is a refusal notice this doctor does not recognize; read it directly with: sh scripts/inject-plan.sh --context=userprompt"
98
120
  ;;
99
121
  esac
100
122
  fi
@@ -130,6 +130,13 @@ function Test-WithinRoot {
130
130
 
131
131
  $activeFile = Join-Path $PlanRoot ".active_plan"
132
132
 
133
+ # A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
134
+ # no directory, fails slug validation, or fails containment terminates
135
+ # resolution instead of falling through to .active_plan and newest-by-mtime:
136
+ # the fall-through let a one-character typo attest and inject a DIFFERENT plan
137
+ # at rc=0. Emptiness is the fail-closed signal on this channel, matching
138
+ # resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
139
+ # falsy here and still means "unset".
133
140
  if ($env:PLAN_ID) {
134
141
  if (Test-ValidSlug $env:PLAN_ID) {
135
142
  $candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -138,6 +145,7 @@ if ($env:PLAN_ID) {
138
145
  exit 0
139
146
  }
140
147
  }
148
+ exit 0
141
149
  }
142
150
 
143
151
  # Get-Item observes the link object even when its target is missing, unlike
@@ -297,7 +297,34 @@ resolve_latest_dir() {
297
297
  return 1
298
298
  }
299
299
 
300
- if resolve_from_env; then exit 0; fi
300
+ # A set PLAN_ID is a BINDING, not a hint (issue #237).
301
+ #
302
+ # resolve_from_env returns 1 both when no selector was set and when the
303
+ # selector was rejected, so continuing the chain after it turned a
304
+ # one-character typo into a silent switch: .active_plan or newest-by-mtime
305
+ # answered instead, attest-plan.sh locked THAT plan at rc=0, and injection
306
+ # followed the attestation onto it. commands/plan-attest.md already promised
307
+ # the opposite ("It never falls back to another plan").
308
+ #
309
+ # Any non-empty PLAN_ID therefore terminates resolution here, whether it was
310
+ # rejected for slug shape (traversal), for naming no directory, or for failing
311
+ # containment. The caller receives an empty result and takes its own
312
+ # fail-closed path rather than a different plan. PWF_PLAN_ROOT, the sibling
313
+ # selector, has failed closed on any bad value since #212; the two selectors
314
+ # now agree.
315
+ #
316
+ # An EMPTY PLAN_ID still means "unset": init-session.sh passes
317
+ # PLAN_ID="${PLAN_ID:-}" into attest-plan.sh on the legacy path and depends on
318
+ # that spelling resolving the root plan.
319
+ #
320
+ # Exit status stays 0 on the refusal (see the header contract). Emptiness is
321
+ # the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
322
+ # above already does it; a non-zero status would kill callers running under
323
+ # set -e for a condition that is not an internal error.
324
+ if [ -n "${PLAN_ID:-}" ]; then
325
+ resolve_from_env && exit 0
326
+ exit 0
327
+ fi
301
328
  if resolve_from_active_file; then exit 0; fi
302
329
  if resolve_latest_dir; then exit 0; fi
303
330
  exit 0