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 +1 -1
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +20 -2
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/plan.ts +28 -9
- package/package.json +56 -56
- package/scripts/attest-plan.sh +12 -1
- package/scripts/check-complete.ps1 +17 -3
- package/scripts/check-complete.sh +30 -2
- package/scripts/init-session.sh +28 -0
- package/scripts/inject-plan.sh +73 -8
- package/scripts/ledger-append.ps1 +9 -0
- package/scripts/ledger-append.sh +10 -1
- package/scripts/ledger-summary.ps1 +15 -0
- package/scripts/ledger-summary.sh +9 -0
- package/scripts/phase-status.ps1 +11 -1
- package/scripts/phase-status.sh +11 -1
- package/scripts/plan-doctor.sh +28 -6
- package/scripts/resolve-plan-dir.ps1 +8 -0
- package/scripts/resolve-plan-dir.sh +28 -1
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.
|
|
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("
|
|
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.
|
|
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
|
-
"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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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
|
+
}
|
package/scripts/attest-plan.sh
CHANGED
|
@@ -108,7 +108,18 @@ case "${1:-}" in
|
|
|
108
108
|
esac
|
|
109
109
|
|
|
110
110
|
plan_file="$(resolve_plan_file)" || {
|
|
111
|
-
|
|
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.
|
|
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
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
package/scripts/init-session.sh
CHANGED
|
@@ -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"
|
package/scripts/inject-plan.sh
CHANGED
|
@@ -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:-}" ]
|
|
258
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/scripts/ledger-append.sh
CHANGED
|
@@ -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
|
package/scripts/phase-status.ps1
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/scripts/phase-status.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/scripts/plan-doctor.sh
CHANGED
|
@@ -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
|
-
*'
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|