Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c1caa27f | ||
|
|
683d591a79 | ||
|
|
fca9b04f95 | ||
|
|
049943aa08 | ||
|
|
4d24d9a8a2 | ||
|
|
21f25c090f | ||
|
|
b0ee2cdd24 | ||
|
|
4c2b269d30 | ||
|
|
94131052d6 | ||
|
|
a22e4865c6 | ||
|
|
b3ed5256a5 | ||
|
|
605f51e09e | ||
|
|
4c4d0fc74c | ||
|
|
77d56b3d00 | ||
|
|
67bfa9971f | ||
|
|
37d6442d7e | ||
|
|
3b7690eafd | ||
|
|
f473fa8930 | ||
|
|
3827d3c018 | ||
|
|
b7edae42dc | ||
|
|
10c64d464e | ||
|
|
a82c5a4588 | ||
|
|
16b6a452bc | ||
|
|
abd39f7e9b | ||
|
|
388b591f77 | ||
|
|
87b2553fa1 | ||
|
|
a3bd42954b | ||
|
|
5165ffe847 | ||
|
|
f7b9be9d58 | ||
|
|
0827eeaecf | ||
|
|
09d2254d76 | ||
|
|
78e5bf7b5b | ||
|
|
27ab93c15d | ||
|
|
4a40450390 | ||
|
|
0bd06d344b | ||
|
|
5ac39bcb41 | ||
|
|
b81c488d46 | ||
|
|
f869dfb4d0 | ||
|
|
428b96c982 | ||
|
|
cd0bc3d202 | ||
|
|
5ad5452276 | ||
|
|
726da18dc7 | ||
|
|
3a4f2077be | ||
|
|
864577857d | ||
|
|
a33fc3eb1a | ||
|
|
85dfd5dcdd | ||
|
|
57fe376da9 | ||
|
|
663908180e | ||
|
|
f54f8144c2 | ||
|
|
95cf6f499b | ||
|
|
0308a2f819 | ||
|
|
0bbc43cff3 | ||
|
|
f01a88a12b | ||
|
|
9a3843b31d | ||
|
|
1528b31d7f | ||
|
|
faf1a15a7d | ||
|
|
c05821a1cf | ||
|
|
52572e3301 | ||
|
|
1df6f5808b | ||
|
|
d03002bcee | ||
|
|
1bea197584 | ||
|
|
e33359977c | ||
|
|
2fbbd77ba9 | ||
|
|
3ac11209f6 | ||
|
|
42a6864bb4 | ||
|
|
c839b458db | ||
|
|
f38020583a | ||
|
|
7d330bba84 | ||
|
|
a60a135a06 | ||
|
|
34d1c88229 | ||
|
|
50b2784006 | ||
|
|
8e16f21098 | ||
|
|
c19c715df6 | ||
|
|
b17e0107d6 | ||
|
|
951893b43c | ||
|
|
3160256d2e | ||
|
|
6ddbe0a522 | ||
|
|
86133195c6 | ||
|
|
bb2ee81404 | ||
|
|
6031c38709 | ||
|
|
7a0a020f16 | ||
|
|
bb48bb0a1e | ||
|
|
ea8b17860d | ||
|
|
808694b726 | ||
|
|
c82f647718 | ||
|
|
e9da40fc87 | ||
|
|
893cb38299 | ||
|
|
02eaf0b64d | ||
|
|
aae2d1ebc5 | ||
|
|
f1ebb38d42 | ||
|
|
746f57a7c5 | ||
|
|
0d41eeeddd | ||
|
|
f12e714e3f | ||
|
|
7f8b6da3b2 | ||
|
|
7297d964eb | ||
|
|
8379610e83 | ||
|
|
7c11cb7570 | ||
|
|
e7d8be14f4 | ||
|
|
6834e65f0b | ||
|
|
8699bc573c | ||
|
|
10d4a585a9 | ||
|
|
ae7e3ae817 | ||
|
|
6317beb930 | ||
|
|
1ec8f4eb5c | ||
|
|
b19fa8e8d9 | ||
|
|
716318a827 | ||
|
|
f67e2bb729 | ||
|
|
a718f05689 | ||
|
|
3cbb7dcc4b | ||
|
|
e8ad1534ed | ||
|
|
5046267994 | ||
|
|
ecaf63f689 |
@@ -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.17.0"
|
||||
version: "3.21.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
@@ -40,7 +40,7 @@ Work like Manus: Use persistent markdown files as your "working memory on disk."
|
||||
**Before continuing**, resolve the plan this task owns:
|
||||
|
||||
1. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the task's `PLAN_ID` and `PWF_PLAN_ROOT`. Read `task_plan.md`, `progress.md`, and `findings.md` from that one selected directory. A root `task_plan.md` must not override a selected `.planning/<id>/` plan.
|
||||
2. If an explicit selector is rejected, or session isolation is armed with multiple plans and no `PLAN_ID`, stop plan recovery and correct the pin. Do not fall back to another task. Use the legacy project-root files only when no selector or named plan applies.
|
||||
2. If an explicit selector is rejected, or multiple named plans exist without `PLAN_ID`, stop plan recovery and correct the pin. Do not fall back to another task. Use the legacy project-root files only when no selector or named plan applies.
|
||||
3. Run `git diff --stat` to see code changes that may not yet be recorded in the planning files.
|
||||
|
||||
All planning filenames below refer to this selected directory, even when the shell runs elsewhere. For parallel tasks, pin each host before starting it or use separate worktrees. A worker joining an existing task uses its assigned plan; it must not create or overwrite a competing root plan.
|
||||
@@ -221,12 +221,18 @@ Helper scripts for automation:
|
||||
|
||||
- `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).
|
||||
- `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.
|
||||
- `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.
|
||||
- `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`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
|
||||
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
|
||||
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history.
|
||||
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
|
||||
- `scripts/plan-doctor.sh` — One-pass self-check for the mechanisms that fail silently (v3.6.0): plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. See `/plan-doctor` command.
|
||||
|
||||
### List saved plans
|
||||
|
||||
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
|
||||
|
||||
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
|
||||
|
||||
### Parallel task workflow
|
||||
|
||||
For independent tasks in the same repository, create a named plan for each and pin each agent host to its own plan:
|
||||
@@ -359,7 +365,7 @@ The mode is set by writing a `.mode` file next to the plan (`.planning/<id>/.mod
|
||||
|
||||
### The legacy invariant (promise)
|
||||
|
||||
With no `.mode` file and no other v3 marker, the hooks produce byte-identical output to v2.43, including the raw `progress.md` tail and the `===BEGIN PLAN DATA===` / `===END PLAN DATA===` delimiters. Every v3 behavior is additive and opt-in. No existing workflow changes.
|
||||
With no `.mode` file and no other v3 marker, plan injection preserves the v2.43 output, including the raw `progress.md` tail and the `===BEGIN PLAN DATA===` / `===END PLAN DATA===` delimiters. Autonomous and gated behavior remains opt-in. Since v3.18.3, completed plans are silent through the shared Stop gate and Codex Stop hook. Explicit `check-complete.sh` or `check-complete.ps1` calls without the gate flag still report completion; incomplete-plan notices and gate decisions are unchanged.
|
||||
|
||||
### What each mode does
|
||||
|
||||
|
||||
@@ -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.");
|
||||
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
|
||||
$securityRootPath = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
# Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
|
||||
# local path is the only accepted shape, as in resolve-plan-dir.ps1.
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
|
||||
if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
|
||||
throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
|
||||
}
|
||||
$securityRootPath = $pin
|
||||
@@ -404,6 +412,7 @@ function Resolve-PlanFile {
|
||||
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
||||
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
||||
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
||||
if ($resolvedDir) {
|
||||
$planFile = Join-Path $resolvedDir "task_plan.md"
|
||||
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
||||
|
||||
@@ -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,9 +45,46 @@ 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)"
|
||||
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
||||
printf "%s\n" "${plan_dir}/task_plan.md"
|
||||
@@ -76,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
|
||||
@@ -97,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)" || {
|
||||
@@ -113,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
|
||||
@@ -152,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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall -> allow stop)
|
||||
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
||||
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
||||
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
||||
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
||||
#
|
||||
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
||||
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
||||
@@ -35,6 +36,7 @@ if ($PlanFile -ne "") {
|
||||
try {
|
||||
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
||||
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
||||
} catch {
|
||||
$resolvedDir = ""
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -84,9 +88,10 @@ if ($TOTAL -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# advisory_report: the v2.43 status echo.
|
||||
# Keep explicit reports, but omit routine success from automatic gate checks.
|
||||
function Write-AdvisoryReport {
|
||||
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
||||
if ($Gate) { return }
|
||||
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
||||
} else {
|
||||
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
||||
|
||||
Regular → Executable
+12
-3
@@ -21,8 +21,9 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall → allow stop)
|
||||
# When all hold, it emits a single-line block-decision JSON on stdout and
|
||||
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
||||
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
||||
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
||||
# plans stay silent in --gate mode, including legacy plans without .mode.
|
||||
# Without --gate, the explicit advisory report is unchanged.
|
||||
#
|
||||
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
||||
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
||||
@@ -56,6 +57,9 @@ else
|
||||
RESOLVED_DIR=""
|
||||
if [ -f "${RESOLVER}" ]; then
|
||||
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
||||
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
||||
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
||||
@@ -75,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
|
||||
@@ -116,9 +123,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
||||
# Explicit status reports retain completion text. Automatic gate checks have
|
||||
# nothing to report on success; keep evaluating all gate guards before here.
|
||||
advisory_report() {
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
[ "$GATE" -eq 1 ] && return 0
|
||||
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
||||
else
|
||||
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
||||
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
||||
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
||||
# Templates: default, analytics
|
||||
@@ -12,10 +13,32 @@
|
||||
param(
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default",
|
||||
[switch]$PlanDir,
|
||||
[switch]$Autonomous,
|
||||
[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.
|
||||
@@ -38,7 +61,65 @@ function Get-Nonce {
|
||||
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
function Get-PlanSlug([string]$Name) {
|
||||
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
||||
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
||||
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
||||
$slug = $slug -creplace '-{2,}', '-'
|
||||
$slug = $slug.Trim('-')
|
||||
if ($slug.Length -gt 40) {
|
||||
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
||||
}
|
||||
# The resolvers and the selector only accept ASCII plan ids.
|
||||
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
||||
return ""
|
||||
}
|
||||
return $slug
|
||||
}
|
||||
|
||||
function Get-ShortId {
|
||||
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
||||
}
|
||||
|
||||
function Get-InheritedMode([string]$CurrentMode) {
|
||||
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
||||
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
||||
return $CurrentMode
|
||||
}
|
||||
if ($CurrentMode -eq "gated") {
|
||||
return $CurrentMode
|
||||
}
|
||||
|
||||
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
||||
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
||||
if ($RootMode -cmatch 'gate') {
|
||||
return "gated"
|
||||
}
|
||||
if ($RootMode -cmatch 'autonomous') {
|
||||
return "autonomous"
|
||||
}
|
||||
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") {
|
||||
@@ -46,11 +127,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
||||
# mode. A positional project name or -PlanDir creates an isolated
|
||||
# .planning/<date>-<slug>/ plan.
|
||||
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
||||
$UsePlanDir = $PlanDir -or $NamedPlan
|
||||
if ($UsePlanDir) {
|
||||
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
||||
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
||||
# shared pointer, so a named plan cannot be created without it.
|
||||
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
||||
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
||||
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. A
|
||||
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
||||
# it first and treat a thrown error as a failure too.
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $PlanSelector -VerifyRoot *> $null
|
||||
} catch {
|
||||
$global:LASTEXITCODE = 1
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NamedPlan) {
|
||||
$Slug = Get-PlanSlug $ProjectName
|
||||
} else {
|
||||
$Slug = ""
|
||||
$ProjectName = "untitled"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Slug)) {
|
||||
$Slug = "untitled-$(Get-ShortId)"
|
||||
}
|
||||
|
||||
$BaseId = "$DATE-$Slug"
|
||||
$PlanId = $BaseId
|
||||
$Counter = 2
|
||||
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
||||
$PlanId = "$BaseId-$Counter"
|
||||
$Counter++
|
||||
}
|
||||
$TargetDir = Join-Path $PlanningRoot $PlanId
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
||||
$Mode = Get-InheritedMode $Mode
|
||||
} else {
|
||||
$TargetDir = (Get-Location).Path
|
||||
}
|
||||
|
||||
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
||||
$FindingsPath = Join-Path $TargetDir "findings.md"
|
||||
$ProgressPath = Join-Path $TargetDir "progress.md"
|
||||
if ($UsePlanDir) {
|
||||
$TaskPlanDisplay = $TaskPlanPath
|
||||
$FindingsDisplay = $FindingsPath
|
||||
$ProgressDisplay = $ProgressPath
|
||||
} else {
|
||||
$TaskPlanDisplay = "task_plan.md"
|
||||
$FindingsDisplay = "findings.md"
|
||||
$ProgressDisplay = "progress.md"
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "PLAN_ID=$PlanId"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
@@ -99,18 +255,18 @@ Phase 1
|
||||
## Errors Encountered
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
Write-Host "Created $TaskPlanDisplay"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
Write-Host "$TaskPlanDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
@@ -131,15 +287,15 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
Write-Host "Created $FindingsDisplay"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
Write-Host "$FindingsDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -160,7 +316,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -181,23 +337,74 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
Write-Host "Created $ProgressDisplay"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
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!"
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
||||
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
||||
Write-Host " `$env:PLAN_ID='$PlanId'"
|
||||
} else {
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
}
|
||||
|
||||
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
||||
# the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
|
||||
# dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
|
||||
# .plan-attestation at the project root.
|
||||
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
||||
# selected plan; root mode therefore retains the legacy project-root behavior.
|
||||
if ($Mode -ne "") {
|
||||
$PlanDirPwf = (Get-Location).Path
|
||||
$PlanDirPwf = $TargetDir
|
||||
|
||||
# (a) reset gate block counter, drop stale gate ledger.
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
||||
@@ -216,15 +423,96 @@ if ($Mode -ne "") {
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
||||
|
||||
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
||||
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
||||
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
||||
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
||||
# 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 $AttestPs1) -and (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 {
|
||||
& $AttestPs1 *> $null
|
||||
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) {
|
||||
$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 {
|
||||
$AttestationCommand = "attest-plan.sh"
|
||||
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
||||
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# CDPATH must not redirect the cd that locates the sibling scripts.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -184,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"
|
||||
@@ -201,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
|
||||
}
|
||||
|
||||
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
||||
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
||||
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. The
|
||||
# selector's check is constant time; --list would parse every plan.
|
||||
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
||||
exit 1
|
||||
fi
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
||||
# redirection would truncate a pre-existing hardlink and could overwrite a
|
||||
# different file that shares the same inode.
|
||||
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
||||
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
inherit_root_mode
|
||||
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
||||
echo ""
|
||||
@@ -401,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}"
|
||||
@@ -412,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
|
||||
|
||||
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
|
||||
one turn-marker slot and one progress-guard slot per plan.
|
||||
|
||||
Known, accepted differences from the shell reference:
|
||||
* Two plan directories with the same whole-second mtime, no .active_plan
|
||||
and no PLAN_ID: the reference picks the first in the shell's glob order
|
||||
(locale collation), this twin the first in code-point order. The same
|
||||
collation difference can change which three of four or more nested
|
||||
projects the ambiguity notice names. A machine runs one implementation,
|
||||
so neither choice flips within a session.
|
||||
* Shell glob order follows locale collation; this twin uses code-point
|
||||
order. This can change which three of four or more nested projects the
|
||||
ambiguity notice names.
|
||||
* BSD sed (macOS) appends a newline to a progress.md whose last line has
|
||||
none; GNU sed and this twin do not.
|
||||
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
|
||||
@@ -837,8 +834,21 @@ class Injector(object):
|
||||
scope = ""
|
||||
explicit = bool(plan_prefix)
|
||||
plan_id = env.get("PLAN_ID", "")
|
||||
ambiguous = plan_is_ambiguous(
|
||||
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
|
||||
)
|
||||
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
|
||||
"for this session; nothing injected."
|
||||
)
|
||||
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
|
||||
@@ -857,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"):
|
||||
@@ -873,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"):
|
||||
@@ -923,30 +935,14 @@ class Injector(object):
|
||||
"single-session mode."
|
||||
)
|
||||
raise Bail()
|
||||
if not plan_id:
|
||||
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
|
||||
try:
|
||||
names = sorted(os.listdir(plan_prefix + ".planning"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
|
||||
continue
|
||||
if not slug_is_valid(name):
|
||||
continue
|
||||
plan_n += 1
|
||||
if plan_n > 1:
|
||||
break
|
||||
if plan_n > 1:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
if ambiguous:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
|
||||
if not explicit:
|
||||
nested = []
|
||||
@@ -1326,6 +1322,28 @@ def inject(context, env=None):
|
||||
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
|
||||
"""A project pointer or mtime cannot bind a session to multiple plans."""
|
||||
if plan_id:
|
||||
return False
|
||||
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
|
||||
try:
|
||||
names = os.listdir(plan_root)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def resolve_plan_dir(env=None):
|
||||
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
|
||||
|
||||
@@ -1356,10 +1374,13 @@ 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 ("", "")
|
||||
|
||||
if plan_is_ambiguous(fs_root, pin if pin else "."):
|
||||
return ("", "")
|
||||
|
||||
active_file = fs_root + "/.active_plan"
|
||||
if is_file(active_file):
|
||||
try:
|
||||
@@ -1371,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):
|
||||
@@ -1387,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"):
|
||||
@@ -1441,6 +1464,8 @@ class ClaudeDispatcher(object):
|
||||
return (spelled, fs)
|
||||
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
|
||||
return ("", "")
|
||||
if plan_is_ambiguous(".planning", "."):
|
||||
return ("", "")
|
||||
if is_file("task_plan.md"):
|
||||
return (".", ".")
|
||||
return ("", "")
|
||||
|
||||
@@ -254,6 +254,30 @@ is_within_root() {
|
||||
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
|
||||
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
|
||||
# conflict check below.
|
||||
# Shared .active_plan and directory mtime cannot identify this session's plan.
|
||||
# Check before selection or preflight, even when isolation was never armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
EXPLICIT=0
|
||||
@@ -272,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
|
||||
@@ -282,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
|
||||
@@ -291,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)
|
||||
@@ -442,27 +469,13 @@ PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An attachment admits a session but does not select one of several plans.
|
||||
# When isolation is armed, require PLAN_ID if more than one live same-root
|
||||
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
|
||||
# within that root.
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
SESSION_PLAN_N=0
|
||||
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
|
||||
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
[ -f "$candidate" ] || continue
|
||||
candidate_dir="${candidate%/task_plan.md}"
|
||||
candidate_slug="${candidate_dir##*/}"
|
||||
slug_is_valid "$candidate_slug" || continue
|
||||
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
|
||||
[ "$SESSION_PLAN_N" -gt 1 ] && break
|
||||
done
|
||||
if [ "$SESSION_PLAN_N" -gt 1 ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
# Attachment admits a session but does not select its plan. Preserve the
|
||||
# attachment-first notice above for sessions that never opted in.
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ function Resolve-PlanDir {
|
||||
|
||||
$activePointer = Join-Path $planRoot ".active_plan"
|
||||
if (Test-Path -LiteralPath $activePointer) {
|
||||
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
|
||||
$planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId) {
|
||||
$candidate = Join-Path $planRoot $planId
|
||||
if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
|
||||
|
||||
@@ -44,7 +44,7 @@ function Resolve-PlanDir {
|
||||
|
||||
$activePointer = Join-Path $planRoot ".active_plan"
|
||||
if (Test-Path -LiteralPath $activePointer) {
|
||||
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
|
||||
$planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId) {
|
||||
$candidate = Join-Path $planRoot $planId
|
||||
if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
|
||||
|
||||
@@ -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).
|
||||
@@ -56,7 +57,7 @@ function Resolve-PlanFile {
|
||||
|
||||
$activePointer = Join-Path $planRoot ".active_plan"
|
||||
if (Test-Path -LiteralPath $activePointer) {
|
||||
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
|
||||
$planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId) {
|
||||
$candidate = Join-Path $planRoot $planId
|
||||
$planFile = Join-Path $candidate "task_plan.md"
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
# junction/symlink escape; slug validation alone blocks textual traversal.
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
|
||||
[switch]$CheckAmbiguity
|
||||
)
|
||||
|
||||
$projectRoot = (Get-Location).Path
|
||||
@@ -76,6 +77,17 @@ function Get-FinalDirectoryPath {
|
||||
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
}
|
||||
|
||||
function Test-FullyQualifiedLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ($script:IsWindowsHost) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [System.IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
||||
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
||||
# project resolves the parent's plan and never sees the nested one;
|
||||
@@ -89,9 +101,8 @@ function Get-FinalDirectoryPath {
|
||||
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
||||
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
if ((Test-FullyQualifiedLocalPath $pin) -and
|
||||
(Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
$projectRoot = $pin
|
||||
$PlanRoot = Join-Path $pin ".planning"
|
||||
} else {
|
||||
@@ -128,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
|
||||
@@ -137,10 +166,37 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
|
||||
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
|
||||
# falsy here and still means "unset".
|
||||
# The optional probe distinguishes ambiguity from a legacy-root fallback.
|
||||
# Multiple named plans require PLAN_ID even without a sessions directory.
|
||||
$planCount = 0
|
||||
if (-not $env:PLAN_ID) {
|
||||
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
|
||||
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
|
||||
$planCount = 1
|
||||
}
|
||||
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++
|
||||
if ($planCount -gt 1) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($CheckAmbiguity) {
|
||||
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
|
||||
exit 0
|
||||
}
|
||||
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 $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
|
||||
}
|
||||
@@ -150,29 +206,37 @@ 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
|
||||
}
|
||||
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
||||
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
||||
# falls through like an invalid one instead of raising inside a caller.
|
||||
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId -and (Test-ValidSlug $planId)) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if ((Test-Path $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
||||
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
||||
# valid selection into an empty result.
|
||||
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 (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-WithinRoot $_.FullName } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Optional probe distinguishes ambiguity from the empty legacy-root result.
|
||||
# Both modes keep stdout data-only and always exit zero.
|
||||
CHECK_AMBIGUITY=0
|
||||
if [ "${1:-}" = "--check-ambiguity" ]; then
|
||||
CHECK_AMBIGUITY=1
|
||||
shift
|
||||
fi
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
@@ -239,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
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
@@ -321,6 +336,30 @@ resolve_latest_dir() {
|
||||
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
|
||||
# above already does it; a non-zero status would kill callers running under
|
||||
# set -e for a condition that is not an internal error.
|
||||
# A shared pointer or mtime is not a per-session binding (issue #240).
|
||||
# Count conservatively, just like injection: slug-valid live plan files.
|
||||
# The legacy root joins the count only when session isolation is armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$CHECK_AMBIGUITY" = "1" ]; then
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
|
||||
exit 0
|
||||
fi
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
|
||||
|
||||
if [ -n "${PLAN_ID:-}" ]; then
|
||||
resolve_from_env && exit 0
|
||||
exit 0
|
||||
|
||||
@@ -407,9 +407,6 @@ def get_session_candidates(
|
||||
return 'claude', []
|
||||
|
||||
|
||||
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
||||
|
||||
|
||||
def get_opencode_db_path() -> Optional[Path]:
|
||||
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
||||
xdg = os.environ.get('XDG_DATA_HOME')
|
||||
@@ -471,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', '')
|
||||
@@ -489,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
|
||||
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
||||
update_time = None
|
||||
update_idx = -1
|
||||
for idx, (sid, _) in enumerate(previous_sessions):
|
||||
params = (sid,) + PLANNING_LIKE_SQL
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_created FROM part
|
||||
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 (
|
||||
json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
IN ('task_plan.md', 'findings.md', 'progress.md')
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/task_plan.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/findings.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/progress.md'
|
||||
)
|
||||
ORDER BY time_created DESC
|
||||
LIMIT 1
|
||||
ORDER BY time_created DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
update_sid = sid
|
||||
update_time = row[0]
|
||||
update_idx = idx
|
||||
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
||||
# would materialize every planning write of the session before the
|
||||
# first validated row ends the loop.
|
||||
for candidate_time, data_str in cur:
|
||||
data = json_loads(data_str)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
state = data.get('state')
|
||||
input_ = state.get('input') if isinstance(state, dict) else None
|
||||
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
||||
if planning_file_from_path(file_path):
|
||||
update_sid = sid
|
||||
update_time = candidate_time
|
||||
update_idx = idx
|
||||
break
|
||||
if update_sid:
|
||||
break
|
||||
|
||||
if not update_sid:
|
||||
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
||||
"""Return a planning filename only when it is the path's exact basename.
|
||||
|
||||
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
||||
planning updates and can anchor catchup at unrelated transcript content.
|
||||
Normalize separators so the same boundary rule works for Unix and Windows
|
||||
session records.
|
||||
"""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
for pf in PLANNING_FILES:
|
||||
if path_value.endswith(pf):
|
||||
return pf
|
||||
return None
|
||||
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
||||
return basename if basename in PLANNING_FILES else None
|
||||
|
||||
|
||||
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
||||
|
||||
Regular → Executable
+386
-16
@@ -1,27 +1,324 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 - print the current active plan (if any)
|
||||
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
||||
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
||||
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
||||
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanId = "",
|
||||
[Alias('l', '-list')]
|
||||
[switch]$List,
|
||||
[Alias('verify-root')]
|
||||
[switch]$VerifyRoot,
|
||||
[Alias('h', '-help')]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
# Windows PowerShell 5.1 can silently change directories when -File inherits
|
||||
# a cwd containing wildcard characters. Recover that physical cwd only for a
|
||||
# direct invocation of this script; interactive Set-Location remains primary.
|
||||
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)) {
|
||||
$ProjectRoot = [Environment]::CurrentDirectory
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$PlanRoot = Join-Path $ProjectRoot ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
|
||||
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
|
||||
# open the actual filesystem object before checking the project boundary.
|
||||
# The Windows handle supports both directories and task_plan.md files.
|
||||
function Get-FinalPath {
|
||||
param([string]$Path)
|
||||
if (-not ("PwfPlanListingNative" -as [type])) {
|
||||
Add-Type -ErrorAction Stop -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public static class PwfPlanListingNative {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string name, uint access, uint share, IntPtr security,
|
||||
uint creation, uint flags, IntPtr template);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern IntPtr realpath(string path, IntPtr resolved);
|
||||
[DllImport("libc")]
|
||||
private static extern void free(IntPtr pointer);
|
||||
|
||||
public static string FinalPath(string path) {
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
|
||||
IntPtr resolved = realpath(path, IntPtr.Zero);
|
||||
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try { return Marshal.PtrToStringAnsi(resolved); }
|
||||
finally { free(resolved); }
|
||||
}
|
||||
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
|
||||
using (SafeFileHandle handle = CreateFileW(
|
||||
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder buffer = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
string result = buffer.ToString();
|
||||
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + result.Substring(8);
|
||||
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
||||
return result.Substring(4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
return [PwfPlanListingNative]::FinalPath($Path)
|
||||
}
|
||||
|
||||
function Test-WithinRoot {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
|
||||
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
|
||||
$comparison = [StringComparison]::Ordinal
|
||||
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
|
||||
return $pathReal.Equals($rootReal, $comparison) -or
|
||||
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ValidSlug {
|
||||
param([string]$Name)
|
||||
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
|
||||
}
|
||||
|
||||
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. 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 -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 {
|
||||
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
|
||||
if (Test-ValidSlug $current) { return $current }
|
||||
} catch { }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Add-PhaseStatus {
|
||||
param([hashtable]$Counts, $Phase)
|
||||
if ($null -eq $Phase) { return }
|
||||
$status = $Phase.Inline
|
||||
if ($Phase.Primary) { $status = $Phase.Primary }
|
||||
if ($status) { $Counts[$status]++ }
|
||||
}
|
||||
|
||||
function Get-PhaseStatus {
|
||||
param([string]$PlanFile)
|
||||
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
|
||||
$phase = $null
|
||||
$fence = ""
|
||||
$fenceLength = 0
|
||||
# The shipped translations keep status identifiers in English. Unicode
|
||||
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
|
||||
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
|
||||
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
|
||||
# Read once; an empty file is a valid plan with zero phases.
|
||||
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
|
||||
foreach ($line in ($content -split '\r?\n')) {
|
||||
if ($fence) {
|
||||
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
|
||||
if ($line -match $closing) { $fence = "" }
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
|
||||
$fence = $Matches[1].Substring(0, 1)
|
||||
$fenceLength = $Matches[1].Length
|
||||
continue
|
||||
}
|
||||
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = @{ Primary = ""; Inline = "" }
|
||||
$counts.total++
|
||||
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
|
||||
$phase.Inline = $Matches[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = $null
|
||||
continue
|
||||
}
|
||||
if ($null -ne $phase -and -not $phase.Primary -and
|
||||
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
|
||||
$phase.Primary = $Matches[1]
|
||||
}
|
||||
}
|
||||
Add-PhaseStatus $counts $phase
|
||||
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
|
||||
}
|
||||
|
||||
function Show-PlanList {
|
||||
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
|
||||
Write-Output "No planning directory found."
|
||||
return
|
||||
}
|
||||
if (-not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$active = Get-CurrentActivePlan
|
||||
Write-Output "Available plans:"
|
||||
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 }
|
||||
try { $status = Get-PhaseStatus $planFile } catch { continue }
|
||||
$marker = ""
|
||||
if ($plan.Name -ceq $active) { $marker = " [active]" }
|
||||
Write-Output "- $($plan.Name)$marker - $status"
|
||||
$found = $true
|
||||
}
|
||||
if (-not $found) { Write-Output "No named plans found." }
|
||||
}
|
||||
|
||||
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
||||
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
if ($VerifyRoot) {
|
||||
if ($List -or $PlanId) {
|
||||
Write-Error "Error: verify the planning root in a separate call."
|
||||
exit 1
|
||||
}
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
|
||||
if ($List -and $PlanId) {
|
||||
Write-Error "Error: list plans or set PLAN_ID in separate calls."
|
||||
exit 1
|
||||
}
|
||||
Show-PlanList
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$current = ""
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
|
||||
if ($current) {
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
@@ -29,21 +326,94 @@ if ($PlanId -eq "") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-ValidSlug $PlanId)) {
|
||||
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
|
||||
exit 1
|
||||
}
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
$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
|
||||
}
|
||||
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($activeItem -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
# Replace the directory entry instead of truncating an existing inode: a
|
||||
# hardlinked pointer must not overwrite another file. The exclusive temporary
|
||||
# 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
|
||||
try {
|
||||
$bytes = $utf8NoBom.GetBytes($PlanId)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
} finally { $stream.Dispose() }
|
||||
$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 {
|
||||
$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 ""
|
||||
|
||||
Regular → Executable
+358
-38
@@ -1,50 +1,370 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
# List saved named plans, show the shared pointer, or change that pointer.
|
||||
# Usage: set-active-plan.sh [--list|-l|--verify-root|PLAN_ID]
|
||||
# Operates on the current project; listing never binds a host or injects data.
|
||||
set -eu
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
PWF_ROOT_PIN=""
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
# Use the resolver canonicalization policy without running plan selection.
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Return true when a candidate path names the Microsoft Store WindowsApps
|
||||
# directory. Store app aliases are not stable interpreter binaries and may
|
||||
# present as executable while refusing script execution. Matching is
|
||||
# case-insensitive and works before or after Windows slash normalization.
|
||||
is_windowsapps_path() {
|
||||
norm_slashes "$1"
|
||||
case "${NORM_OUT}" in
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Select only an interpreter path the caller explicitly trusted.
|
||||
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
||||
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
||||
# repositories that control PATH. Windows-native absolute paths are converted
|
||||
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
||||
trusted_python() {
|
||||
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
||||
[ -n "${_tp_candidate}" ] || continue
|
||||
case "${_tp_candidate}" in
|
||||
\\\\*|//*) continue ;;
|
||||
[A-Za-z]:[\\/]*)
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
_tp_cygpath="/usr/bin/cygpath.exe"
|
||||
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
||||
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
||||
|| continue
|
||||
;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
[ -f "${_tp_candidate}" ] || continue
|
||||
[ -x "${_tp_candidate}" ] || continue
|
||||
printf "%s\n" "${_tp_candidate}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
||||
# Prints the canonical absolute path on success; prints nothing and returns 1
|
||||
# on a full miss so containment fails closed. No Python spawn on the happy
|
||||
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
||||
canonicalize() {
|
||||
target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
_canonical_python="$(trusted_python)" || _canonical_python=""
|
||||
if [ -n "${_canonical_python}" ]; then
|
||||
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
||||
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
#
|
||||
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
||||
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
||||
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
||||
# relative "." root — byte-identical to the legacy check.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
# Pinned resolution skips the rewrite: candidate and root then share the
|
||||
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
||||
if [ -n "${PWF_ROOT_PIN}" ]; then
|
||||
check_target="${candidate}"
|
||||
else
|
||||
echo "No active plan set."
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
fi
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Each phase contributes at most one status. An explicit Status line wins
|
||||
# over an inline heading marker. Fenced examples and unrelated sections are
|
||||
# not phases. Keep this parser aligned with the PowerShell helper.
|
||||
phase_status() {
|
||||
awk '
|
||||
function finish() {
|
||||
if (phase) {
|
||||
status = primary != "" ? primary : inline_status
|
||||
if (status == "complete") complete++
|
||||
else if (status == "in_progress") in_progress++
|
||||
else if (status == "pending") pending++
|
||||
}
|
||||
phase = 0; primary = ""; inline_status = ""
|
||||
}
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
trimmed = line
|
||||
sub(/^ */, "", trimmed)
|
||||
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
|
||||
marker = substr(trimmed, 1, 1)
|
||||
run = 0
|
||||
while (substr(trimmed, run + 1, 1) == marker) run++
|
||||
if (fence == "") { fence = marker; fence_length = run }
|
||||
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
|
||||
next
|
||||
}
|
||||
if (fence != "") next
|
||||
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
|
||||
finish(); phase = 1; total++
|
||||
if (match(line, /\[(complete|in_progress|pending)\]/))
|
||||
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
|
||||
finish()
|
||||
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
|
||||
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
|
||||
sub(/[ \t].*$/, "", line)
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
END {
|
||||
finish()
|
||||
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
|
||||
}
|
||||
' < "$1"
|
||||
}
|
||||
|
||||
# A pre-existing pointer must be a contained regular file before it is
|
||||
# replaced: a link would be followed or its shared inode overwritten.
|
||||
pointer_is_unsafe() {
|
||||
{ [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
|
||||
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
verify_root() {
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
current_active() {
|
||||
# An unreadable pointer is treated as unset; under set -e the read
|
||||
# would otherwise abort listing.
|
||||
if [ -f "${ACTIVE_FILE}" ] && [ -r "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
|
||||
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
|
||||
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
|
||||
# Treat it as an encoding marker, not part of the shared plan slug.
|
||||
_utf8_bom="$(printf '\357\273\277')"
|
||||
case "${_current}" in
|
||||
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
list_plans() {
|
||||
if [ ! -d "${PLAN_ROOT}" ]; then
|
||||
printf '%s\n' 'No planning directory found.'
|
||||
return 0
|
||||
fi
|
||||
if ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
_active="$(current_active)"
|
||||
_found=0
|
||||
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
|
||||
_plan_file="${_dir}/task_plan.md"
|
||||
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
|
||||
is_within_root "${_plan_file}" || continue
|
||||
_status="$(phase_status "${_plan_file}")" || continue
|
||||
_marker=''
|
||||
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
|
||||
printf '%s\n' "- ${_id}${_marker} - ${_status}"
|
||||
_found=1
|
||||
done
|
||||
if [ "${_found}" -eq 0 ]; then
|
||||
printf '%s\n' 'No named plans found.'
|
||||
else
|
||||
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
printf '%s\n' 'Error: list plans, verify the root, or set PLAN_ID in separate calls.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--list|-l) list_plans; exit $? ;;
|
||||
--verify-root) verify_root; exit $? ;;
|
||||
--help|-h)
|
||||
printf '%s\n' 'Usage: set-active-plan.sh [--list|--verify-root|PLAN_ID]' \
|
||||
'Lists saved named plans in the current project without selecting a plan.' \
|
||||
'--verify-root checks the planning root and pointer without listing or selecting.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = '' ]; then
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
exit 1
|
||||
fi
|
||||
plan_id="$(current_active)"
|
||||
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)"
|
||||
else
|
||||
printf '%s\n' 'No active plan set.'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
if ! slug_is_valid "${PLAN_ID}"; then
|
||||
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
|
||||
"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
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
exit 1
|
||||
fi
|
||||
# Replace the pointer atomically instead of truncating a possible hardlink.
|
||||
# mktemp creates a private, exclusive file beside the destination.
|
||||
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
|
||||
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "${temp_file}"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
|
||||
# mktemp creates the file 0600; the shared pointer must stay readable by
|
||||
# every session, so apply the caller's umask instead (=rw without a who
|
||||
# clause is umask-relative in POSIX chmod).
|
||||
chmod =rw "${temp_file}" 2>/dev/null || true
|
||||
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
|
||||
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
|
||||
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.17.0"
|
||||
"version": "3.21.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.17.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.17.0"
|
||||
version: "3.21.0"
|
||||
|
||||
---
|
||||
|
||||
@@ -46,7 +46,7 @@ Work like Manus: Use persistent markdown files as your "working memory on disk."
|
||||
|
||||
## FIRST: Restore Project State
|
||||
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or session isolation is armed with multiple plans and no `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or multiple named plans exist without `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
|
||||
```bash
|
||||
# Linux/macOS — auto-detects skill directory (plugin env or default install path)
|
||||
@@ -213,6 +213,12 @@ Helper scripts for automation:
|
||||
- `scripts/check-complete.sh` — Verify all phases complete
|
||||
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
|
||||
|
||||
### List saved plans
|
||||
|
||||
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
|
||||
|
||||
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- **Manus Principles:** See [reference.md](reference.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.");
|
||||
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
|
||||
$securityRootPath = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
# Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
|
||||
# local path is the only accepted shape, as in resolve-plan-dir.ps1.
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
|
||||
if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
|
||||
throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
|
||||
}
|
||||
$securityRootPath = $pin
|
||||
@@ -404,6 +412,7 @@ function Resolve-PlanFile {
|
||||
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
||||
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
||||
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
||||
if ($resolvedDir) {
|
||||
$planFile = Join-Path $resolvedDir "task_plan.md"
|
||||
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
||||
|
||||
@@ -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,9 +45,46 @@ 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)"
|
||||
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
||||
printf "%s\n" "${plan_dir}/task_plan.md"
|
||||
@@ -76,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
|
||||
@@ -97,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)" || {
|
||||
@@ -113,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
|
||||
@@ -152,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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall -> allow stop)
|
||||
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
||||
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
||||
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
||||
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
||||
#
|
||||
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
||||
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
||||
@@ -35,6 +36,7 @@ if ($PlanFile -ne "") {
|
||||
try {
|
||||
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
||||
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
||||
} catch {
|
||||
$resolvedDir = ""
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -84,9 +88,10 @@ if ($TOTAL -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# advisory_report: the v2.43 status echo.
|
||||
# Keep explicit reports, but omit routine success from automatic gate checks.
|
||||
function Write-AdvisoryReport {
|
||||
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
||||
if ($Gate) { return }
|
||||
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
||||
} else {
|
||||
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall → allow stop)
|
||||
# When all hold, it emits a single-line block-decision JSON on stdout and
|
||||
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
||||
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
||||
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
||||
# plans stay silent in --gate mode, including legacy plans without .mode.
|
||||
# Without --gate, the explicit advisory report is unchanged.
|
||||
#
|
||||
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
||||
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
||||
@@ -56,6 +57,9 @@ else
|
||||
RESOLVED_DIR=""
|
||||
if [ -f "${RESOLVER}" ]; then
|
||||
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
||||
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
||||
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
||||
@@ -75,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
|
||||
@@ -116,9 +123,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
||||
# Explicit status reports retain completion text. Automatic gate checks have
|
||||
# nothing to report on success; keep evaluating all gate guards before here.
|
||||
advisory_report() {
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
[ "$GATE" -eq 1 ] && return 0
|
||||
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
||||
else
|
||||
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
||||
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
||||
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
||||
# Templates: default, analytics
|
||||
@@ -12,10 +13,32 @@
|
||||
param(
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default",
|
||||
[switch]$PlanDir,
|
||||
[switch]$Autonomous,
|
||||
[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.
|
||||
@@ -38,7 +61,65 @@ function Get-Nonce {
|
||||
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
function Get-PlanSlug([string]$Name) {
|
||||
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
||||
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
||||
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
||||
$slug = $slug -creplace '-{2,}', '-'
|
||||
$slug = $slug.Trim('-')
|
||||
if ($slug.Length -gt 40) {
|
||||
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
||||
}
|
||||
# The resolvers and the selector only accept ASCII plan ids.
|
||||
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
||||
return ""
|
||||
}
|
||||
return $slug
|
||||
}
|
||||
|
||||
function Get-ShortId {
|
||||
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
||||
}
|
||||
|
||||
function Get-InheritedMode([string]$CurrentMode) {
|
||||
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
||||
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
||||
return $CurrentMode
|
||||
}
|
||||
if ($CurrentMode -eq "gated") {
|
||||
return $CurrentMode
|
||||
}
|
||||
|
||||
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
||||
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
||||
if ($RootMode -cmatch 'gate') {
|
||||
return "gated"
|
||||
}
|
||||
if ($RootMode -cmatch 'autonomous') {
|
||||
return "autonomous"
|
||||
}
|
||||
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") {
|
||||
@@ -46,11 +127,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
||||
# mode. A positional project name or -PlanDir creates an isolated
|
||||
# .planning/<date>-<slug>/ plan.
|
||||
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
||||
$UsePlanDir = $PlanDir -or $NamedPlan
|
||||
if ($UsePlanDir) {
|
||||
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
||||
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
||||
# shared pointer, so a named plan cannot be created without it.
|
||||
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
||||
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
||||
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. A
|
||||
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
||||
# it first and treat a thrown error as a failure too.
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $PlanSelector -VerifyRoot *> $null
|
||||
} catch {
|
||||
$global:LASTEXITCODE = 1
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NamedPlan) {
|
||||
$Slug = Get-PlanSlug $ProjectName
|
||||
} else {
|
||||
$Slug = ""
|
||||
$ProjectName = "untitled"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Slug)) {
|
||||
$Slug = "untitled-$(Get-ShortId)"
|
||||
}
|
||||
|
||||
$BaseId = "$DATE-$Slug"
|
||||
$PlanId = $BaseId
|
||||
$Counter = 2
|
||||
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
||||
$PlanId = "$BaseId-$Counter"
|
||||
$Counter++
|
||||
}
|
||||
$TargetDir = Join-Path $PlanningRoot $PlanId
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
||||
$Mode = Get-InheritedMode $Mode
|
||||
} else {
|
||||
$TargetDir = (Get-Location).Path
|
||||
}
|
||||
|
||||
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
||||
$FindingsPath = Join-Path $TargetDir "findings.md"
|
||||
$ProgressPath = Join-Path $TargetDir "progress.md"
|
||||
if ($UsePlanDir) {
|
||||
$TaskPlanDisplay = $TaskPlanPath
|
||||
$FindingsDisplay = $FindingsPath
|
||||
$ProgressDisplay = $ProgressPath
|
||||
} else {
|
||||
$TaskPlanDisplay = "task_plan.md"
|
||||
$FindingsDisplay = "findings.md"
|
||||
$ProgressDisplay = "progress.md"
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "PLAN_ID=$PlanId"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
@@ -99,18 +255,18 @@ Phase 1
|
||||
## Errors Encountered
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
Write-Host "Created $TaskPlanDisplay"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
Write-Host "$TaskPlanDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
@@ -131,15 +287,15 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
Write-Host "Created $FindingsDisplay"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
Write-Host "$FindingsDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -160,7 +316,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -181,23 +337,74 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
Write-Host "Created $ProgressDisplay"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
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!"
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
||||
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
||||
Write-Host " `$env:PLAN_ID='$PlanId'"
|
||||
} else {
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
}
|
||||
|
||||
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
||||
# the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
|
||||
# dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
|
||||
# .plan-attestation at the project root.
|
||||
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
||||
# selected plan; root mode therefore retains the legacy project-root behavior.
|
||||
if ($Mode -ne "") {
|
||||
$PlanDirPwf = (Get-Location).Path
|
||||
$PlanDirPwf = $TargetDir
|
||||
|
||||
# (a) reset gate block counter, drop stale gate ledger.
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
||||
@@ -216,15 +423,96 @@ if ($Mode -ne "") {
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
||||
|
||||
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
||||
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
||||
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
||||
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
||||
# 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 $AttestPs1) -and (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 {
|
||||
& $AttestPs1 *> $null
|
||||
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) {
|
||||
$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 {
|
||||
$AttestationCommand = "attest-plan.sh"
|
||||
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
||||
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# CDPATH must not redirect the cd that locates the sibling scripts.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -184,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"
|
||||
@@ -201,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
|
||||
}
|
||||
|
||||
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
||||
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
||||
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. The
|
||||
# selector's check is constant time; --list would parse every plan.
|
||||
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
||||
exit 1
|
||||
fi
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
||||
# redirection would truncate a pre-existing hardlink and could overwrite a
|
||||
# different file that shares the same inode.
|
||||
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
||||
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
inherit_root_mode
|
||||
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
||||
echo ""
|
||||
@@ -401,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}"
|
||||
@@ -412,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
|
||||
|
||||
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
|
||||
one turn-marker slot and one progress-guard slot per plan.
|
||||
|
||||
Known, accepted differences from the shell reference:
|
||||
* Two plan directories with the same whole-second mtime, no .active_plan
|
||||
and no PLAN_ID: the reference picks the first in the shell's glob order
|
||||
(locale collation), this twin the first in code-point order. The same
|
||||
collation difference can change which three of four or more nested
|
||||
projects the ambiguity notice names. A machine runs one implementation,
|
||||
so neither choice flips within a session.
|
||||
* Shell glob order follows locale collation; this twin uses code-point
|
||||
order. This can change which three of four or more nested projects the
|
||||
ambiguity notice names.
|
||||
* BSD sed (macOS) appends a newline to a progress.md whose last line has
|
||||
none; GNU sed and this twin do not.
|
||||
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
|
||||
@@ -837,8 +834,21 @@ class Injector(object):
|
||||
scope = ""
|
||||
explicit = bool(plan_prefix)
|
||||
plan_id = env.get("PLAN_ID", "")
|
||||
ambiguous = plan_is_ambiguous(
|
||||
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
|
||||
)
|
||||
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
|
||||
"for this session; nothing injected."
|
||||
)
|
||||
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
|
||||
@@ -857,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"):
|
||||
@@ -873,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"):
|
||||
@@ -923,30 +935,14 @@ class Injector(object):
|
||||
"single-session mode."
|
||||
)
|
||||
raise Bail()
|
||||
if not plan_id:
|
||||
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
|
||||
try:
|
||||
names = sorted(os.listdir(plan_prefix + ".planning"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
|
||||
continue
|
||||
if not slug_is_valid(name):
|
||||
continue
|
||||
plan_n += 1
|
||||
if plan_n > 1:
|
||||
break
|
||||
if plan_n > 1:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
if ambiguous:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
|
||||
if not explicit:
|
||||
nested = []
|
||||
@@ -1326,6 +1322,28 @@ def inject(context, env=None):
|
||||
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
|
||||
"""A project pointer or mtime cannot bind a session to multiple plans."""
|
||||
if plan_id:
|
||||
return False
|
||||
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
|
||||
try:
|
||||
names = os.listdir(plan_root)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def resolve_plan_dir(env=None):
|
||||
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
|
||||
|
||||
@@ -1356,10 +1374,13 @@ 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 ("", "")
|
||||
|
||||
if plan_is_ambiguous(fs_root, pin if pin else "."):
|
||||
return ("", "")
|
||||
|
||||
active_file = fs_root + "/.active_plan"
|
||||
if is_file(active_file):
|
||||
try:
|
||||
@@ -1371,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):
|
||||
@@ -1387,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"):
|
||||
@@ -1441,6 +1464,8 @@ class ClaudeDispatcher(object):
|
||||
return (spelled, fs)
|
||||
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
|
||||
return ("", "")
|
||||
if plan_is_ambiguous(".planning", "."):
|
||||
return ("", "")
|
||||
if is_file("task_plan.md"):
|
||||
return (".", ".")
|
||||
return ("", "")
|
||||
|
||||
@@ -254,6 +254,30 @@ is_within_root() {
|
||||
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
|
||||
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
|
||||
# conflict check below.
|
||||
# Shared .active_plan and directory mtime cannot identify this session's plan.
|
||||
# Check before selection or preflight, even when isolation was never armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
EXPLICIT=0
|
||||
@@ -272,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
|
||||
@@ -282,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
|
||||
@@ -291,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)
|
||||
@@ -442,27 +469,13 @@ PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An attachment admits a session but does not select one of several plans.
|
||||
# When isolation is armed, require PLAN_ID if more than one live same-root
|
||||
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
|
||||
# within that root.
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
SESSION_PLAN_N=0
|
||||
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
|
||||
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
[ -f "$candidate" ] || continue
|
||||
candidate_dir="${candidate%/task_plan.md}"
|
||||
candidate_slug="${candidate_dir##*/}"
|
||||
slug_is_valid "$candidate_slug" || continue
|
||||
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
|
||||
[ "$SESSION_PLAN_N" -gt 1 ] && break
|
||||
done
|
||||
if [ "$SESSION_PLAN_N" -gt 1 ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
# Attachment admits a session but does not select its plan. Preserve the
|
||||
# attachment-first notice above for sessions that never opted in.
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
# junction/symlink escape; slug validation alone blocks textual traversal.
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
|
||||
[switch]$CheckAmbiguity
|
||||
)
|
||||
|
||||
$projectRoot = (Get-Location).Path
|
||||
@@ -76,6 +77,17 @@ function Get-FinalDirectoryPath {
|
||||
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
}
|
||||
|
||||
function Test-FullyQualifiedLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ($script:IsWindowsHost) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [System.IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
||||
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
||||
# project resolves the parent's plan and never sees the nested one;
|
||||
@@ -89,9 +101,8 @@ function Get-FinalDirectoryPath {
|
||||
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
||||
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
if ((Test-FullyQualifiedLocalPath $pin) -and
|
||||
(Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
$projectRoot = $pin
|
||||
$PlanRoot = Join-Path $pin ".planning"
|
||||
} else {
|
||||
@@ -128,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
|
||||
@@ -137,10 +166,37 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
|
||||
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
|
||||
# falsy here and still means "unset".
|
||||
# The optional probe distinguishes ambiguity from a legacy-root fallback.
|
||||
# Multiple named plans require PLAN_ID even without a sessions directory.
|
||||
$planCount = 0
|
||||
if (-not $env:PLAN_ID) {
|
||||
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
|
||||
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
|
||||
$planCount = 1
|
||||
}
|
||||
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++
|
||||
if ($planCount -gt 1) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($CheckAmbiguity) {
|
||||
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
|
||||
exit 0
|
||||
}
|
||||
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 $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
|
||||
}
|
||||
@@ -150,29 +206,37 @@ 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
|
||||
}
|
||||
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
||||
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
||||
# falls through like an invalid one instead of raising inside a caller.
|
||||
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId -and (Test-ValidSlug $planId)) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if ((Test-Path $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
||||
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
||||
# valid selection into an empty result.
|
||||
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 (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-WithinRoot $_.FullName } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Optional probe distinguishes ambiguity from the empty legacy-root result.
|
||||
# Both modes keep stdout data-only and always exit zero.
|
||||
CHECK_AMBIGUITY=0
|
||||
if [ "${1:-}" = "--check-ambiguity" ]; then
|
||||
CHECK_AMBIGUITY=1
|
||||
shift
|
||||
fi
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
@@ -239,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
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
@@ -321,6 +336,30 @@ resolve_latest_dir() {
|
||||
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
|
||||
# above already does it; a non-zero status would kill callers running under
|
||||
# set -e for a condition that is not an internal error.
|
||||
# A shared pointer or mtime is not a per-session binding (issue #240).
|
||||
# Count conservatively, just like injection: slug-valid live plan files.
|
||||
# The legacy root joins the count only when session isolation is armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$CHECK_AMBIGUITY" = "1" ]; then
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
|
||||
exit 0
|
||||
fi
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
|
||||
|
||||
if [ -n "${PLAN_ID:-}" ]; then
|
||||
resolve_from_env && exit 0
|
||||
exit 0
|
||||
|
||||
@@ -407,9 +407,6 @@ def get_session_candidates(
|
||||
return 'claude', []
|
||||
|
||||
|
||||
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
||||
|
||||
|
||||
def get_opencode_db_path() -> Optional[Path]:
|
||||
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
||||
xdg = os.environ.get('XDG_DATA_HOME')
|
||||
@@ -471,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', '')
|
||||
@@ -489,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
|
||||
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
||||
update_time = None
|
||||
update_idx = -1
|
||||
for idx, (sid, _) in enumerate(previous_sessions):
|
||||
params = (sid,) + PLANNING_LIKE_SQL
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_created FROM part
|
||||
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 (
|
||||
json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
IN ('task_plan.md', 'findings.md', 'progress.md')
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/task_plan.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/findings.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/progress.md'
|
||||
)
|
||||
ORDER BY time_created DESC
|
||||
LIMIT 1
|
||||
ORDER BY time_created DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
update_sid = sid
|
||||
update_time = row[0]
|
||||
update_idx = idx
|
||||
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
||||
# would materialize every planning write of the session before the
|
||||
# first validated row ends the loop.
|
||||
for candidate_time, data_str in cur:
|
||||
data = json_loads(data_str)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
state = data.get('state')
|
||||
input_ = state.get('input') if isinstance(state, dict) else None
|
||||
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
||||
if planning_file_from_path(file_path):
|
||||
update_sid = sid
|
||||
update_time = candidate_time
|
||||
update_idx = idx
|
||||
break
|
||||
if update_sid:
|
||||
break
|
||||
|
||||
if not update_sid:
|
||||
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
||||
"""Return a planning filename only when it is the path's exact basename.
|
||||
|
||||
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
||||
planning updates and can anchor catchup at unrelated transcript content.
|
||||
Normalize separators so the same boundary rule works for Unix and Windows
|
||||
session records.
|
||||
"""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
for pf in PLANNING_FILES:
|
||||
if path_value.endswith(pf):
|
||||
return pf
|
||||
return None
|
||||
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
||||
return basename if basename in PLANNING_FILES else None
|
||||
|
||||
|
||||
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
||||
|
||||
Regular → Executable
+386
-16
@@ -1,27 +1,324 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 - print the current active plan (if any)
|
||||
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
||||
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
||||
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
||||
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanId = "",
|
||||
[Alias('l', '-list')]
|
||||
[switch]$List,
|
||||
[Alias('verify-root')]
|
||||
[switch]$VerifyRoot,
|
||||
[Alias('h', '-help')]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
# Windows PowerShell 5.1 can silently change directories when -File inherits
|
||||
# a cwd containing wildcard characters. Recover that physical cwd only for a
|
||||
# direct invocation of this script; interactive Set-Location remains primary.
|
||||
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)) {
|
||||
$ProjectRoot = [Environment]::CurrentDirectory
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$PlanRoot = Join-Path $ProjectRoot ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
|
||||
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
|
||||
# open the actual filesystem object before checking the project boundary.
|
||||
# The Windows handle supports both directories and task_plan.md files.
|
||||
function Get-FinalPath {
|
||||
param([string]$Path)
|
||||
if (-not ("PwfPlanListingNative" -as [type])) {
|
||||
Add-Type -ErrorAction Stop -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public static class PwfPlanListingNative {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string name, uint access, uint share, IntPtr security,
|
||||
uint creation, uint flags, IntPtr template);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern IntPtr realpath(string path, IntPtr resolved);
|
||||
[DllImport("libc")]
|
||||
private static extern void free(IntPtr pointer);
|
||||
|
||||
public static string FinalPath(string path) {
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
|
||||
IntPtr resolved = realpath(path, IntPtr.Zero);
|
||||
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try { return Marshal.PtrToStringAnsi(resolved); }
|
||||
finally { free(resolved); }
|
||||
}
|
||||
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
|
||||
using (SafeFileHandle handle = CreateFileW(
|
||||
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder buffer = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
string result = buffer.ToString();
|
||||
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + result.Substring(8);
|
||||
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
||||
return result.Substring(4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
return [PwfPlanListingNative]::FinalPath($Path)
|
||||
}
|
||||
|
||||
function Test-WithinRoot {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
|
||||
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
|
||||
$comparison = [StringComparison]::Ordinal
|
||||
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
|
||||
return $pathReal.Equals($rootReal, $comparison) -or
|
||||
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ValidSlug {
|
||||
param([string]$Name)
|
||||
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
|
||||
}
|
||||
|
||||
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. 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 -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 {
|
||||
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
|
||||
if (Test-ValidSlug $current) { return $current }
|
||||
} catch { }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Add-PhaseStatus {
|
||||
param([hashtable]$Counts, $Phase)
|
||||
if ($null -eq $Phase) { return }
|
||||
$status = $Phase.Inline
|
||||
if ($Phase.Primary) { $status = $Phase.Primary }
|
||||
if ($status) { $Counts[$status]++ }
|
||||
}
|
||||
|
||||
function Get-PhaseStatus {
|
||||
param([string]$PlanFile)
|
||||
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
|
||||
$phase = $null
|
||||
$fence = ""
|
||||
$fenceLength = 0
|
||||
# The shipped translations keep status identifiers in English. Unicode
|
||||
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
|
||||
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
|
||||
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
|
||||
# Read once; an empty file is a valid plan with zero phases.
|
||||
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
|
||||
foreach ($line in ($content -split '\r?\n')) {
|
||||
if ($fence) {
|
||||
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
|
||||
if ($line -match $closing) { $fence = "" }
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
|
||||
$fence = $Matches[1].Substring(0, 1)
|
||||
$fenceLength = $Matches[1].Length
|
||||
continue
|
||||
}
|
||||
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = @{ Primary = ""; Inline = "" }
|
||||
$counts.total++
|
||||
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
|
||||
$phase.Inline = $Matches[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = $null
|
||||
continue
|
||||
}
|
||||
if ($null -ne $phase -and -not $phase.Primary -and
|
||||
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
|
||||
$phase.Primary = $Matches[1]
|
||||
}
|
||||
}
|
||||
Add-PhaseStatus $counts $phase
|
||||
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
|
||||
}
|
||||
|
||||
function Show-PlanList {
|
||||
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
|
||||
Write-Output "No planning directory found."
|
||||
return
|
||||
}
|
||||
if (-not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$active = Get-CurrentActivePlan
|
||||
Write-Output "Available plans:"
|
||||
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 }
|
||||
try { $status = Get-PhaseStatus $planFile } catch { continue }
|
||||
$marker = ""
|
||||
if ($plan.Name -ceq $active) { $marker = " [active]" }
|
||||
Write-Output "- $($plan.Name)$marker - $status"
|
||||
$found = $true
|
||||
}
|
||||
if (-not $found) { Write-Output "No named plans found." }
|
||||
}
|
||||
|
||||
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
||||
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
if ($VerifyRoot) {
|
||||
if ($List -or $PlanId) {
|
||||
Write-Error "Error: verify the planning root in a separate call."
|
||||
exit 1
|
||||
}
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
|
||||
if ($List -and $PlanId) {
|
||||
Write-Error "Error: list plans or set PLAN_ID in separate calls."
|
||||
exit 1
|
||||
}
|
||||
Show-PlanList
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$current = ""
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
|
||||
if ($current) {
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
@@ -29,21 +326,94 @@ if ($PlanId -eq "") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-ValidSlug $PlanId)) {
|
||||
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
|
||||
exit 1
|
||||
}
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
$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
|
||||
}
|
||||
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($activeItem -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
# Replace the directory entry instead of truncating an existing inode: a
|
||||
# hardlinked pointer must not overwrite another file. The exclusive temporary
|
||||
# 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
|
||||
try {
|
||||
$bytes = $utf8NoBom.GetBytes($PlanId)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
} finally { $stream.Dispose() }
|
||||
$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 {
|
||||
$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 ""
|
||||
|
||||
Regular → Executable
+358
-38
@@ -1,50 +1,370 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
# List saved named plans, show the shared pointer, or change that pointer.
|
||||
# Usage: set-active-plan.sh [--list|-l|--verify-root|PLAN_ID]
|
||||
# Operates on the current project; listing never binds a host or injects data.
|
||||
set -eu
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
PWF_ROOT_PIN=""
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
# Use the resolver canonicalization policy without running plan selection.
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Return true when a candidate path names the Microsoft Store WindowsApps
|
||||
# directory. Store app aliases are not stable interpreter binaries and may
|
||||
# present as executable while refusing script execution. Matching is
|
||||
# case-insensitive and works before or after Windows slash normalization.
|
||||
is_windowsapps_path() {
|
||||
norm_slashes "$1"
|
||||
case "${NORM_OUT}" in
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Select only an interpreter path the caller explicitly trusted.
|
||||
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
||||
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
||||
# repositories that control PATH. Windows-native absolute paths are converted
|
||||
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
||||
trusted_python() {
|
||||
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
||||
[ -n "${_tp_candidate}" ] || continue
|
||||
case "${_tp_candidate}" in
|
||||
\\\\*|//*) continue ;;
|
||||
[A-Za-z]:[\\/]*)
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
_tp_cygpath="/usr/bin/cygpath.exe"
|
||||
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
||||
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
||||
|| continue
|
||||
;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
[ -f "${_tp_candidate}" ] || continue
|
||||
[ -x "${_tp_candidate}" ] || continue
|
||||
printf "%s\n" "${_tp_candidate}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
||||
# Prints the canonical absolute path on success; prints nothing and returns 1
|
||||
# on a full miss so containment fails closed. No Python spawn on the happy
|
||||
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
||||
canonicalize() {
|
||||
target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
_canonical_python="$(trusted_python)" || _canonical_python=""
|
||||
if [ -n "${_canonical_python}" ]; then
|
||||
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
||||
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
#
|
||||
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
||||
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
||||
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
||||
# relative "." root — byte-identical to the legacy check.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
# Pinned resolution skips the rewrite: candidate and root then share the
|
||||
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
||||
if [ -n "${PWF_ROOT_PIN}" ]; then
|
||||
check_target="${candidate}"
|
||||
else
|
||||
echo "No active plan set."
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
fi
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Each phase contributes at most one status. An explicit Status line wins
|
||||
# over an inline heading marker. Fenced examples and unrelated sections are
|
||||
# not phases. Keep this parser aligned with the PowerShell helper.
|
||||
phase_status() {
|
||||
awk '
|
||||
function finish() {
|
||||
if (phase) {
|
||||
status = primary != "" ? primary : inline_status
|
||||
if (status == "complete") complete++
|
||||
else if (status == "in_progress") in_progress++
|
||||
else if (status == "pending") pending++
|
||||
}
|
||||
phase = 0; primary = ""; inline_status = ""
|
||||
}
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
trimmed = line
|
||||
sub(/^ */, "", trimmed)
|
||||
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
|
||||
marker = substr(trimmed, 1, 1)
|
||||
run = 0
|
||||
while (substr(trimmed, run + 1, 1) == marker) run++
|
||||
if (fence == "") { fence = marker; fence_length = run }
|
||||
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
|
||||
next
|
||||
}
|
||||
if (fence != "") next
|
||||
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
|
||||
finish(); phase = 1; total++
|
||||
if (match(line, /\[(complete|in_progress|pending)\]/))
|
||||
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
|
||||
finish()
|
||||
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
|
||||
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
|
||||
sub(/[ \t].*$/, "", line)
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
END {
|
||||
finish()
|
||||
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
|
||||
}
|
||||
' < "$1"
|
||||
}
|
||||
|
||||
# A pre-existing pointer must be a contained regular file before it is
|
||||
# replaced: a link would be followed or its shared inode overwritten.
|
||||
pointer_is_unsafe() {
|
||||
{ [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
|
||||
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
verify_root() {
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
current_active() {
|
||||
# An unreadable pointer is treated as unset; under set -e the read
|
||||
# would otherwise abort listing.
|
||||
if [ -f "${ACTIVE_FILE}" ] && [ -r "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
|
||||
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
|
||||
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
|
||||
# Treat it as an encoding marker, not part of the shared plan slug.
|
||||
_utf8_bom="$(printf '\357\273\277')"
|
||||
case "${_current}" in
|
||||
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
list_plans() {
|
||||
if [ ! -d "${PLAN_ROOT}" ]; then
|
||||
printf '%s\n' 'No planning directory found.'
|
||||
return 0
|
||||
fi
|
||||
if ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
_active="$(current_active)"
|
||||
_found=0
|
||||
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
|
||||
_plan_file="${_dir}/task_plan.md"
|
||||
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
|
||||
is_within_root "${_plan_file}" || continue
|
||||
_status="$(phase_status "${_plan_file}")" || continue
|
||||
_marker=''
|
||||
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
|
||||
printf '%s\n' "- ${_id}${_marker} - ${_status}"
|
||||
_found=1
|
||||
done
|
||||
if [ "${_found}" -eq 0 ]; then
|
||||
printf '%s\n' 'No named plans found.'
|
||||
else
|
||||
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
printf '%s\n' 'Error: list plans, verify the root, or set PLAN_ID in separate calls.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--list|-l) list_plans; exit $? ;;
|
||||
--verify-root) verify_root; exit $? ;;
|
||||
--help|-h)
|
||||
printf '%s\n' 'Usage: set-active-plan.sh [--list|--verify-root|PLAN_ID]' \
|
||||
'Lists saved named plans in the current project without selecting a plan.' \
|
||||
'--verify-root checks the planning root and pointer without listing or selecting.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = '' ]; then
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
exit 1
|
||||
fi
|
||||
plan_id="$(current_active)"
|
||||
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)"
|
||||
else
|
||||
printf '%s\n' 'No active plan set.'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
if ! slug_is_valid "${PLAN_ID}"; then
|
||||
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
|
||||
"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
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
exit 1
|
||||
fi
|
||||
# Replace the pointer atomically instead of truncating a possible hardlink.
|
||||
# mktemp creates a private, exclusive file beside the destination.
|
||||
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
|
||||
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "${temp_file}"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
|
||||
# mktemp creates the file 0600; the shared pointer must stay readable by
|
||||
# every session, so apply the caller's umask instead (=rw without a who
|
||||
# clause is umask-relative in POSIX chmod).
|
||||
chmod =rw "${temp_file}" 2>/dev/null || true
|
||||
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
|
||||
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
|
||||
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.17.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",
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"repository": "https://github.com/OthmanAdi/planning-with-files",
|
||||
"license": "MIT",
|
||||
"commands": [],
|
||||
"skills": "./.agents/skills/",
|
||||
"hooks": "./hooks/codex-hooks.json"
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ HOOK_DIR = Path(__file__).resolve().parent
|
||||
_SAFE_LEGACY_SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
||||
_PLAN_SLUG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*\Z")
|
||||
SESSION_PLAN_BINDING_NOTICE = (
|
||||
"[planning-with-files] Multiple plans are available while session isolation "
|
||||
"is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
"[planning-with-files] Multiple plans are available. "
|
||||
"Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
)
|
||||
_DARWIN_SYSTEM_ALIASES = {
|
||||
Path("/var"): Path("/private/var"),
|
||||
@@ -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":
|
||||
@@ -223,28 +244,30 @@ def session_plan_requires_binding(root: Path) -> bool:
|
||||
"""Require PLAN_ID when an attached session can resolve multiple plans.
|
||||
|
||||
Session sentinels admit a session to planning context. They do not select a
|
||||
plan. Once isolation is armed, an unbound session may use legacy resolution
|
||||
only when the effective root has at most one live plan candidate.
|
||||
plan. Multiple named plans always require a pin. With isolation armed,
|
||||
a legacy root plan also counts as a competing candidate.
|
||||
"""
|
||||
if os.environ.get("PLAN_ID", ""):
|
||||
return False
|
||||
|
||||
sessions_dir = root / ".planning" / "sessions"
|
||||
try:
|
||||
sessions_info = sessions_dir.lstat()
|
||||
if _is_reparse_or_link(sessions_dir) or not stat.S_ISDIR(sessions_info.st_mode):
|
||||
return False
|
||||
except (FileNotFoundError, OSError, RuntimeError):
|
||||
armed = sessions_dir.exists() or sessions_dir.is_symlink()
|
||||
planning_dir = root / ".planning"
|
||||
if not planning_dir.exists():
|
||||
return False
|
||||
|
||||
candidates = 0
|
||||
try:
|
||||
if (root / "task_plan.md").is_file():
|
||||
if armed and (root / "task_plan.md").is_file():
|
||||
candidates += 1
|
||||
planning_dir = root / ".planning"
|
||||
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:
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
|
||||
# resolver rejected one it emits nothing, and the legacy-root fallback below
|
||||
# would answer for a plan the operator never named. Stay silent instead; the
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
|
||||
# resolver rejected one it emits nothing, and the legacy-root fallback below
|
||||
# would carry the wrong plan through compaction, which is the one moment the
|
||||
|
||||
@@ -10,6 +10,9 @@ fi
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
|
||||
# resolver rejected one it emits nothing, and the legacy-root fallback below
|
||||
# would recite a plan the operator never named on every tool call. Stay silent
|
||||
@@ -34,10 +37,10 @@ if [ -f "$PLAN_FILE" ]; then
|
||||
if [ -n "$PWF_PYTHON" ] && [ -f "$PWF_PYTHON" ] && [ -f "${HOOK_DIR}/context_frame.py" ]; then
|
||||
if [ -n "$PLAN_DIR" ]; then ATTEST_FILE="${PLAN_DIR}/.attestation"; else ATTEST_FILE=".plan-attestation"; fi
|
||||
if [ -n "$PLAN_DIR" ]; then MODE_FILE="${PLAN_DIR}/.mode"; else MODE_FILE=".mode"; fi
|
||||
PWF_MODE=$("$PWF_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import context_frame as c; raw=c.read_optional_regular_bytes(Path(sys.argv[2]), max_source_bytes=256); tokens=[] if raw is None else raw.decode("ascii", errors="strict").split(); allowed={"autonomous", "gate", "inject-smart"}; print("unsafe" if any(t not in allowed for t in tokens) else ("gated" if "gate" in tokens else ("autonomous" if "autonomous" in tokens else "legacy")))' "$HOOK_DIR" "$MODE_FILE" 2>/dev/null) || PWF_MODE="unsafe"
|
||||
PWF_MODE=$("$PWF_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import context_frame as c; raw=c.read_optional_regular_bytes(Path(sys.argv[2]), max_source_bytes=256); tokens=[] if raw is None else raw.decode("ascii", errors="strict").split(); allowed={"autonomous", "gate", "inject-smart"}; print("unsafe" if any(t not in allowed for t in tokens) else ("gated" if "gate" in tokens else ("autonomous" if "autonomous" in tokens else "legacy")))' "$HOOK_DIR" "$MODE_FILE" 2>/dev/null) || PWF_MODE="unsafe"
|
||||
case "$PWF_MODE" in
|
||||
autonomous|gated) echo '{"decision": "allow"}'; exit 0 ;;
|
||||
legacy) "$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 30 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" >&2 ;;
|
||||
legacy) "$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 30 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" >&2 ;;
|
||||
*) : ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -12,7 +12,7 @@ SKILL_DIR="$CODEX_ROOT/skills/planning-with-files"
|
||||
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
|
||||
|
||||
if [ -n "$PYTHON_BIN" ] && [ -f "$SKILL_DIR/scripts/session-catchup.py" ]; then
|
||||
"$PYTHON_BIN" "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)"
|
||||
"$PYTHON_BIN" -I -X utf8 "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)"
|
||||
fi
|
||||
|
||||
sh "$SCRIPT_DIR/user-prompt-submit.sh"
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
|
||||
# resolver rejected one it emits nothing, and the legacy-root fallback below
|
||||
# would decide whether this run may stop from a plan the operator never named.
|
||||
@@ -20,8 +23,8 @@ fi
|
||||
# Codex supports a native Stop continuation decision. Delegate the decision to
|
||||
# the existing v3 gate oracle so Codex and skill-frontmatter installs share the
|
||||
# same opt-in mode, in_progress, recursion, cap, and stall guards. Outside a
|
||||
# gated block the oracle is advisory, and the legacy message below remains the
|
||||
# public output for backward compatibility.
|
||||
# gated block the oracle is advisory. Incomplete plans retain their notice;
|
||||
# completed plans need no followup message.
|
||||
CHECK_COMPLETE="${HOOK_DIR}/../skills/planning-with-files/scripts/check-complete.sh"
|
||||
if [ "${1:-}" != "--stop-hook-active" ] && [ -f "${CHECK_COMPLETE}" ]; then
|
||||
GATE_OUTPUT="$(sh "${CHECK_COMPLETE}" --gate "${PLAN_FILE}")"
|
||||
@@ -56,7 +59,6 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
fi
|
||||
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
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.\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ validate_python_candidate() {
|
||||
case "$_tp_lower" in
|
||||
*/microsoft/windowsapps/*) return 1 ;;
|
||||
esac
|
||||
"$_tp_candidate" -c 'import sys' >/dev/null 2>&1 || return 1
|
||||
"$_tp_candidate" -I -X utf8 -c 'import sys' >/dev/null 2>&1 || return 1
|
||||
printf '%s\n' "$_tp_candidate"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ if [ -n "${PWF_PLAN_ROOT:-}" ]; then
|
||||
esac
|
||||
PIN_PYTHON="$(trusted_python explicit 2>/dev/null)" || PIN_PYTHON=""
|
||||
[ -n "$PIN_PYTHON" ] || { echo "[planning-with-files] PWF_PLAN_ROOT could not be validated; nothing injected."; exit 0; }
|
||||
PIN_REAL=$("$PIN_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd()); print(root or "")' "$HOOK_DIR" 2>/dev/null) || PIN_REAL=""
|
||||
PIN_REAL=$("$PIN_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd()); print(root or "")' "$HOOK_DIR" 2>/dev/null) || PIN_REAL=""
|
||||
[ -n "$PIN_REAL" ] || { echo "[planning-with-files] PWF_PLAN_ROOT must stay within the current workspace; nothing injected."; exit 0; }
|
||||
if [ -n "$PIN_REAL" ] && [ -d "$PIN_REAL" ]; then
|
||||
PWF_PLAN_ROOT="$PIN_REAL"
|
||||
@@ -76,6 +76,10 @@ fi
|
||||
# Resolve and confirm a contained plan before probing or executing any Python
|
||||
# candidate. No-plan hook fires remain shell-only and silent.
|
||||
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
PLAN_AMBIGUOUS=1
|
||||
fi
|
||||
if [ -n "$PLAN_DIR" ]; then
|
||||
PLAN_FILE="${PLAN_DIR}/task_plan.md"
|
||||
PROGRESS_FILE="${PLAN_DIR}/progress.md"
|
||||
@@ -92,7 +96,7 @@ else
|
||||
PLAN_FILE="${PLAN_PREFIX}task_plan.md"
|
||||
PROGRESS_FILE="${PLAN_PREFIX}progress.md"
|
||||
fi
|
||||
[ -f "$PLAN_FILE" ] || exit 0
|
||||
[ -f "$PLAN_FILE" ] || [ "$PLAN_AMBIGUOUS" = "1" ] || exit 0
|
||||
|
||||
# --- Re-arm the once-per-turn PostToolUse nudge (issue #239). ---
|
||||
# One user message is one turn, and this hook fires once per turn, so clearing
|
||||
@@ -113,7 +117,7 @@ elif [ -n "${HOME:-}" ]; then
|
||||
else
|
||||
TURN_ROOT="${TMPDIR:-/tmp}/pwf-turn"
|
||||
fi
|
||||
if [ -d "$TURN_ROOT" ]; then
|
||||
if [ "$PLAN_AMBIGUOUS" = "0" ] && [ -d "$TURN_ROOT" ]; then
|
||||
case "$TURN_PLAN_FILE" in
|
||||
/*|[A-Za-z]:*|\\\\*) TURN_KEY_SRC="$TURN_PLAN_FILE" ;;
|
||||
*) TURN_KEY_SRC="${PWD}/${TURN_PLAN_FILE}" ;;
|
||||
@@ -141,7 +145,7 @@ else
|
||||
if [ -z "$PWF_PYTHON" ]; then
|
||||
PWF_SESSION_ADMISSION="refused"
|
||||
else
|
||||
PWF_SESSION_ADMISSION=$("$PWF_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd());
|
||||
PWF_SESSION_ADMISSION=$("$PWF_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd());
|
||||
if root is None: print("refused")
|
||||
else:
|
||||
try: (root / ".planning" / "sessions").lstat(); armed=True
|
||||
@@ -160,6 +164,11 @@ case "$PWF_SESSION_ADMISSION" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Plan-id safe-identifier check. Pure-sh case patterns; shared shape with
|
||||
# resolve-plan-dir.sh, needed below to decide whether PLAN_ID named the plan.
|
||||
slug_is_valid() {
|
||||
@@ -244,13 +253,13 @@ if [ -f "$PLAN_FILE" ]; then
|
||||
if [ -z "$PWF_PYTHON" ] || [ ! -f "${HOOK_DIR}/context_frame.py" ]; then exit 0; fi
|
||||
if [ -n "$PLAN_DIR" ]; then ATTEST_FILE="${PLAN_DIR}/.attestation"; else ATTEST_FILE="${PLAN_PREFIX}.plan-attestation"; fi
|
||||
if [ -n "$PLAN_DIR" ]; then MODE_FILE="${PLAN_DIR}/.mode"; else MODE_FILE="${PLAN_PREFIX}.mode"; fi
|
||||
PLAN_CONTEXT=$("$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 50 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" 2>&1)
|
||||
PLAN_CONTEXT=$("$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 50 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" 2>&1)
|
||||
if [ $? -ne 0 ]; then printf '%s\n' "$PLAN_CONTEXT"; exit 0; fi
|
||||
echo "[planning-with-files] ACTIVE PLAN — current state:"
|
||||
printf '%s\n' "$PLAN_CONTEXT"
|
||||
echo ""
|
||||
if [ -f "$PROGRESS_FILE" ]; then
|
||||
PROGRESS_CONTEXT=$("$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" progress "$PROGRESS_FILE" --tail 20 2>&1)
|
||||
PROGRESS_CONTEXT=$("$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" progress "$PROGRESS_FILE" --tail 20 2>&1)
|
||||
[ $? -eq 0 ] && printf '%s\n' "$PROGRESS_CONTEXT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -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.17.0"
|
||||
version: "3.21.0"
|
||||
|
||||
---
|
||||
|
||||
@@ -46,7 +46,7 @@ Work like Manus: Use persistent markdown files as your "working memory on disk."
|
||||
|
||||
## FIRST: Restore Project State
|
||||
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or session isolation is armed with multiple plans and no `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or multiple named plans exist without `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
|
||||
```bash
|
||||
# Linux/macOS (auto-detects python3 or python)
|
||||
@@ -212,6 +212,12 @@ Helper scripts for automation:
|
||||
- `scripts/check-complete.sh` — Verify all phases complete
|
||||
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
|
||||
|
||||
### List saved plans
|
||||
|
||||
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
|
||||
|
||||
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- **Manus Principles:** See [references/reference.md](references/reference.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.");
|
||||
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
|
||||
$securityRootPath = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
# Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
|
||||
# local path is the only accepted shape, as in resolve-plan-dir.ps1.
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
|
||||
if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
|
||||
throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
|
||||
}
|
||||
$securityRootPath = $pin
|
||||
@@ -404,6 +412,7 @@ function Resolve-PlanFile {
|
||||
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
||||
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
||||
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
||||
if ($resolvedDir) {
|
||||
$planFile = Join-Path $resolvedDir "task_plan.md"
|
||||
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
||||
|
||||
@@ -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,9 +45,46 @@ 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)"
|
||||
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
||||
printf "%s\n" "${plan_dir}/task_plan.md"
|
||||
@@ -76,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
|
||||
@@ -97,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)" || {
|
||||
@@ -113,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
|
||||
@@ -152,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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall -> allow stop)
|
||||
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
||||
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
||||
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
||||
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
||||
#
|
||||
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
||||
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
||||
@@ -35,6 +36,7 @@ if ($PlanFile -ne "") {
|
||||
try {
|
||||
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
||||
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
||||
} catch {
|
||||
$resolvedDir = ""
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -84,9 +88,10 @@ if ($TOTAL -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# advisory_report: the v2.43 status echo.
|
||||
# Keep explicit reports, but omit routine success from automatic gate checks.
|
||||
function Write-AdvisoryReport {
|
||||
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
||||
if ($Gate) { return }
|
||||
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
||||
} else {
|
||||
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall → allow stop)
|
||||
# When all hold, it emits a single-line block-decision JSON on stdout and
|
||||
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
||||
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
||||
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
||||
# plans stay silent in --gate mode, including legacy plans without .mode.
|
||||
# Without --gate, the explicit advisory report is unchanged.
|
||||
#
|
||||
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
||||
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
||||
@@ -56,6 +57,9 @@ else
|
||||
RESOLVED_DIR=""
|
||||
if [ -f "${RESOLVER}" ]; then
|
||||
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
||||
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
||||
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
||||
@@ -75,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
|
||||
@@ -116,9 +123,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
||||
# Explicit status reports retain completion text. Automatic gate checks have
|
||||
# nothing to report on success; keep evaluating all gate guards before here.
|
||||
advisory_report() {
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
[ "$GATE" -eq 1 ] && return 0
|
||||
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
||||
else
|
||||
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
||||
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
||||
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
||||
# Templates: default, analytics
|
||||
@@ -12,10 +13,32 @@
|
||||
param(
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default",
|
||||
[switch]$PlanDir,
|
||||
[switch]$Autonomous,
|
||||
[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.
|
||||
@@ -38,7 +61,65 @@ function Get-Nonce {
|
||||
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
function Get-PlanSlug([string]$Name) {
|
||||
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
||||
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
||||
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
||||
$slug = $slug -creplace '-{2,}', '-'
|
||||
$slug = $slug.Trim('-')
|
||||
if ($slug.Length -gt 40) {
|
||||
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
||||
}
|
||||
# The resolvers and the selector only accept ASCII plan ids.
|
||||
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
||||
return ""
|
||||
}
|
||||
return $slug
|
||||
}
|
||||
|
||||
function Get-ShortId {
|
||||
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
||||
}
|
||||
|
||||
function Get-InheritedMode([string]$CurrentMode) {
|
||||
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
||||
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
||||
return $CurrentMode
|
||||
}
|
||||
if ($CurrentMode -eq "gated") {
|
||||
return $CurrentMode
|
||||
}
|
||||
|
||||
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
||||
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
||||
if ($RootMode -cmatch 'gate') {
|
||||
return "gated"
|
||||
}
|
||||
if ($RootMode -cmatch 'autonomous') {
|
||||
return "autonomous"
|
||||
}
|
||||
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") {
|
||||
@@ -46,11 +127,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
||||
# mode. A positional project name or -PlanDir creates an isolated
|
||||
# .planning/<date>-<slug>/ plan.
|
||||
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
||||
$UsePlanDir = $PlanDir -or $NamedPlan
|
||||
if ($UsePlanDir) {
|
||||
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
||||
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
||||
# shared pointer, so a named plan cannot be created without it.
|
||||
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
||||
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
||||
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. A
|
||||
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
||||
# it first and treat a thrown error as a failure too.
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $PlanSelector -VerifyRoot *> $null
|
||||
} catch {
|
||||
$global:LASTEXITCODE = 1
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NamedPlan) {
|
||||
$Slug = Get-PlanSlug $ProjectName
|
||||
} else {
|
||||
$Slug = ""
|
||||
$ProjectName = "untitled"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Slug)) {
|
||||
$Slug = "untitled-$(Get-ShortId)"
|
||||
}
|
||||
|
||||
$BaseId = "$DATE-$Slug"
|
||||
$PlanId = $BaseId
|
||||
$Counter = 2
|
||||
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
||||
$PlanId = "$BaseId-$Counter"
|
||||
$Counter++
|
||||
}
|
||||
$TargetDir = Join-Path $PlanningRoot $PlanId
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
||||
$Mode = Get-InheritedMode $Mode
|
||||
} else {
|
||||
$TargetDir = (Get-Location).Path
|
||||
}
|
||||
|
||||
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
||||
$FindingsPath = Join-Path $TargetDir "findings.md"
|
||||
$ProgressPath = Join-Path $TargetDir "progress.md"
|
||||
if ($UsePlanDir) {
|
||||
$TaskPlanDisplay = $TaskPlanPath
|
||||
$FindingsDisplay = $FindingsPath
|
||||
$ProgressDisplay = $ProgressPath
|
||||
} else {
|
||||
$TaskPlanDisplay = "task_plan.md"
|
||||
$FindingsDisplay = "findings.md"
|
||||
$ProgressDisplay = "progress.md"
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "PLAN_ID=$PlanId"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
@@ -99,18 +255,18 @@ Phase 1
|
||||
## Errors Encountered
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
Write-Host "Created $TaskPlanDisplay"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
Write-Host "$TaskPlanDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
@@ -131,15 +287,15 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
Write-Host "Created $FindingsDisplay"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
Write-Host "$FindingsDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -160,7 +316,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -181,23 +337,74 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
Write-Host "Created $ProgressDisplay"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
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!"
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
||||
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
||||
Write-Host " `$env:PLAN_ID='$PlanId'"
|
||||
} else {
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
}
|
||||
|
||||
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
||||
# the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
|
||||
# dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
|
||||
# .plan-attestation at the project root.
|
||||
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
||||
# selected plan; root mode therefore retains the legacy project-root behavior.
|
||||
if ($Mode -ne "") {
|
||||
$PlanDirPwf = (Get-Location).Path
|
||||
$PlanDirPwf = $TargetDir
|
||||
|
||||
# (a) reset gate block counter, drop stale gate ledger.
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
||||
@@ -216,15 +423,96 @@ if ($Mode -ne "") {
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
||||
|
||||
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
||||
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
||||
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
||||
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
||||
# 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 $AttestPs1) -and (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 {
|
||||
& $AttestPs1 *> $null
|
||||
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) {
|
||||
$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 {
|
||||
$AttestationCommand = "attest-plan.sh"
|
||||
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
||||
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# CDPATH must not redirect the cd that locates the sibling scripts.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -184,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"
|
||||
@@ -201,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
|
||||
}
|
||||
|
||||
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
||||
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
||||
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. The
|
||||
# selector's check is constant time; --list would parse every plan.
|
||||
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
||||
exit 1
|
||||
fi
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
||||
# redirection would truncate a pre-existing hardlink and could overwrite a
|
||||
# different file that shares the same inode.
|
||||
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
||||
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
inherit_root_mode
|
||||
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
||||
echo ""
|
||||
@@ -401,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}"
|
||||
@@ -412,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
|
||||
|
||||
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
|
||||
one turn-marker slot and one progress-guard slot per plan.
|
||||
|
||||
Known, accepted differences from the shell reference:
|
||||
* Two plan directories with the same whole-second mtime, no .active_plan
|
||||
and no PLAN_ID: the reference picks the first in the shell's glob order
|
||||
(locale collation), this twin the first in code-point order. The same
|
||||
collation difference can change which three of four or more nested
|
||||
projects the ambiguity notice names. A machine runs one implementation,
|
||||
so neither choice flips within a session.
|
||||
* Shell glob order follows locale collation; this twin uses code-point
|
||||
order. This can change which three of four or more nested projects the
|
||||
ambiguity notice names.
|
||||
* BSD sed (macOS) appends a newline to a progress.md whose last line has
|
||||
none; GNU sed and this twin do not.
|
||||
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
|
||||
@@ -837,8 +834,21 @@ class Injector(object):
|
||||
scope = ""
|
||||
explicit = bool(plan_prefix)
|
||||
plan_id = env.get("PLAN_ID", "")
|
||||
ambiguous = plan_is_ambiguous(
|
||||
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
|
||||
)
|
||||
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
|
||||
"for this session; nothing injected."
|
||||
)
|
||||
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
|
||||
@@ -857,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"):
|
||||
@@ -873,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"):
|
||||
@@ -923,30 +935,14 @@ class Injector(object):
|
||||
"single-session mode."
|
||||
)
|
||||
raise Bail()
|
||||
if not plan_id:
|
||||
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
|
||||
try:
|
||||
names = sorted(os.listdir(plan_prefix + ".planning"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
|
||||
continue
|
||||
if not slug_is_valid(name):
|
||||
continue
|
||||
plan_n += 1
|
||||
if plan_n > 1:
|
||||
break
|
||||
if plan_n > 1:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
if ambiguous:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
|
||||
if not explicit:
|
||||
nested = []
|
||||
@@ -1326,6 +1322,28 @@ def inject(context, env=None):
|
||||
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
|
||||
"""A project pointer or mtime cannot bind a session to multiple plans."""
|
||||
if plan_id:
|
||||
return False
|
||||
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
|
||||
try:
|
||||
names = os.listdir(plan_root)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def resolve_plan_dir(env=None):
|
||||
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
|
||||
|
||||
@@ -1356,10 +1374,13 @@ 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 ("", "")
|
||||
|
||||
if plan_is_ambiguous(fs_root, pin if pin else "."):
|
||||
return ("", "")
|
||||
|
||||
active_file = fs_root + "/.active_plan"
|
||||
if is_file(active_file):
|
||||
try:
|
||||
@@ -1371,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):
|
||||
@@ -1387,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"):
|
||||
@@ -1441,6 +1464,8 @@ class ClaudeDispatcher(object):
|
||||
return (spelled, fs)
|
||||
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
|
||||
return ("", "")
|
||||
if plan_is_ambiguous(".planning", "."):
|
||||
return ("", "")
|
||||
if is_file("task_plan.md"):
|
||||
return (".", ".")
|
||||
return ("", "")
|
||||
|
||||
@@ -254,6 +254,30 @@ is_within_root() {
|
||||
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
|
||||
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
|
||||
# conflict check below.
|
||||
# Shared .active_plan and directory mtime cannot identify this session's plan.
|
||||
# Check before selection or preflight, even when isolation was never armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
EXPLICIT=0
|
||||
@@ -272,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
|
||||
@@ -282,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
|
||||
@@ -291,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)
|
||||
@@ -442,27 +469,13 @@ PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An attachment admits a session but does not select one of several plans.
|
||||
# When isolation is armed, require PLAN_ID if more than one live same-root
|
||||
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
|
||||
# within that root.
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
SESSION_PLAN_N=0
|
||||
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
|
||||
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
[ -f "$candidate" ] || continue
|
||||
candidate_dir="${candidate%/task_plan.md}"
|
||||
candidate_slug="${candidate_dir##*/}"
|
||||
slug_is_valid "$candidate_slug" || continue
|
||||
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
|
||||
[ "$SESSION_PLAN_N" -gt 1 ] && break
|
||||
done
|
||||
if [ "$SESSION_PLAN_N" -gt 1 ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
# Attachment admits a session but does not select its plan. Preserve the
|
||||
# attachment-first notice above for sessions that never opted in.
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
# junction/symlink escape; slug validation alone blocks textual traversal.
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
|
||||
[switch]$CheckAmbiguity
|
||||
)
|
||||
|
||||
$projectRoot = (Get-Location).Path
|
||||
@@ -76,6 +77,17 @@ function Get-FinalDirectoryPath {
|
||||
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
}
|
||||
|
||||
function Test-FullyQualifiedLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ($script:IsWindowsHost) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [System.IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
||||
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
||||
# project resolves the parent's plan and never sees the nested one;
|
||||
@@ -89,9 +101,8 @@ function Get-FinalDirectoryPath {
|
||||
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
||||
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
if ((Test-FullyQualifiedLocalPath $pin) -and
|
||||
(Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
$projectRoot = $pin
|
||||
$PlanRoot = Join-Path $pin ".planning"
|
||||
} else {
|
||||
@@ -128,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
|
||||
@@ -137,10 +166,37 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
|
||||
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
|
||||
# falsy here and still means "unset".
|
||||
# The optional probe distinguishes ambiguity from a legacy-root fallback.
|
||||
# Multiple named plans require PLAN_ID even without a sessions directory.
|
||||
$planCount = 0
|
||||
if (-not $env:PLAN_ID) {
|
||||
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
|
||||
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
|
||||
$planCount = 1
|
||||
}
|
||||
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++
|
||||
if ($planCount -gt 1) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($CheckAmbiguity) {
|
||||
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
|
||||
exit 0
|
||||
}
|
||||
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 $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
|
||||
}
|
||||
@@ -150,29 +206,37 @@ 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
|
||||
}
|
||||
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
||||
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
||||
# falls through like an invalid one instead of raising inside a caller.
|
||||
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId -and (Test-ValidSlug $planId)) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if ((Test-Path $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
||||
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
||||
# valid selection into an empty result.
|
||||
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 (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-WithinRoot $_.FullName } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Optional probe distinguishes ambiguity from the empty legacy-root result.
|
||||
# Both modes keep stdout data-only and always exit zero.
|
||||
CHECK_AMBIGUITY=0
|
||||
if [ "${1:-}" = "--check-ambiguity" ]; then
|
||||
CHECK_AMBIGUITY=1
|
||||
shift
|
||||
fi
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
@@ -239,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
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
@@ -321,6 +336,30 @@ resolve_latest_dir() {
|
||||
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
|
||||
# above already does it; a non-zero status would kill callers running under
|
||||
# set -e for a condition that is not an internal error.
|
||||
# A shared pointer or mtime is not a per-session binding (issue #240).
|
||||
# Count conservatively, just like injection: slug-valid live plan files.
|
||||
# The legacy root joins the count only when session isolation is armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$CHECK_AMBIGUITY" = "1" ]; then
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
|
||||
exit 0
|
||||
fi
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
|
||||
|
||||
if [ -n "${PLAN_ID:-}" ]; then
|
||||
resolve_from_env && exit 0
|
||||
exit 0
|
||||
|
||||
@@ -407,9 +407,6 @@ def get_session_candidates(
|
||||
return 'claude', []
|
||||
|
||||
|
||||
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
||||
|
||||
|
||||
def get_opencode_db_path() -> Optional[Path]:
|
||||
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
||||
xdg = os.environ.get('XDG_DATA_HOME')
|
||||
@@ -471,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', '')
|
||||
@@ -489,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
|
||||
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
||||
update_time = None
|
||||
update_idx = -1
|
||||
for idx, (sid, _) in enumerate(previous_sessions):
|
||||
params = (sid,) + PLANNING_LIKE_SQL
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_created FROM part
|
||||
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 (
|
||||
json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
IN ('task_plan.md', 'findings.md', 'progress.md')
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/task_plan.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/findings.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/progress.md'
|
||||
)
|
||||
ORDER BY time_created DESC
|
||||
LIMIT 1
|
||||
ORDER BY time_created DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
update_sid = sid
|
||||
update_time = row[0]
|
||||
update_idx = idx
|
||||
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
||||
# would materialize every planning write of the session before the
|
||||
# first validated row ends the loop.
|
||||
for candidate_time, data_str in cur:
|
||||
data = json_loads(data_str)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
state = data.get('state')
|
||||
input_ = state.get('input') if isinstance(state, dict) else None
|
||||
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
||||
if planning_file_from_path(file_path):
|
||||
update_sid = sid
|
||||
update_time = candidate_time
|
||||
update_idx = idx
|
||||
break
|
||||
if update_sid:
|
||||
break
|
||||
|
||||
if not update_sid:
|
||||
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
||||
"""Return a planning filename only when it is the path's exact basename.
|
||||
|
||||
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
||||
planning updates and can anchor catchup at unrelated transcript content.
|
||||
Normalize separators so the same boundary rule works for Unix and Windows
|
||||
session records.
|
||||
"""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
for pf in PLANNING_FILES:
|
||||
if path_value.endswith(pf):
|
||||
return pf
|
||||
return None
|
||||
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
||||
return basename if basename in PLANNING_FILES else None
|
||||
|
||||
|
||||
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
||||
|
||||
Regular → Executable
+386
-16
@@ -1,27 +1,324 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 - print the current active plan (if any)
|
||||
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
||||
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
||||
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
||||
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanId = "",
|
||||
[Alias('l', '-list')]
|
||||
[switch]$List,
|
||||
[Alias('verify-root')]
|
||||
[switch]$VerifyRoot,
|
||||
[Alias('h', '-help')]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
# Windows PowerShell 5.1 can silently change directories when -File inherits
|
||||
# a cwd containing wildcard characters. Recover that physical cwd only for a
|
||||
# direct invocation of this script; interactive Set-Location remains primary.
|
||||
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)) {
|
||||
$ProjectRoot = [Environment]::CurrentDirectory
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$PlanRoot = Join-Path $ProjectRoot ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
|
||||
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
|
||||
# open the actual filesystem object before checking the project boundary.
|
||||
# The Windows handle supports both directories and task_plan.md files.
|
||||
function Get-FinalPath {
|
||||
param([string]$Path)
|
||||
if (-not ("PwfPlanListingNative" -as [type])) {
|
||||
Add-Type -ErrorAction Stop -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public static class PwfPlanListingNative {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string name, uint access, uint share, IntPtr security,
|
||||
uint creation, uint flags, IntPtr template);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern IntPtr realpath(string path, IntPtr resolved);
|
||||
[DllImport("libc")]
|
||||
private static extern void free(IntPtr pointer);
|
||||
|
||||
public static string FinalPath(string path) {
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
|
||||
IntPtr resolved = realpath(path, IntPtr.Zero);
|
||||
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try { return Marshal.PtrToStringAnsi(resolved); }
|
||||
finally { free(resolved); }
|
||||
}
|
||||
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
|
||||
using (SafeFileHandle handle = CreateFileW(
|
||||
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder buffer = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
string result = buffer.ToString();
|
||||
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + result.Substring(8);
|
||||
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
||||
return result.Substring(4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
return [PwfPlanListingNative]::FinalPath($Path)
|
||||
}
|
||||
|
||||
function Test-WithinRoot {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
|
||||
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
|
||||
$comparison = [StringComparison]::Ordinal
|
||||
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
|
||||
return $pathReal.Equals($rootReal, $comparison) -or
|
||||
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ValidSlug {
|
||||
param([string]$Name)
|
||||
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
|
||||
}
|
||||
|
||||
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. 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 -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 {
|
||||
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
|
||||
if (Test-ValidSlug $current) { return $current }
|
||||
} catch { }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Add-PhaseStatus {
|
||||
param([hashtable]$Counts, $Phase)
|
||||
if ($null -eq $Phase) { return }
|
||||
$status = $Phase.Inline
|
||||
if ($Phase.Primary) { $status = $Phase.Primary }
|
||||
if ($status) { $Counts[$status]++ }
|
||||
}
|
||||
|
||||
function Get-PhaseStatus {
|
||||
param([string]$PlanFile)
|
||||
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
|
||||
$phase = $null
|
||||
$fence = ""
|
||||
$fenceLength = 0
|
||||
# The shipped translations keep status identifiers in English. Unicode
|
||||
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
|
||||
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
|
||||
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
|
||||
# Read once; an empty file is a valid plan with zero phases.
|
||||
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
|
||||
foreach ($line in ($content -split '\r?\n')) {
|
||||
if ($fence) {
|
||||
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
|
||||
if ($line -match $closing) { $fence = "" }
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
|
||||
$fence = $Matches[1].Substring(0, 1)
|
||||
$fenceLength = $Matches[1].Length
|
||||
continue
|
||||
}
|
||||
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = @{ Primary = ""; Inline = "" }
|
||||
$counts.total++
|
||||
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
|
||||
$phase.Inline = $Matches[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = $null
|
||||
continue
|
||||
}
|
||||
if ($null -ne $phase -and -not $phase.Primary -and
|
||||
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
|
||||
$phase.Primary = $Matches[1]
|
||||
}
|
||||
}
|
||||
Add-PhaseStatus $counts $phase
|
||||
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
|
||||
}
|
||||
|
||||
function Show-PlanList {
|
||||
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
|
||||
Write-Output "No planning directory found."
|
||||
return
|
||||
}
|
||||
if (-not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$active = Get-CurrentActivePlan
|
||||
Write-Output "Available plans:"
|
||||
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 }
|
||||
try { $status = Get-PhaseStatus $planFile } catch { continue }
|
||||
$marker = ""
|
||||
if ($plan.Name -ceq $active) { $marker = " [active]" }
|
||||
Write-Output "- $($plan.Name)$marker - $status"
|
||||
$found = $true
|
||||
}
|
||||
if (-not $found) { Write-Output "No named plans found." }
|
||||
}
|
||||
|
||||
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
||||
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
if ($VerifyRoot) {
|
||||
if ($List -or $PlanId) {
|
||||
Write-Error "Error: verify the planning root in a separate call."
|
||||
exit 1
|
||||
}
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
|
||||
if ($List -and $PlanId) {
|
||||
Write-Error "Error: list plans or set PLAN_ID in separate calls."
|
||||
exit 1
|
||||
}
|
||||
Show-PlanList
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$current = ""
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
|
||||
if ($current) {
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
@@ -29,21 +326,94 @@ if ($PlanId -eq "") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-ValidSlug $PlanId)) {
|
||||
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
|
||||
exit 1
|
||||
}
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
$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
|
||||
}
|
||||
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($activeItem -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
# Replace the directory entry instead of truncating an existing inode: a
|
||||
# hardlinked pointer must not overwrite another file. The exclusive temporary
|
||||
# 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
|
||||
try {
|
||||
$bytes = $utf8NoBom.GetBytes($PlanId)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
} finally { $stream.Dispose() }
|
||||
$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 {
|
||||
$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 ""
|
||||
|
||||
Regular → Executable
+358
-38
@@ -1,50 +1,370 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
# List saved named plans, show the shared pointer, or change that pointer.
|
||||
# Usage: set-active-plan.sh [--list|-l|--verify-root|PLAN_ID]
|
||||
# Operates on the current project; listing never binds a host or injects data.
|
||||
set -eu
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
PWF_ROOT_PIN=""
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
# Use the resolver canonicalization policy without running plan selection.
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Return true when a candidate path names the Microsoft Store WindowsApps
|
||||
# directory. Store app aliases are not stable interpreter binaries and may
|
||||
# present as executable while refusing script execution. Matching is
|
||||
# case-insensitive and works before or after Windows slash normalization.
|
||||
is_windowsapps_path() {
|
||||
norm_slashes "$1"
|
||||
case "${NORM_OUT}" in
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Select only an interpreter path the caller explicitly trusted.
|
||||
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
||||
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
||||
# repositories that control PATH. Windows-native absolute paths are converted
|
||||
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
||||
trusted_python() {
|
||||
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
||||
[ -n "${_tp_candidate}" ] || continue
|
||||
case "${_tp_candidate}" in
|
||||
\\\\*|//*) continue ;;
|
||||
[A-Za-z]:[\\/]*)
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
_tp_cygpath="/usr/bin/cygpath.exe"
|
||||
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
||||
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
||||
|| continue
|
||||
;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
[ -f "${_tp_candidate}" ] || continue
|
||||
[ -x "${_tp_candidate}" ] || continue
|
||||
printf "%s\n" "${_tp_candidate}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
||||
# Prints the canonical absolute path on success; prints nothing and returns 1
|
||||
# on a full miss so containment fails closed. No Python spawn on the happy
|
||||
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
||||
canonicalize() {
|
||||
target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
_canonical_python="$(trusted_python)" || _canonical_python=""
|
||||
if [ -n "${_canonical_python}" ]; then
|
||||
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
||||
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
#
|
||||
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
||||
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
||||
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
||||
# relative "." root — byte-identical to the legacy check.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
# Pinned resolution skips the rewrite: candidate and root then share the
|
||||
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
||||
if [ -n "${PWF_ROOT_PIN}" ]; then
|
||||
check_target="${candidate}"
|
||||
else
|
||||
echo "No active plan set."
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
fi
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Each phase contributes at most one status. An explicit Status line wins
|
||||
# over an inline heading marker. Fenced examples and unrelated sections are
|
||||
# not phases. Keep this parser aligned with the PowerShell helper.
|
||||
phase_status() {
|
||||
awk '
|
||||
function finish() {
|
||||
if (phase) {
|
||||
status = primary != "" ? primary : inline_status
|
||||
if (status == "complete") complete++
|
||||
else if (status == "in_progress") in_progress++
|
||||
else if (status == "pending") pending++
|
||||
}
|
||||
phase = 0; primary = ""; inline_status = ""
|
||||
}
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
trimmed = line
|
||||
sub(/^ */, "", trimmed)
|
||||
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
|
||||
marker = substr(trimmed, 1, 1)
|
||||
run = 0
|
||||
while (substr(trimmed, run + 1, 1) == marker) run++
|
||||
if (fence == "") { fence = marker; fence_length = run }
|
||||
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
|
||||
next
|
||||
}
|
||||
if (fence != "") next
|
||||
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
|
||||
finish(); phase = 1; total++
|
||||
if (match(line, /\[(complete|in_progress|pending)\]/))
|
||||
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
|
||||
finish()
|
||||
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
|
||||
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
|
||||
sub(/[ \t].*$/, "", line)
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
END {
|
||||
finish()
|
||||
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
|
||||
}
|
||||
' < "$1"
|
||||
}
|
||||
|
||||
# A pre-existing pointer must be a contained regular file before it is
|
||||
# replaced: a link would be followed or its shared inode overwritten.
|
||||
pointer_is_unsafe() {
|
||||
{ [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
|
||||
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
verify_root() {
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
current_active() {
|
||||
# An unreadable pointer is treated as unset; under set -e the read
|
||||
# would otherwise abort listing.
|
||||
if [ -f "${ACTIVE_FILE}" ] && [ -r "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
|
||||
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
|
||||
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
|
||||
# Treat it as an encoding marker, not part of the shared plan slug.
|
||||
_utf8_bom="$(printf '\357\273\277')"
|
||||
case "${_current}" in
|
||||
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
list_plans() {
|
||||
if [ ! -d "${PLAN_ROOT}" ]; then
|
||||
printf '%s\n' 'No planning directory found.'
|
||||
return 0
|
||||
fi
|
||||
if ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
_active="$(current_active)"
|
||||
_found=0
|
||||
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
|
||||
_plan_file="${_dir}/task_plan.md"
|
||||
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
|
||||
is_within_root "${_plan_file}" || continue
|
||||
_status="$(phase_status "${_plan_file}")" || continue
|
||||
_marker=''
|
||||
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
|
||||
printf '%s\n' "- ${_id}${_marker} - ${_status}"
|
||||
_found=1
|
||||
done
|
||||
if [ "${_found}" -eq 0 ]; then
|
||||
printf '%s\n' 'No named plans found.'
|
||||
else
|
||||
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
printf '%s\n' 'Error: list plans, verify the root, or set PLAN_ID in separate calls.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--list|-l) list_plans; exit $? ;;
|
||||
--verify-root) verify_root; exit $? ;;
|
||||
--help|-h)
|
||||
printf '%s\n' 'Usage: set-active-plan.sh [--list|--verify-root|PLAN_ID]' \
|
||||
'Lists saved named plans in the current project without selecting a plan.' \
|
||||
'--verify-root checks the planning root and pointer without listing or selecting.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = '' ]; then
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
exit 1
|
||||
fi
|
||||
plan_id="$(current_active)"
|
||||
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)"
|
||||
else
|
||||
printf '%s\n' 'No active plan set.'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
if ! slug_is_valid "${PLAN_ID}"; then
|
||||
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
|
||||
"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
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
exit 1
|
||||
fi
|
||||
# Replace the pointer atomically instead of truncating a possible hardlink.
|
||||
# mktemp creates a private, exclusive file beside the destination.
|
||||
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
|
||||
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "${temp_file}"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
|
||||
# mktemp creates the file 0600; the shared pointer must stay readable by
|
||||
# every session, so apply the caller's umask instead (=rw without a who
|
||||
# clause is umask-relative in POSIX chmod).
|
||||
chmod =rw "${temp_file}" 2>/dev/null || true
|
||||
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
|
||||
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
|
||||
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -181,32 +181,36 @@ Helper scripts for automation:
|
||||
|
||||
- `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).
|
||||
- `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.
|
||||
- `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.
|
||||
- `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`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
|
||||
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
|
||||
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history. OpenCode uses its read-only SQLite store.
|
||||
- `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.
|
||||
|
||||
### List saved plans
|
||||
|
||||
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
|
||||
|
||||
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
|
||||
|
||||
### Parallel task workflow
|
||||
|
||||
When working on multiple tasks in the same repo simultaneously:
|
||||
For concurrent tasks, initialize a named plan and pin each host before starting it. Set `SKILL_DIR` to the installed skill directory in each terminal and keep your current directory at the project root:
|
||||
|
||||
```bash
|
||||
# Start task A
|
||||
.init-session.sh "Backend Refactor"
|
||||
# → .planning/2026-01-10-backend-refactor/task_plan.md
|
||||
# Terminal A: use the exact PLAN_ID printed by initialization.
|
||||
sh "$SKILL_DIR/scripts/init-session.sh" "Backend Refactor"
|
||||
export PLAN_ID=2026-09-13-backend-refactor
|
||||
# Start the first agent from this terminal after setting PLAN_ID.
|
||||
|
||||
# Start task B in a second terminal
|
||||
.init-session.sh "Incident Investigation"
|
||||
# → .planning/2026-01-10-incident-investigation/task_plan.md
|
||||
|
||||
# Switch active plan
|
||||
.set-active-plan.sh 2026-01-10-backend-refactor
|
||||
|
||||
# Or pin a terminal to a specific plan
|
||||
export PLAN_ID=2026-01-10-backend-refactor
|
||||
# Terminal B: use the different PLAN_ID printed for this task.
|
||||
sh "$SKILL_DIR/scripts/init-session.sh" "Incident Investigation"
|
||||
export PLAN_ID=2026-09-13-incident-investigation
|
||||
# Start the second agent from this terminal after setting PLAN_ID.
|
||||
```
|
||||
|
||||
Each session reads from its own isolated plan directory. Hooks resolve the correct plan automatically.
|
||||
The IDs are examples; use the IDs printed by your initialization commands. In PowerShell, set `$env:PLAN_ID` before starting the host. Setting it inside an already-running agent's tool subprocess does not change the parent host's environment. Use separate worktrees if the host cannot be pinned per task.
|
||||
|
||||
Use `set-active-plan` for sequential switching of the shared default pointer. Concurrent sessions need their own `PLAN_ID` even when the listing shows `[active]`.
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
|
||||
@@ -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.");
|
||||
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
|
||||
$securityRootPath = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
# Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
|
||||
# local path is the only accepted shape, as in resolve-plan-dir.ps1.
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
|
||||
if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
|
||||
throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
|
||||
}
|
||||
$securityRootPath = $pin
|
||||
@@ -404,6 +412,7 @@ function Resolve-PlanFile {
|
||||
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
||||
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
||||
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
||||
if ($resolvedDir) {
|
||||
$planFile = Join-Path $resolvedDir "task_plan.md"
|
||||
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
||||
|
||||
@@ -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,9 +45,46 @@ 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)"
|
||||
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
||||
printf "%s\n" "${plan_dir}/task_plan.md"
|
||||
@@ -76,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
|
||||
@@ -97,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)" || {
|
||||
@@ -113,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
|
||||
@@ -152,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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall -> allow stop)
|
||||
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
||||
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
||||
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
||||
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
||||
#
|
||||
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
||||
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
||||
@@ -35,6 +36,7 @@ if ($PlanFile -ne "") {
|
||||
try {
|
||||
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
||||
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
||||
} catch {
|
||||
$resolvedDir = ""
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -84,9 +88,10 @@ if ($TOTAL -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# advisory_report: the v2.43 status echo.
|
||||
# Keep explicit reports, but omit routine success from automatic gate checks.
|
||||
function Write-AdvisoryReport {
|
||||
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
||||
if ($Gate) { return }
|
||||
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
||||
} else {
|
||||
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall → allow stop)
|
||||
# When all hold, it emits a single-line block-decision JSON on stdout and
|
||||
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
||||
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
||||
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
||||
# plans stay silent in --gate mode, including legacy plans without .mode.
|
||||
# Without --gate, the explicit advisory report is unchanged.
|
||||
#
|
||||
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
||||
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
||||
@@ -56,6 +57,9 @@ else
|
||||
RESOLVED_DIR=""
|
||||
if [ -f "${RESOLVER}" ]; then
|
||||
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
||||
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
||||
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
||||
@@ -75,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
|
||||
@@ -116,9 +123,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
||||
# Explicit status reports retain completion text. Automatic gate checks have
|
||||
# nothing to report on success; keep evaluating all gate guards before here.
|
||||
advisory_report() {
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
[ "$GATE" -eq 1 ] && return 0
|
||||
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
||||
else
|
||||
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
||||
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
||||
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
||||
# Templates: default, analytics
|
||||
@@ -12,10 +13,32 @@
|
||||
param(
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default",
|
||||
[switch]$PlanDir,
|
||||
[switch]$Autonomous,
|
||||
[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.
|
||||
@@ -38,7 +61,65 @@ function Get-Nonce {
|
||||
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
function Get-PlanSlug([string]$Name) {
|
||||
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
||||
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
||||
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
||||
$slug = $slug -creplace '-{2,}', '-'
|
||||
$slug = $slug.Trim('-')
|
||||
if ($slug.Length -gt 40) {
|
||||
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
||||
}
|
||||
# The resolvers and the selector only accept ASCII plan ids.
|
||||
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
||||
return ""
|
||||
}
|
||||
return $slug
|
||||
}
|
||||
|
||||
function Get-ShortId {
|
||||
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
||||
}
|
||||
|
||||
function Get-InheritedMode([string]$CurrentMode) {
|
||||
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
||||
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
||||
return $CurrentMode
|
||||
}
|
||||
if ($CurrentMode -eq "gated") {
|
||||
return $CurrentMode
|
||||
}
|
||||
|
||||
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
||||
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
||||
if ($RootMode -cmatch 'gate') {
|
||||
return "gated"
|
||||
}
|
||||
if ($RootMode -cmatch 'autonomous') {
|
||||
return "autonomous"
|
||||
}
|
||||
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") {
|
||||
@@ -46,11 +127,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
||||
# mode. A positional project name or -PlanDir creates an isolated
|
||||
# .planning/<date>-<slug>/ plan.
|
||||
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
||||
$UsePlanDir = $PlanDir -or $NamedPlan
|
||||
if ($UsePlanDir) {
|
||||
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
||||
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
||||
# shared pointer, so a named plan cannot be created without it.
|
||||
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
||||
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
||||
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. A
|
||||
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
||||
# it first and treat a thrown error as a failure too.
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $PlanSelector -VerifyRoot *> $null
|
||||
} catch {
|
||||
$global:LASTEXITCODE = 1
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NamedPlan) {
|
||||
$Slug = Get-PlanSlug $ProjectName
|
||||
} else {
|
||||
$Slug = ""
|
||||
$ProjectName = "untitled"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Slug)) {
|
||||
$Slug = "untitled-$(Get-ShortId)"
|
||||
}
|
||||
|
||||
$BaseId = "$DATE-$Slug"
|
||||
$PlanId = $BaseId
|
||||
$Counter = 2
|
||||
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
||||
$PlanId = "$BaseId-$Counter"
|
||||
$Counter++
|
||||
}
|
||||
$TargetDir = Join-Path $PlanningRoot $PlanId
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
||||
$Mode = Get-InheritedMode $Mode
|
||||
} else {
|
||||
$TargetDir = (Get-Location).Path
|
||||
}
|
||||
|
||||
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
||||
$FindingsPath = Join-Path $TargetDir "findings.md"
|
||||
$ProgressPath = Join-Path $TargetDir "progress.md"
|
||||
if ($UsePlanDir) {
|
||||
$TaskPlanDisplay = $TaskPlanPath
|
||||
$FindingsDisplay = $FindingsPath
|
||||
$ProgressDisplay = $ProgressPath
|
||||
} else {
|
||||
$TaskPlanDisplay = "task_plan.md"
|
||||
$FindingsDisplay = "findings.md"
|
||||
$ProgressDisplay = "progress.md"
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "PLAN_ID=$PlanId"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
@@ -99,18 +255,18 @@ Phase 1
|
||||
## Errors Encountered
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
Write-Host "Created $TaskPlanDisplay"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
Write-Host "$TaskPlanDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
@@ -131,15 +287,15 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
Write-Host "Created $FindingsDisplay"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
Write-Host "$FindingsDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -160,7 +316,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -181,23 +337,74 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
Write-Host "Created $ProgressDisplay"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
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!"
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
||||
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
||||
Write-Host " `$env:PLAN_ID='$PlanId'"
|
||||
} else {
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
}
|
||||
|
||||
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
||||
# the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
|
||||
# dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
|
||||
# .plan-attestation at the project root.
|
||||
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
||||
# selected plan; root mode therefore retains the legacy project-root behavior.
|
||||
if ($Mode -ne "") {
|
||||
$PlanDirPwf = (Get-Location).Path
|
||||
$PlanDirPwf = $TargetDir
|
||||
|
||||
# (a) reset gate block counter, drop stale gate ledger.
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
||||
@@ -216,15 +423,96 @@ if ($Mode -ne "") {
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
||||
|
||||
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
||||
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
||||
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
||||
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
||||
# 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 $AttestPs1) -and (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 {
|
||||
& $AttestPs1 *> $null
|
||||
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) {
|
||||
$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 {
|
||||
$AttestationCommand = "attest-plan.sh"
|
||||
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
||||
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# CDPATH must not redirect the cd that locates the sibling scripts.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -184,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"
|
||||
@@ -201,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
|
||||
}
|
||||
|
||||
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
||||
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
||||
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. The
|
||||
# selector's check is constant time; --list would parse every plan.
|
||||
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
||||
exit 1
|
||||
fi
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
||||
# redirection would truncate a pre-existing hardlink and could overwrite a
|
||||
# different file that shares the same inode.
|
||||
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
||||
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
inherit_root_mode
|
||||
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
||||
echo ""
|
||||
@@ -401,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}"
|
||||
@@ -412,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
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
# junction/symlink escape; slug validation alone blocks textual traversal.
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
|
||||
[switch]$CheckAmbiguity
|
||||
)
|
||||
|
||||
$projectRoot = (Get-Location).Path
|
||||
@@ -76,6 +77,17 @@ function Get-FinalDirectoryPath {
|
||||
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
}
|
||||
|
||||
function Test-FullyQualifiedLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ($script:IsWindowsHost) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [System.IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
||||
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
||||
# project resolves the parent's plan and never sees the nested one;
|
||||
@@ -89,9 +101,8 @@ function Get-FinalDirectoryPath {
|
||||
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
||||
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
if ((Test-FullyQualifiedLocalPath $pin) -and
|
||||
(Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
$projectRoot = $pin
|
||||
$PlanRoot = Join-Path $pin ".planning"
|
||||
} else {
|
||||
@@ -128,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
|
||||
@@ -137,10 +166,37 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
|
||||
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
|
||||
# falsy here and still means "unset".
|
||||
# The optional probe distinguishes ambiguity from a legacy-root fallback.
|
||||
# Multiple named plans require PLAN_ID even without a sessions directory.
|
||||
$planCount = 0
|
||||
if (-not $env:PLAN_ID) {
|
||||
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
|
||||
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
|
||||
$planCount = 1
|
||||
}
|
||||
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++
|
||||
if ($planCount -gt 1) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($CheckAmbiguity) {
|
||||
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
|
||||
exit 0
|
||||
}
|
||||
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 $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
|
||||
}
|
||||
@@ -150,29 +206,37 @@ 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
|
||||
}
|
||||
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
||||
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
||||
# falls through like an invalid one instead of raising inside a caller.
|
||||
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId -and (Test-ValidSlug $planId)) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if ((Test-Path $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
||||
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
||||
# valid selection into an empty result.
|
||||
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 (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-WithinRoot $_.FullName } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Optional probe distinguishes ambiguity from the empty legacy-root result.
|
||||
# Both modes keep stdout data-only and always exit zero.
|
||||
CHECK_AMBIGUITY=0
|
||||
if [ "${1:-}" = "--check-ambiguity" ]; then
|
||||
CHECK_AMBIGUITY=1
|
||||
shift
|
||||
fi
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
@@ -239,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
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
@@ -321,6 +336,30 @@ resolve_latest_dir() {
|
||||
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
|
||||
# above already does it; a non-zero status would kill callers running under
|
||||
# set -e for a condition that is not an internal error.
|
||||
# A shared pointer or mtime is not a per-session binding (issue #240).
|
||||
# Count conservatively, just like injection: slug-valid live plan files.
|
||||
# The legacy root joins the count only when session isolation is armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$CHECK_AMBIGUITY" = "1" ]; then
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
|
||||
exit 0
|
||||
fi
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
|
||||
|
||||
if [ -n "${PLAN_ID:-}" ]; then
|
||||
resolve_from_env && exit 0
|
||||
exit 0
|
||||
|
||||
@@ -407,9 +407,6 @@ def get_session_candidates(
|
||||
return 'claude', []
|
||||
|
||||
|
||||
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
||||
|
||||
|
||||
def get_opencode_db_path() -> Optional[Path]:
|
||||
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
||||
xdg = os.environ.get('XDG_DATA_HOME')
|
||||
@@ -471,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', '')
|
||||
@@ -489,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
|
||||
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
||||
update_time = None
|
||||
update_idx = -1
|
||||
for idx, (sid, _) in enumerate(previous_sessions):
|
||||
params = (sid,) + PLANNING_LIKE_SQL
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_created FROM part
|
||||
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 (
|
||||
json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
IN ('task_plan.md', 'findings.md', 'progress.md')
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/task_plan.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/findings.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/progress.md'
|
||||
)
|
||||
ORDER BY time_created DESC
|
||||
LIMIT 1
|
||||
ORDER BY time_created DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
update_sid = sid
|
||||
update_time = row[0]
|
||||
update_idx = idx
|
||||
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
||||
# would materialize every planning write of the session before the
|
||||
# first validated row ends the loop.
|
||||
for candidate_time, data_str in cur:
|
||||
data = json_loads(data_str)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
state = data.get('state')
|
||||
input_ = state.get('input') if isinstance(state, dict) else None
|
||||
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
||||
if planning_file_from_path(file_path):
|
||||
update_sid = sid
|
||||
update_time = candidate_time
|
||||
update_idx = idx
|
||||
break
|
||||
if update_sid:
|
||||
break
|
||||
|
||||
if not update_sid:
|
||||
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
||||
"""Return a planning filename only when it is the path's exact basename.
|
||||
|
||||
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
||||
planning updates and can anchor catchup at unrelated transcript content.
|
||||
Normalize separators so the same boundary rule works for Unix and Windows
|
||||
session records.
|
||||
"""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
for pf in PLANNING_FILES:
|
||||
if path_value.endswith(pf):
|
||||
return pf
|
||||
return None
|
||||
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
||||
return basename if basename in PLANNING_FILES else None
|
||||
|
||||
|
||||
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
||||
|
||||
Regular → Executable
+386
-16
@@ -1,27 +1,324 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 - print the current active plan (if any)
|
||||
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
||||
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
||||
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
||||
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanId = "",
|
||||
[Alias('l', '-list')]
|
||||
[switch]$List,
|
||||
[Alias('verify-root')]
|
||||
[switch]$VerifyRoot,
|
||||
[Alias('h', '-help')]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
# Windows PowerShell 5.1 can silently change directories when -File inherits
|
||||
# a cwd containing wildcard characters. Recover that physical cwd only for a
|
||||
# direct invocation of this script; interactive Set-Location remains primary.
|
||||
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)) {
|
||||
$ProjectRoot = [Environment]::CurrentDirectory
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$PlanRoot = Join-Path $ProjectRoot ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
|
||||
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
|
||||
# open the actual filesystem object before checking the project boundary.
|
||||
# The Windows handle supports both directories and task_plan.md files.
|
||||
function Get-FinalPath {
|
||||
param([string]$Path)
|
||||
if (-not ("PwfPlanListingNative" -as [type])) {
|
||||
Add-Type -ErrorAction Stop -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public static class PwfPlanListingNative {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string name, uint access, uint share, IntPtr security,
|
||||
uint creation, uint flags, IntPtr template);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern IntPtr realpath(string path, IntPtr resolved);
|
||||
[DllImport("libc")]
|
||||
private static extern void free(IntPtr pointer);
|
||||
|
||||
public static string FinalPath(string path) {
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
|
||||
IntPtr resolved = realpath(path, IntPtr.Zero);
|
||||
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try { return Marshal.PtrToStringAnsi(resolved); }
|
||||
finally { free(resolved); }
|
||||
}
|
||||
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
|
||||
using (SafeFileHandle handle = CreateFileW(
|
||||
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder buffer = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
string result = buffer.ToString();
|
||||
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + result.Substring(8);
|
||||
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
||||
return result.Substring(4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
return [PwfPlanListingNative]::FinalPath($Path)
|
||||
}
|
||||
|
||||
function Test-WithinRoot {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
|
||||
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
|
||||
$comparison = [StringComparison]::Ordinal
|
||||
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
|
||||
return $pathReal.Equals($rootReal, $comparison) -or
|
||||
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ValidSlug {
|
||||
param([string]$Name)
|
||||
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
|
||||
}
|
||||
|
||||
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. 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 -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 {
|
||||
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
|
||||
if (Test-ValidSlug $current) { return $current }
|
||||
} catch { }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Add-PhaseStatus {
|
||||
param([hashtable]$Counts, $Phase)
|
||||
if ($null -eq $Phase) { return }
|
||||
$status = $Phase.Inline
|
||||
if ($Phase.Primary) { $status = $Phase.Primary }
|
||||
if ($status) { $Counts[$status]++ }
|
||||
}
|
||||
|
||||
function Get-PhaseStatus {
|
||||
param([string]$PlanFile)
|
||||
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
|
||||
$phase = $null
|
||||
$fence = ""
|
||||
$fenceLength = 0
|
||||
# The shipped translations keep status identifiers in English. Unicode
|
||||
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
|
||||
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
|
||||
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
|
||||
# Read once; an empty file is a valid plan with zero phases.
|
||||
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
|
||||
foreach ($line in ($content -split '\r?\n')) {
|
||||
if ($fence) {
|
||||
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
|
||||
if ($line -match $closing) { $fence = "" }
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
|
||||
$fence = $Matches[1].Substring(0, 1)
|
||||
$fenceLength = $Matches[1].Length
|
||||
continue
|
||||
}
|
||||
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = @{ Primary = ""; Inline = "" }
|
||||
$counts.total++
|
||||
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
|
||||
$phase.Inline = $Matches[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = $null
|
||||
continue
|
||||
}
|
||||
if ($null -ne $phase -and -not $phase.Primary -and
|
||||
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
|
||||
$phase.Primary = $Matches[1]
|
||||
}
|
||||
}
|
||||
Add-PhaseStatus $counts $phase
|
||||
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
|
||||
}
|
||||
|
||||
function Show-PlanList {
|
||||
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
|
||||
Write-Output "No planning directory found."
|
||||
return
|
||||
}
|
||||
if (-not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$active = Get-CurrentActivePlan
|
||||
Write-Output "Available plans:"
|
||||
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 }
|
||||
try { $status = Get-PhaseStatus $planFile } catch { continue }
|
||||
$marker = ""
|
||||
if ($plan.Name -ceq $active) { $marker = " [active]" }
|
||||
Write-Output "- $($plan.Name)$marker - $status"
|
||||
$found = $true
|
||||
}
|
||||
if (-not $found) { Write-Output "No named plans found." }
|
||||
}
|
||||
|
||||
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
||||
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
if ($VerifyRoot) {
|
||||
if ($List -or $PlanId) {
|
||||
Write-Error "Error: verify the planning root in a separate call."
|
||||
exit 1
|
||||
}
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
|
||||
if ($List -and $PlanId) {
|
||||
Write-Error "Error: list plans or set PLAN_ID in separate calls."
|
||||
exit 1
|
||||
}
|
||||
Show-PlanList
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$current = ""
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
|
||||
if ($current) {
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
@@ -29,21 +326,94 @@ if ($PlanId -eq "") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-ValidSlug $PlanId)) {
|
||||
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
|
||||
exit 1
|
||||
}
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
$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
|
||||
}
|
||||
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($activeItem -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
# Replace the directory entry instead of truncating an existing inode: a
|
||||
# hardlinked pointer must not overwrite another file. The exclusive temporary
|
||||
# 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
|
||||
try {
|
||||
$bytes = $utf8NoBom.GetBytes($PlanId)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
} finally { $stream.Dispose() }
|
||||
$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 {
|
||||
$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 ""
|
||||
|
||||
Regular → Executable
+358
-38
@@ -1,50 +1,370 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
# List saved named plans, show the shared pointer, or change that pointer.
|
||||
# Usage: set-active-plan.sh [--list|-l|--verify-root|PLAN_ID]
|
||||
# Operates on the current project; listing never binds a host or injects data.
|
||||
set -eu
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
PWF_ROOT_PIN=""
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
# Use the resolver canonicalization policy without running plan selection.
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Return true when a candidate path names the Microsoft Store WindowsApps
|
||||
# directory. Store app aliases are not stable interpreter binaries and may
|
||||
# present as executable while refusing script execution. Matching is
|
||||
# case-insensitive and works before or after Windows slash normalization.
|
||||
is_windowsapps_path() {
|
||||
norm_slashes "$1"
|
||||
case "${NORM_OUT}" in
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Select only an interpreter path the caller explicitly trusted.
|
||||
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
||||
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
||||
# repositories that control PATH. Windows-native absolute paths are converted
|
||||
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
||||
trusted_python() {
|
||||
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
||||
[ -n "${_tp_candidate}" ] || continue
|
||||
case "${_tp_candidate}" in
|
||||
\\\\*|//*) continue ;;
|
||||
[A-Za-z]:[\\/]*)
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
_tp_cygpath="/usr/bin/cygpath.exe"
|
||||
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
||||
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
||||
|| continue
|
||||
;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
[ -f "${_tp_candidate}" ] || continue
|
||||
[ -x "${_tp_candidate}" ] || continue
|
||||
printf "%s\n" "${_tp_candidate}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
||||
# Prints the canonical absolute path on success; prints nothing and returns 1
|
||||
# on a full miss so containment fails closed. No Python spawn on the happy
|
||||
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
||||
canonicalize() {
|
||||
target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
_canonical_python="$(trusted_python)" || _canonical_python=""
|
||||
if [ -n "${_canonical_python}" ]; then
|
||||
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
||||
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
#
|
||||
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
||||
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
||||
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
||||
# relative "." root — byte-identical to the legacy check.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
# Pinned resolution skips the rewrite: candidate and root then share the
|
||||
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
||||
if [ -n "${PWF_ROOT_PIN}" ]; then
|
||||
check_target="${candidate}"
|
||||
else
|
||||
echo "No active plan set."
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
fi
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Each phase contributes at most one status. An explicit Status line wins
|
||||
# over an inline heading marker. Fenced examples and unrelated sections are
|
||||
# not phases. Keep this parser aligned with the PowerShell helper.
|
||||
phase_status() {
|
||||
awk '
|
||||
function finish() {
|
||||
if (phase) {
|
||||
status = primary != "" ? primary : inline_status
|
||||
if (status == "complete") complete++
|
||||
else if (status == "in_progress") in_progress++
|
||||
else if (status == "pending") pending++
|
||||
}
|
||||
phase = 0; primary = ""; inline_status = ""
|
||||
}
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
trimmed = line
|
||||
sub(/^ */, "", trimmed)
|
||||
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
|
||||
marker = substr(trimmed, 1, 1)
|
||||
run = 0
|
||||
while (substr(trimmed, run + 1, 1) == marker) run++
|
||||
if (fence == "") { fence = marker; fence_length = run }
|
||||
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
|
||||
next
|
||||
}
|
||||
if (fence != "") next
|
||||
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
|
||||
finish(); phase = 1; total++
|
||||
if (match(line, /\[(complete|in_progress|pending)\]/))
|
||||
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
|
||||
finish()
|
||||
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
|
||||
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
|
||||
sub(/[ \t].*$/, "", line)
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
END {
|
||||
finish()
|
||||
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
|
||||
}
|
||||
' < "$1"
|
||||
}
|
||||
|
||||
# A pre-existing pointer must be a contained regular file before it is
|
||||
# replaced: a link would be followed or its shared inode overwritten.
|
||||
pointer_is_unsafe() {
|
||||
{ [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
|
||||
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
verify_root() {
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
current_active() {
|
||||
# An unreadable pointer is treated as unset; under set -e the read
|
||||
# would otherwise abort listing.
|
||||
if [ -f "${ACTIVE_FILE}" ] && [ -r "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
|
||||
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
|
||||
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
|
||||
# Treat it as an encoding marker, not part of the shared plan slug.
|
||||
_utf8_bom="$(printf '\357\273\277')"
|
||||
case "${_current}" in
|
||||
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
list_plans() {
|
||||
if [ ! -d "${PLAN_ROOT}" ]; then
|
||||
printf '%s\n' 'No planning directory found.'
|
||||
return 0
|
||||
fi
|
||||
if ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
_active="$(current_active)"
|
||||
_found=0
|
||||
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
|
||||
_plan_file="${_dir}/task_plan.md"
|
||||
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
|
||||
is_within_root "${_plan_file}" || continue
|
||||
_status="$(phase_status "${_plan_file}")" || continue
|
||||
_marker=''
|
||||
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
|
||||
printf '%s\n' "- ${_id}${_marker} - ${_status}"
|
||||
_found=1
|
||||
done
|
||||
if [ "${_found}" -eq 0 ]; then
|
||||
printf '%s\n' 'No named plans found.'
|
||||
else
|
||||
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
printf '%s\n' 'Error: list plans, verify the root, or set PLAN_ID in separate calls.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--list|-l) list_plans; exit $? ;;
|
||||
--verify-root) verify_root; exit $? ;;
|
||||
--help|-h)
|
||||
printf '%s\n' 'Usage: set-active-plan.sh [--list|--verify-root|PLAN_ID]' \
|
||||
'Lists saved named plans in the current project without selecting a plan.' \
|
||||
'--verify-root checks the planning root and pointer without listing or selecting.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = '' ]; then
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
exit 1
|
||||
fi
|
||||
plan_id="$(current_active)"
|
||||
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)"
|
||||
else
|
||||
printf '%s\n' 'No active plan set.'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
if ! slug_is_valid "${PLAN_ID}"; then
|
||||
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
|
||||
"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
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
exit 1
|
||||
fi
|
||||
# Replace the pointer atomically instead of truncating a possible hardlink.
|
||||
# mktemp creates a private, exclusive file beside the destination.
|
||||
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
|
||||
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "${temp_file}"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
|
||||
# mktemp creates the file 0600; the shared pointer must stay readable by
|
||||
# every session, so apply the caller's umask instead (=rw without a who
|
||||
# clause is umask-relative in POSIX chmod).
|
||||
chmod =rw "${temp_file}" 2>/dev/null || true
|
||||
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
|
||||
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
|
||||
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"userPromptSubmit": [
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": ".cursor/hooks/user-prompt-submit.sh",
|
||||
"command": ".cursor/hooks/session-start.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,11 +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
|
||||
}
|
||||
|
||||
if (Test-Path "task_plan.md") {
|
||||
Write-Output "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
|
||||
# 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 { }
|
||||
|
||||
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)) {
|
||||
$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
@@ -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
|
||||
|
||||
@@ -1,19 +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
|
||||
}
|
||||
|
||||
$PlanFile = "task_plan.md"
|
||||
|
||||
if (Test-Path $PlanFile) {
|
||||
Get-Content $PlanFile -TotalCount 30 | Write-Host
|
||||
}
|
||||
|
||||
Write-Output '{"decision": "allow"}'
|
||||
Write-Output '{"permission":"allow"}'
|
||||
exit 0
|
||||
|
||||
Regular → Executable
+3
-14
@@ -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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Shared selected-plan resolver for Cursor's native PowerShell hooks.
|
||||
# Returns one hashtable with Directory set for a safe named or legacy plan root.
|
||||
# Empty Directory means the hook must fail closed instead of reading a
|
||||
# different plan than the selector or active pointer named.
|
||||
#
|
||||
# A hashtable, not [pscustomobject]: ConstrainedLanguage mode (WDAC, AppLocker
|
||||
# script rules) rejects the custom-object cast, which turned every hook fire,
|
||||
# the legacy root included, into a silent "nothing injected".
|
||||
|
||||
$script:CursorPlanResolver = Join-Path (
|
||||
Split-Path -Parent $PSScriptRoot
|
||||
) "skills/planning-with-files/scripts/resolve-plan-dir.ps1"
|
||||
|
||||
function New-CursorPlanContext {
|
||||
param([string]$Directory, [string]$Status, [string]$Detail = '')
|
||||
return @{
|
||||
Directory = $Directory
|
||||
Status = $Status
|
||||
Detail = $Detail
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CursorAbsoluteLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# Same shape as the resolver's Test-ValidSlug and the sh slug_is_valid.
|
||||
function Test-CursorValidSlug {
|
||||
param([string]$Name)
|
||||
if (-not $Name) { return $false }
|
||||
return $Name -match '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
||||
}
|
||||
|
||||
function Resolve-CursorPlanContext {
|
||||
$legacyRoot = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
if (-not (Test-CursorAbsoluteLocalPath $env:PWF_PLAN_ROOT) -or
|
||||
-not (Test-Path -LiteralPath $env:PWF_PLAN_ROOT -PathType Container)) {
|
||||
return (New-CursorPlanContext $null 'invalid-pin' $env:PWF_PLAN_ROOT)
|
||||
}
|
||||
$legacyRoot = $env:PWF_PLAN_ROOT
|
||||
}
|
||||
|
||||
$planRoot = Join-Path $legacyRoot '.planning'
|
||||
$planRootExists = Test-Path -LiteralPath $planRoot -PathType Container
|
||||
if (-not $env:PLAN_ID -and -not $planRootExists) {
|
||||
return (New-CursorPlanContext $legacyRoot 'legacy')
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $script:CursorPlanResolver -PathType Leaf)) {
|
||||
return (New-CursorPlanContext $null 'invalid' 'resolver-missing')
|
||||
}
|
||||
|
||||
try {
|
||||
# Assign first, then read $?: wrapping the call in @() resets $? to
|
||||
# $true on Windows PowerShell 5.1, so the failure check would never fire.
|
||||
$resolvedOutput = & $script:CursorPlanResolver 2>$null
|
||||
if (-not $?) { throw 'resolver failed' }
|
||||
$resolved = @(@($resolvedOutput) | Where-Object { $_ } | Select-Object -First 1)
|
||||
if ($resolved.Count -gt 0) {
|
||||
return (New-CursorPlanContext ([string]$resolved[0]) 'selected')
|
||||
}
|
||||
$ambiguity = & $script:CursorPlanResolver -CheckAmbiguity 2>$null
|
||||
if (-not $?) { throw 'ambiguity probe failed' }
|
||||
if (@($ambiguity) -contains 'PWF_PLAN_AMBIGUOUS_V1') {
|
||||
return (New-CursorPlanContext $null 'ambiguous')
|
||||
}
|
||||
} catch {
|
||||
return (New-CursorPlanContext $null 'invalid' 'resolver-error')
|
||||
}
|
||||
|
||||
# The resolver named nothing. An explicit PLAN_ID is a binding (issue #237)
|
||||
# and fails closed instead of selecting another plan.
|
||||
if ($env:PLAN_ID) {
|
||||
return (New-CursorPlanContext $null 'invalid-plan-id' $env:PLAN_ID)
|
||||
}
|
||||
|
||||
# A pointer that is a directory or a reparse point is an unsafe selector the
|
||||
# resolver stopped on, and a valid slug plan the resolver still refused is a
|
||||
# containment failure (a junction escaping the project). Neither may fall
|
||||
# through to the root plan; the containment case stays silent, as
|
||||
# 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
|
||||
(([string]$activeItem.LinkType) -in @('SymbolicLink', 'Junction')))) {
|
||||
return (New-CursorPlanContext $null 'invalid' 'unsafe-pointer')
|
||||
}
|
||||
if ($planRootExists) {
|
||||
$refused = @(Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { -not $_.Name.StartsWith('.') } |
|
||||
Where-Object { Test-CursorValidSlug $_.Name } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'task_plan.md') -PathType Leaf } |
|
||||
Select-Object -First 1)
|
||||
if ($refused.Count -gt 0) {
|
||||
return (New-CursorPlanContext $null 'refused' 'containment')
|
||||
}
|
||||
}
|
||||
return (New-CursorPlanContext $legacyRoot 'legacy')
|
||||
}
|
||||
@@ -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 '{}'
|
||||
}
|
||||
Executable
+33
@@ -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
|
||||
+16
-4
@@ -7,13 +7,24 @@
|
||||
# no-plan-file behaviour, so the Cursor protocol shape never changes.
|
||||
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
|
||||
|
||||
$PlanFile = "task_plan.md"
|
||||
# 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 { }
|
||||
|
||||
if (-not (Test-Path $PlanFile)) {
|
||||
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
|
||||
$PlanContext = Resolve-CursorPlanContext
|
||||
$PlanFile = if ($PlanContext.Directory) {
|
||||
Join-Path $PlanContext.Directory "task_plan.md"
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
|
||||
if (-not $PlanFile -or -not (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$content = Get-Content $PlanFile -Raw
|
||||
$content = Get-Content -LiteralPath $PlanFile -Raw
|
||||
|
||||
$TOTAL = ([regex]::Matches($content, "### Phase")).Count
|
||||
|
||||
@@ -37,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
@@ -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,46 +4,52 @@
|
||||
|
||||
# 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 }
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
# A thread whose cwd is a shared PARENT of the real project can be pinned to
|
||||
# the nested project root; every planning-state read below goes through the
|
||||
# prefix. An explicit but broken pin fails CLOSED: one notice, nothing
|
||||
# injected, never a silent fall back to the ambiguous cwd plan the caller was
|
||||
# escaping. With the var unset the paths stay byte-identical to the legacy
|
||||
# shape. Wording matches scripts/inject-plan.sh and the sh twin.
|
||||
$planPrefix = ""
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
if (Test-Path -LiteralPath $env:PWF_PLAN_ROOT -PathType Container) {
|
||||
$planPrefix = $env:PWF_PLAN_ROOT
|
||||
} else {
|
||||
Write-Output "[planning-with-files] PWF_PLAN_ROOT is not a directory: $($env:PWF_PLAN_ROOT) — nothing injected."
|
||||
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
|
||||
|
||||
if (-not $PlanContext.Directory) {
|
||||
# One notice per failure, worded like inject-plan.sh, which owns the rule.
|
||||
switch ($PlanContext.Status) {
|
||||
'ambiguous' {
|
||||
Write-Output "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
}
|
||||
'invalid-pin' {
|
||||
Write-Output "[planning-with-files] PWF_PLAN_ROOT is not a supported absolute local directory: $($PlanContext.Detail) — nothing injected."
|
||||
}
|
||||
'refused' {
|
||||
# A plan the resolver refused for containment: silent, like inject-plan.sh.
|
||||
}
|
||||
'invalid-plan-id' {
|
||||
Write-Output "[planning-with-files] PLAN_ID does not name a plan directory under .planning: $($PlanContext.Detail) — nothing injected. Fix or unset the pin; a broken pin fails closed rather than selecting another plan."
|
||||
}
|
||||
default {
|
||||
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."
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($planPrefix) {
|
||||
$planFile = Join-Path $planPrefix "task_plan.md"
|
||||
$progressFile = Join-Path $planPrefix "progress.md"
|
||||
} else {
|
||||
$planFile = "task_plan.md"
|
||||
$progressFile = "progress.md"
|
||||
}
|
||||
$planFile = Join-Path $PlanContext.Directory "task_plan.md"
|
||||
$progressFile = Join-Path $PlanContext.Directory "progress.md"
|
||||
|
||||
if (Test-Path $planFile) {
|
||||
if (Test-Path -LiteralPath $planFile -PathType Leaf) {
|
||||
# --- Nested-root conflict detection (issue #212): fail CLOSED on ambiguity.
|
||||
# This hook resolves only the legacy root task_plan.md, so any resolution
|
||||
# is a cwd GUESS unless PWF_PLAN_ROOT pinned it. If a direct child carries
|
||||
# A shared pointer or newest-plan fallback is still a cwd GUESS unless an
|
||||
# explicit PWF_PLAN_ROOT or PLAN_ID selected it. If a direct child carries
|
||||
# its own competing .planning (an .active_plan pointer, or at least one
|
||||
# <slug>/task_plan.md), this cwd is a shared parent and injecting the root
|
||||
# plan is the wrong answer for at least one thread — inject NOTHING and
|
||||
# say why. Depth 1 only, matching the sh twin's single glob; dot-named
|
||||
# children are skipped for parity with the sh glob (`*` never matches
|
||||
# them), so the root's own .planning is never a hit. Wording matches
|
||||
# scripts/inject-plan.sh, minus the PLAN_ID escape hatch this route does
|
||||
# not implement.
|
||||
if (-not $planPrefix) {
|
||||
# them), so the root's own .planning is never a hit.
|
||||
if (-not $env:PWF_PLAN_ROOT -and -not $env:PLAN_ID) {
|
||||
$nestedRoots = @()
|
||||
foreach ($child in @(Get-ChildItem -Directory -ErrorAction SilentlyContinue)) {
|
||||
if ($child.Name.StartsWith('.')) { continue }
|
||||
@@ -62,20 +68,20 @@ if (Test-Path $planFile) {
|
||||
}
|
||||
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>."
|
||||
exit 0
|
||||
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>."
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "[planning-with-files] ACTIVE PLAN — current state:"
|
||||
Get-Content $planFile -TotalCount 50 -Encoding UTF8
|
||||
Get-Content -LiteralPath $planFile -TotalCount 50 -Encoding UTF8
|
||||
Write-Output ""
|
||||
Write-Output "=== recent progress ==="
|
||||
if (Test-Path $progressFile) {
|
||||
if (Test-Path -LiteralPath $progressFile -PathType Leaf) {
|
||||
# Timestamp normalization matches scripts/inject-plan.sh and the sh twin
|
||||
# (KV-cache stability, v2.40): wall-clock times in the injected tail move
|
||||
# every fire otherwise.
|
||||
Get-Content $progressFile -Tail 20 -Encoding UTF8 |
|
||||
Get-Content -LiteralPath $progressFile -Tail 20 -Encoding UTF8 |
|
||||
ForEach-Object {
|
||||
$line = $_ -replace 'T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z', 'T00:00:00Z'
|
||||
$line -replace 'T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?([+-][0-9]{2}:[0-9]{2})', 'T00:00:00$2'
|
||||
@@ -84,4 +90,4 @@ if (Test-Path $planFile) {
|
||||
Write-Output ""
|
||||
Write-Output "[planning-with-files] Read findings.md for research context. Continue from the current phase."
|
||||
}
|
||||
exit 0
|
||||
return
|
||||
|
||||
Regular → Executable
@@ -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.17.0"
|
||||
version: "3.21.0"
|
||||
|
||||
---
|
||||
|
||||
@@ -46,7 +46,7 @@ Work like Manus: Use persistent markdown files as your "working memory on disk."
|
||||
|
||||
## FIRST: Restore Project State
|
||||
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or session isolation is armed with multiple plans and no `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
**Before continuing**, resolve the plan this task owns. Use the installed `scripts/resolve-plan-dir.sh` (or `.ps1`) with the host's `PLAN_ID` and `PWF_PLAN_ROOT`, then read `task_plan.md`, `progress.md`, and `findings.md` from that selected directory. If an explicit selector is rejected, or multiple named plans exist without `PLAN_ID`, correct the pin and do not fall back to another task. Run `git diff --stat` for code changes not yet recorded there. All planning filenames below mean that selected directory. For parallel tasks, pin each host before it starts or use separate worktrees; a child process export does not change its host. One orchestrator owns a shared plan and summaries, while workers use assigned files or ledgers.
|
||||
|
||||
```bash
|
||||
# Linux/macOS (auto-detects python3 or python)
|
||||
@@ -212,6 +212,12 @@ Helper scripts for automation:
|
||||
- `scripts/check-complete.sh` — Verify all phases complete
|
||||
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
|
||||
|
||||
### List saved plans
|
||||
|
||||
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
|
||||
|
||||
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- **Manus Principles:** See [reference.md](reference.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.");
|
||||
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
|
||||
$securityRootPath = (Get-Location).Path
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
# Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
|
||||
# local path is the only accepted shape, as in resolve-plan-dir.ps1.
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
|
||||
if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
|
||||
throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
|
||||
}
|
||||
$securityRootPath = $pin
|
||||
@@ -404,6 +412,7 @@ function Resolve-PlanFile {
|
||||
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
||||
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
||||
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
||||
if ($resolvedDir) {
|
||||
$planFile = Join-Path $resolvedDir "task_plan.md"
|
||||
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
||||
|
||||
@@ -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,9 +45,46 @@ 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)"
|
||||
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
||||
printf "%s\n" "${plan_dir}/task_plan.md"
|
||||
@@ -76,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
|
||||
@@ -97,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)" || {
|
||||
@@ -113,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
|
||||
@@ -152,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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall -> allow stop)
|
||||
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
||||
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
||||
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
||||
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
||||
#
|
||||
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
||||
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
||||
@@ -35,6 +36,7 @@ if ($PlanFile -ne "") {
|
||||
try {
|
||||
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
||||
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
||||
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
||||
} catch {
|
||||
$resolvedDir = ""
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -84,9 +88,10 @@ if ($TOTAL -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# advisory_report: the v2.43 status echo.
|
||||
# Keep explicit reports, but omit routine success from automatic gate checks.
|
||||
function Write-AdvisoryReport {
|
||||
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
||||
if ($Gate) { return }
|
||||
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
||||
} else {
|
||||
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
||||
|
||||
Regular → Executable
+12
-3
@@ -21,8 +21,9 @@
|
||||
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
||||
# 5. the ledger advanced since the last block (stall → allow stop)
|
||||
# When all hold, it emits a single-line block-decision JSON on stdout and
|
||||
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
||||
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
||||
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
||||
# plans stay silent in --gate mode, including legacy plans without .mode.
|
||||
# Without --gate, the explicit advisory report is unchanged.
|
||||
#
|
||||
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
||||
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
||||
@@ -56,6 +57,9 @@ else
|
||||
RESOLVED_DIR=""
|
||||
if [ -f "${RESOLVER}" ]; then
|
||||
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
||||
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
||||
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
||||
@@ -75,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
|
||||
@@ -116,9 +123,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
||||
# Explicit status reports retain completion text. Automatic gate checks have
|
||||
# nothing to report on success; keep evaluating all gate guards before here.
|
||||
advisory_report() {
|
||||
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
||||
[ "$GATE" -eq 1 ] && return 0
|
||||
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
||||
else
|
||||
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
||||
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
||||
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
||||
# Templates: default, analytics
|
||||
@@ -12,10 +13,32 @@
|
||||
param(
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default",
|
||||
[switch]$PlanDir,
|
||||
[switch]$Autonomous,
|
||||
[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.
|
||||
@@ -38,7 +61,65 @@ function Get-Nonce {
|
||||
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
function Get-PlanSlug([string]$Name) {
|
||||
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
||||
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
||||
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
||||
$slug = $slug -creplace '-{2,}', '-'
|
||||
$slug = $slug.Trim('-')
|
||||
if ($slug.Length -gt 40) {
|
||||
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
||||
}
|
||||
# The resolvers and the selector only accept ASCII plan ids.
|
||||
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
||||
return ""
|
||||
}
|
||||
return $slug
|
||||
}
|
||||
|
||||
function Get-ShortId {
|
||||
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
||||
}
|
||||
|
||||
function Get-InheritedMode([string]$CurrentMode) {
|
||||
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
||||
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
||||
return $CurrentMode
|
||||
}
|
||||
if ($CurrentMode -eq "gated") {
|
||||
return $CurrentMode
|
||||
}
|
||||
|
||||
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
||||
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
||||
if ($RootMode -cmatch 'gate') {
|
||||
return "gated"
|
||||
}
|
||||
if ($RootMode -cmatch 'autonomous') {
|
||||
return "autonomous"
|
||||
}
|
||||
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") {
|
||||
@@ -46,11 +127,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
||||
# mode. A positional project name or -PlanDir creates an isolated
|
||||
# .planning/<date>-<slug>/ plan.
|
||||
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
||||
$UsePlanDir = $PlanDir -or $NamedPlan
|
||||
if ($UsePlanDir) {
|
||||
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
||||
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
||||
# shared pointer, so a named plan cannot be created without it.
|
||||
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
||||
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
||||
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. A
|
||||
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
||||
# it first and treat a thrown error as a failure too.
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $PlanSelector -VerifyRoot *> $null
|
||||
} catch {
|
||||
$global:LASTEXITCODE = 1
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NamedPlan) {
|
||||
$Slug = Get-PlanSlug $ProjectName
|
||||
} else {
|
||||
$Slug = ""
|
||||
$ProjectName = "untitled"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Slug)) {
|
||||
$Slug = "untitled-$(Get-ShortId)"
|
||||
}
|
||||
|
||||
$BaseId = "$DATE-$Slug"
|
||||
$PlanId = $BaseId
|
||||
$Counter = 2
|
||||
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
||||
$PlanId = "$BaseId-$Counter"
|
||||
$Counter++
|
||||
}
|
||||
$TargetDir = Join-Path $PlanningRoot $PlanId
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
||||
$Mode = Get-InheritedMode $Mode
|
||||
} else {
|
||||
$TargetDir = (Get-Location).Path
|
||||
}
|
||||
|
||||
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
||||
$FindingsPath = Join-Path $TargetDir "findings.md"
|
||||
$ProgressPath = Join-Path $TargetDir "progress.md"
|
||||
if ($UsePlanDir) {
|
||||
$TaskPlanDisplay = $TaskPlanPath
|
||||
$FindingsDisplay = $FindingsPath
|
||||
$ProgressDisplay = $ProgressPath
|
||||
} else {
|
||||
$TaskPlanDisplay = "task_plan.md"
|
||||
$FindingsDisplay = "findings.md"
|
||||
$ProgressDisplay = "progress.md"
|
||||
}
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "PLAN_ID=$PlanId"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
@@ -99,18 +255,18 @@ Phase 1
|
||||
## Errors Encountered
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
Write-Host "Created $TaskPlanDisplay"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
Write-Host "$TaskPlanDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
@@ -131,15 +287,15 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
Write-Host "Created $FindingsDisplay"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
Write-Host "$FindingsDisplay already exists, skipping"
|
||||
}
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -160,7 +316,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
@@ -181,23 +337,74 @@ if (-not (Test-Path "progress.md")) {
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
Write-Host "Created $ProgressDisplay"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
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!"
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
if ($UsePlanDir) {
|
||||
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
||||
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
||||
Write-Host " `$env:PLAN_ID='$PlanId'"
|
||||
} else {
|
||||
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
||||
}
|
||||
|
||||
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
||||
# the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
|
||||
# dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
|
||||
# .plan-attestation at the project root.
|
||||
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
||||
# selected plan; root mode therefore retains the legacy project-root behavior.
|
||||
if ($Mode -ne "") {
|
||||
$PlanDirPwf = (Get-Location).Path
|
||||
$PlanDirPwf = $TargetDir
|
||||
|
||||
# (a) reset gate block counter, drop stale gate ledger.
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
||||
@@ -216,15 +423,96 @@ if ($Mode -ne "") {
|
||||
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
||||
|
||||
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
||||
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
||||
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
||||
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
||||
# 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 $AttestPs1) -and (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 {
|
||||
& $AttestPs1 *> $null
|
||||
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) {
|
||||
$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 {
|
||||
$AttestationCommand = "attest-plan.sh"
|
||||
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
||||
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# CDPATH must not redirect the cd that locates the sibling scripts.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -184,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"
|
||||
@@ -201,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
|
||||
}
|
||||
|
||||
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
BASE_ID="${DATE}-${SLUG}"
|
||||
PLAN_ID="$BASE_ID"
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
||||
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
||||
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
# Verify the physical planning root and the existing pointer before
|
||||
# creating anything below it. A symlink or junction that escapes the
|
||||
# project must not redirect init writes, and a linked or non-regular
|
||||
# pointer must be refused before a plan directory exists on disk. The
|
||||
# selector's check is constant time; --list would parse every plan.
|
||||
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
||||
exit 1
|
||||
fi
|
||||
counter=2
|
||||
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
||||
PLAN_ID="${BASE_ID}-${counter}"
|
||||
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
||||
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
||||
echo "PLAN_ID=$PLAN_ID"
|
||||
create_files_in "$PLAN_DIR"
|
||||
printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
|
||||
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
||||
# redirection would truncate a pre-existing hardlink and could overwrite a
|
||||
# different file that shares the same inode.
|
||||
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
||||
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
inherit_root_mode
|
||||
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
||||
echo ""
|
||||
@@ -401,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}"
|
||||
@@ -412,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
|
||||
|
||||
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
|
||||
one turn-marker slot and one progress-guard slot per plan.
|
||||
|
||||
Known, accepted differences from the shell reference:
|
||||
* Two plan directories with the same whole-second mtime, no .active_plan
|
||||
and no PLAN_ID: the reference picks the first in the shell's glob order
|
||||
(locale collation), this twin the first in code-point order. The same
|
||||
collation difference can change which three of four or more nested
|
||||
projects the ambiguity notice names. A machine runs one implementation,
|
||||
so neither choice flips within a session.
|
||||
* Shell glob order follows locale collation; this twin uses code-point
|
||||
order. This can change which three of four or more nested projects the
|
||||
ambiguity notice names.
|
||||
* BSD sed (macOS) appends a newline to a progress.md whose last line has
|
||||
none; GNU sed and this twin do not.
|
||||
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
|
||||
@@ -837,8 +834,21 @@ class Injector(object):
|
||||
scope = ""
|
||||
explicit = bool(plan_prefix)
|
||||
plan_id = env.get("PLAN_ID", "")
|
||||
ambiguous = plan_is_ambiguous(
|
||||
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
|
||||
)
|
||||
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
|
||||
"for this session; nothing injected."
|
||||
)
|
||||
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
|
||||
@@ -857,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"):
|
||||
@@ -873,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"):
|
||||
@@ -923,30 +935,14 @@ class Injector(object):
|
||||
"single-session mode."
|
||||
)
|
||||
raise Bail()
|
||||
if not plan_id:
|
||||
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
|
||||
try:
|
||||
names = sorted(os.listdir(plan_prefix + ".planning"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
|
||||
continue
|
||||
if not slug_is_valid(name):
|
||||
continue
|
||||
plan_n += 1
|
||||
if plan_n > 1:
|
||||
break
|
||||
if plan_n > 1:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
if ambiguous:
|
||||
if context == "userprompt":
|
||||
self.echo(
|
||||
"[planning-with-files] Multiple plans are available while session "
|
||||
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
|
||||
"injected."
|
||||
)
|
||||
raise Bail()
|
||||
|
||||
if not explicit:
|
||||
nested = []
|
||||
@@ -1326,6 +1322,28 @@ def inject(context, env=None):
|
||||
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
|
||||
"""A project pointer or mtime cannot bind a session to multiple plans."""
|
||||
if plan_id:
|
||||
return False
|
||||
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
|
||||
try:
|
||||
names = os.listdir(plan_root)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def resolve_plan_dir(env=None):
|
||||
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
|
||||
|
||||
@@ -1356,10 +1374,13 @@ 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 ("", "")
|
||||
|
||||
if plan_is_ambiguous(fs_root, pin if pin else "."):
|
||||
return ("", "")
|
||||
|
||||
active_file = fs_root + "/.active_plan"
|
||||
if is_file(active_file):
|
||||
try:
|
||||
@@ -1371,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):
|
||||
@@ -1387,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"):
|
||||
@@ -1441,6 +1464,8 @@ class ClaudeDispatcher(object):
|
||||
return (spelled, fs)
|
||||
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
|
||||
return ("", "")
|
||||
if plan_is_ambiguous(".planning", "."):
|
||||
return ("", "")
|
||||
if is_file("task_plan.md"):
|
||||
return (".", ".")
|
||||
return ("", "")
|
||||
|
||||
@@ -254,6 +254,30 @@ is_within_root() {
|
||||
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
|
||||
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
|
||||
# conflict check below.
|
||||
# Shared .active_plan and directory mtime cannot identify this session's plan.
|
||||
# Check before selection or preflight, even when isolation was never armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOLVED=""
|
||||
SCOPE=""
|
||||
EXPLICIT=0
|
||||
@@ -272,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
|
||||
@@ -282,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
|
||||
@@ -291,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)
|
||||
@@ -442,27 +469,13 @@ PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An attachment admits a session but does not select one of several plans.
|
||||
# When isolation is armed, require PLAN_ID if more than one live same-root
|
||||
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
|
||||
# within that root.
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
SESSION_PLAN_N=0
|
||||
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
|
||||
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
|
||||
[ -f "$candidate" ] || continue
|
||||
candidate_dir="${candidate%/task_plan.md}"
|
||||
candidate_slug="${candidate_dir##*/}"
|
||||
slug_is_valid "$candidate_slug" || continue
|
||||
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
|
||||
[ "$SESSION_PLAN_N" -gt 1 ] && break
|
||||
done
|
||||
if [ "$SESSION_PLAN_N" -gt 1 ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
# Attachment admits a session but does not select its plan. Preserve the
|
||||
# attachment-first notice above for sessions that never opted in.
|
||||
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
|
||||
if [ "$CONTEXT" = "userprompt" ]; then
|
||||
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
# junction/symlink escape; slug validation alone blocks textual traversal.
|
||||
|
||||
param(
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
||||
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
|
||||
[switch]$CheckAmbiguity
|
||||
)
|
||||
|
||||
$projectRoot = (Get-Location).Path
|
||||
@@ -76,6 +77,17 @@ function Get-FinalDirectoryPath {
|
||||
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
||||
}
|
||||
|
||||
function Test-FullyQualifiedLocalPath {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
||||
return $false
|
||||
}
|
||||
if ($script:IsWindowsHost) {
|
||||
return $Path -match '^[A-Za-z]:[\\/]'
|
||||
}
|
||||
return [System.IO.Path]::IsPathRooted($Path)
|
||||
}
|
||||
|
||||
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
||||
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
||||
# project resolves the parent's plan and never sees the nested one;
|
||||
@@ -89,9 +101,8 @@ function Get-FinalDirectoryPath {
|
||||
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
||||
if ($env:PWF_PLAN_ROOT) {
|
||||
$pin = $env:PWF_PLAN_ROOT
|
||||
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
||||
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
||||
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
if ((Test-FullyQualifiedLocalPath $pin) -and
|
||||
(Test-Path -LiteralPath $pin -PathType Container)) {
|
||||
$projectRoot = $pin
|
||||
$PlanRoot = Join-Path $pin ".planning"
|
||||
} else {
|
||||
@@ -128,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
|
||||
@@ -137,10 +166,37 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
|
||||
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
|
||||
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
|
||||
# falsy here and still means "unset".
|
||||
# The optional probe distinguishes ambiguity from a legacy-root fallback.
|
||||
# Multiple named plans require PLAN_ID even without a sessions directory.
|
||||
$planCount = 0
|
||||
if (-not $env:PLAN_ID) {
|
||||
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
|
||||
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
|
||||
$planCount = 1
|
||||
}
|
||||
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++
|
||||
if ($planCount -gt 1) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($CheckAmbiguity) {
|
||||
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
|
||||
exit 0
|
||||
}
|
||||
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 $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
|
||||
}
|
||||
@@ -150,29 +206,37 @@ 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
|
||||
}
|
||||
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
||||
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
||||
# falls through like an invalid one instead of raising inside a caller.
|
||||
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
||||
if ($planId -and (Test-ValidSlug $planId)) {
|
||||
$candidate = Join-Path $PlanRoot $planId
|
||||
if ((Test-Path $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PlanRoot -PathType Container) {
|
||||
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
||||
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
||||
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
||||
# valid selection into an empty result.
|
||||
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 (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
||||
Where-Object { Test-WithinRoot $_.FullName } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Optional probe distinguishes ambiguity from the empty legacy-root result.
|
||||
# Both modes keep stdout data-only and always exit zero.
|
||||
CHECK_AMBIGUITY=0
|
||||
if [ "${1:-}" = "--check-ambiguity" ]; then
|
||||
CHECK_AMBIGUITY=1
|
||||
shift
|
||||
fi
|
||||
PLAN_ROOT="${1:-${PWD}/.planning}"
|
||||
|
||||
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
||||
@@ -239,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
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
@@ -321,6 +336,30 @@ resolve_latest_dir() {
|
||||
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
|
||||
# above already does it; a non-zero status would kill callers running under
|
||||
# set -e for a condition that is not an internal error.
|
||||
# A shared pointer or mtime is not a per-session binding (issue #240).
|
||||
# Count conservatively, just like injection: slug-valid live plan files.
|
||||
# The legacy root joins the count only when session isolation is armed.
|
||||
PLAN_AMBIGUOUS=0
|
||||
if [ -z "${PLAN_ID:-}" ]; then
|
||||
PLAN_COUNT=0
|
||||
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
|
||||
PLAN_COUNT=1
|
||||
fi
|
||||
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
||||
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
|
||||
done
|
||||
fi
|
||||
if [ "$CHECK_AMBIGUITY" = "1" ]; then
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
|
||||
exit 0
|
||||
fi
|
||||
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
|
||||
|
||||
if [ -n "${PLAN_ID:-}" ]; then
|
||||
resolve_from_env && exit 0
|
||||
exit 0
|
||||
|
||||
@@ -407,9 +407,6 @@ def get_session_candidates(
|
||||
return 'claude', []
|
||||
|
||||
|
||||
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
||||
|
||||
|
||||
def get_opencode_db_path() -> Optional[Path]:
|
||||
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
||||
xdg = os.environ.get('XDG_DATA_HOME')
|
||||
@@ -471,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', '')
|
||||
@@ -489,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
|
||||
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
||||
update_time = None
|
||||
update_idx = -1
|
||||
for idx, (sid, _) in enumerate(previous_sessions):
|
||||
params = (sid,) + PLANNING_LIKE_SQL
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT time_created FROM part
|
||||
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 (
|
||||
json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
||||
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
IN ('task_plan.md', 'findings.md', 'progress.md')
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/task_plan.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/findings.md'
|
||||
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
||||
GLOB '*/progress.md'
|
||||
)
|
||||
ORDER BY time_created DESC
|
||||
LIMIT 1
|
||||
ORDER BY time_created DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
update_sid = sid
|
||||
update_time = row[0]
|
||||
update_idx = idx
|
||||
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
||||
# would materialize every planning write of the session before the
|
||||
# first validated row ends the loop.
|
||||
for candidate_time, data_str in cur:
|
||||
data = json_loads(data_str)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
state = data.get('state')
|
||||
input_ = state.get('input') if isinstance(state, dict) else None
|
||||
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
||||
if planning_file_from_path(file_path):
|
||||
update_sid = sid
|
||||
update_time = candidate_time
|
||||
update_idx = idx
|
||||
break
|
||||
if update_sid:
|
||||
break
|
||||
|
||||
if not update_sid:
|
||||
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
||||
"""Return a planning filename only when it is the path's exact basename.
|
||||
|
||||
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
||||
planning updates and can anchor catchup at unrelated transcript content.
|
||||
Normalize separators so the same boundary rule works for Unix and Windows
|
||||
session records.
|
||||
"""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
for pf in PLANNING_FILES:
|
||||
if path_value.endswith(pf):
|
||||
return pf
|
||||
return None
|
||||
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
||||
return basename if basename in PLANNING_FILES else None
|
||||
|
||||
|
||||
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
||||
|
||||
Regular → Executable
+386
-16
@@ -1,27 +1,324 @@
|
||||
# planning-with-files: set or display the active plan pointer (PowerShell).
|
||||
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
|
||||
#
|
||||
# Usage:
|
||||
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
|
||||
# .\set-active-plan.ps1 - print the current active plan (if any)
|
||||
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
||||
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
||||
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
||||
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
||||
|
||||
param(
|
||||
[string]$PlanId = ""
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanId = "",
|
||||
[Alias('l', '-list')]
|
||||
[switch]$List,
|
||||
[Alias('verify-root')]
|
||||
[switch]$VerifyRoot,
|
||||
[Alias('h', '-help')]
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$PlanRoot = Join-Path (Get-Location) ".planning"
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
# Windows PowerShell 5.1 can silently change directories when -File inherits
|
||||
# a cwd containing wildcard characters. Recover that physical cwd only for a
|
||||
# direct invocation of this script; interactive Set-Location remains primary.
|
||||
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)) {
|
||||
$ProjectRoot = [Environment]::CurrentDirectory
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$PlanRoot = Join-Path $ProjectRoot ".planning"
|
||||
$ActiveFile = Join-Path $PlanRoot ".active_plan"
|
||||
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
||||
|
||||
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
|
||||
# open the actual filesystem object before checking the project boundary.
|
||||
# The Windows handle supports both directories and task_plan.md files.
|
||||
function Get-FinalPath {
|
||||
param([string]$Path)
|
||||
if (-not ("PwfPlanListingNative" -as [type])) {
|
||||
Add-Type -ErrorAction Stop -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public static class PwfPlanListingNative {
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string name, uint access, uint share, IntPtr security,
|
||||
uint creation, uint flags, IntPtr template);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern IntPtr realpath(string path, IntPtr resolved);
|
||||
[DllImport("libc")]
|
||||
private static extern void free(IntPtr pointer);
|
||||
|
||||
public static string FinalPath(string path) {
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
|
||||
IntPtr resolved = realpath(path, IntPtr.Zero);
|
||||
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try { return Marshal.PtrToStringAnsi(resolved); }
|
||||
finally { free(resolved); }
|
||||
}
|
||||
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
|
||||
using (SafeFileHandle handle = CreateFileW(
|
||||
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder buffer = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
string result = buffer.ToString();
|
||||
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + result.Substring(8);
|
||||
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
||||
return result.Substring(4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
return [PwfPlanListingNative]::FinalPath($Path)
|
||||
}
|
||||
|
||||
function Test-WithinRoot {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
|
||||
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
|
||||
$comparison = [StringComparison]::Ordinal
|
||||
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
|
||||
return $pathReal.Equals($rootReal, $comparison) -or
|
||||
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ValidSlug {
|
||||
param([string]$Name)
|
||||
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
|
||||
}
|
||||
|
||||
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. 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 -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 {
|
||||
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
|
||||
if (Test-ValidSlug $current) { return $current }
|
||||
} catch { }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Add-PhaseStatus {
|
||||
param([hashtable]$Counts, $Phase)
|
||||
if ($null -eq $Phase) { return }
|
||||
$status = $Phase.Inline
|
||||
if ($Phase.Primary) { $status = $Phase.Primary }
|
||||
if ($status) { $Counts[$status]++ }
|
||||
}
|
||||
|
||||
function Get-PhaseStatus {
|
||||
param([string]$PlanFile)
|
||||
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
|
||||
$phase = $null
|
||||
$fence = ""
|
||||
$fenceLength = 0
|
||||
# The shipped translations keep status identifiers in English. Unicode
|
||||
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
|
||||
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
|
||||
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
|
||||
# Read once; an empty file is a valid plan with zero phases.
|
||||
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
|
||||
foreach ($line in ($content -split '\r?\n')) {
|
||||
if ($fence) {
|
||||
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
|
||||
if ($line -match $closing) { $fence = "" }
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
|
||||
$fence = $Matches[1].Substring(0, 1)
|
||||
$fenceLength = $Matches[1].Length
|
||||
continue
|
||||
}
|
||||
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = @{ Primary = ""; Inline = "" }
|
||||
$counts.total++
|
||||
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
|
||||
$phase.Inline = $Matches[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
|
||||
Add-PhaseStatus $counts $phase
|
||||
$phase = $null
|
||||
continue
|
||||
}
|
||||
if ($null -ne $phase -and -not $phase.Primary -and
|
||||
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
|
||||
$phase.Primary = $Matches[1]
|
||||
}
|
||||
}
|
||||
Add-PhaseStatus $counts $phase
|
||||
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
|
||||
}
|
||||
|
||||
function Show-PlanList {
|
||||
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
|
||||
Write-Output "No planning directory found."
|
||||
return
|
||||
}
|
||||
if (-not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$active = Get-CurrentActivePlan
|
||||
Write-Output "Available plans:"
|
||||
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 }
|
||||
try { $status = Get-PhaseStatus $planFile } catch { continue }
|
||||
$marker = ""
|
||||
if ($plan.Name -ceq $active) { $marker = " [active]" }
|
||||
Write-Output "- $($plan.Name)$marker - $status"
|
||||
$found = $true
|
||||
}
|
||||
if (-not $found) { Write-Output "No named plans found." }
|
||||
}
|
||||
|
||||
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
||||
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
if ($VerifyRoot) {
|
||||
if ($List -or $PlanId) {
|
||||
Write-Error "Error: verify the planning root in a separate call."
|
||||
exit 1
|
||||
}
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
|
||||
if ($List -and $PlanId) {
|
||||
Write-Error "Error: list plans or set PLAN_ID in separate calls."
|
||||
exit 1
|
||||
}
|
||||
Show-PlanList
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PlanId -eq "") {
|
||||
if (Test-Path $ActiveFile) {
|
||||
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
|
||||
$current = ""
|
||||
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
||||
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
||||
exit 1
|
||||
}
|
||||
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
|
||||
if ($current) {
|
||||
$planDir = Join-Path $PlanRoot $current
|
||||
if ($current -ne "" -and (Test-Path $planDir)) {
|
||||
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
|
||||
Write-Output "Active plan: $current"
|
||||
Write-Output "Path: $planDir"
|
||||
} elseif ($current -ne "") {
|
||||
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
|
||||
}
|
||||
} else {
|
||||
Write-Output "No active plan set."
|
||||
@@ -29,21 +326,94 @@ if ($PlanId -eq "") {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-ValidSlug $PlanId)) {
|
||||
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
|
||||
exit 1
|
||||
}
|
||||
$PlanDir = Join-Path $PlanRoot $PlanId
|
||||
|
||||
if (-not (Test-Path $PlanDir)) {
|
||||
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
||||
Write-Error "Error: plan directory not found: $PlanDir"
|
||||
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PlanRoot)) {
|
||||
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
|
||||
$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
|
||||
}
|
||||
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
||||
if ($activeItem -and -not (Test-SafeActiveFile)) {
|
||||
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
|
||||
|
||||
# Replace the directory entry instead of truncating an existing inode: a
|
||||
# hardlinked pointer must not overwrite another file. The exclusive temporary
|
||||
# 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
|
||||
try {
|
||||
$bytes = $utf8NoBom.GetBytes($PlanId)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
} finally { $stream.Dispose() }
|
||||
$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 {
|
||||
$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 ""
|
||||
|
||||
Regular → Executable
+358
-38
@@ -1,50 +1,370 @@
|
||||
#!/bin/sh
|
||||
# planning-with-files: set or display the active plan pointer.
|
||||
#
|
||||
# Usage:
|
||||
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
|
||||
# set-active-plan.sh — print the current active plan (if any)
|
||||
#
|
||||
# The active plan is stored in .planning/.active_plan and is read by
|
||||
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
|
||||
|
||||
set -e
|
||||
|
||||
# List saved named plans, show the shared pointer, or change that pointer.
|
||||
# Usage: set-active-plan.sh [--list|-l|--verify-root|PLAN_ID]
|
||||
# Operates on the current project; listing never binds a host or injects data.
|
||||
set -eu
|
||||
PLAN_ROOT="${PWD}/.planning"
|
||||
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
||||
PWF_ROOT_PIN=""
|
||||
|
||||
# No args → show current active plan
|
||||
if [ "${1:-}" = "" ]; then
|
||||
if [ -f "${ACTIVE_FILE}" ]; then
|
||||
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
|
||||
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
|
||||
echo "Active plan: ${plan_id}"
|
||||
echo "Path: ${PLAN_ROOT}/${plan_id}"
|
||||
elif [ -n "${plan_id}" ]; then
|
||||
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
|
||||
else
|
||||
echo "No active plan set."
|
||||
fi
|
||||
# Use the resolver canonicalization policy without running plan selection.
|
||||
slug_is_valid() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
[A-Za-z0-9_]*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
||||
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
||||
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
||||
# backslash output. The containment prefix match below is written with forward
|
||||
# slashes, so without this normalization every canonical pair mismatches and
|
||||
# resolution silently fails. On POSIX systems paths contain no backslash and
|
||||
# this is the identity. A literal backslash in a Unix filename normalizes to
|
||||
# "/" and at worst fails containment — the safe direction. No subshell, no
|
||||
# fork: plain parameter expansion in a loop.
|
||||
norm_slashes() {
|
||||
NORM_OUT=""
|
||||
_ns_rest="$1"
|
||||
while :; do
|
||||
case "${_ns_rest}" in
|
||||
*\\*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
||||
_ns_rest="${_ns_rest#*\\}"
|
||||
;;
|
||||
*)
|
||||
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Return true when a candidate path names the Microsoft Store WindowsApps
|
||||
# directory. Store app aliases are not stable interpreter binaries and may
|
||||
# present as executable while refusing script execution. Matching is
|
||||
# case-insensitive and works before or after Windows slash normalization.
|
||||
is_windowsapps_path() {
|
||||
norm_slashes "$1"
|
||||
case "${NORM_OUT}" in
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
||||
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Select only an interpreter path the caller explicitly trusted.
|
||||
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
||||
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
||||
# repositories that control PATH. Windows-native absolute paths are converted
|
||||
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
||||
trusted_python() {
|
||||
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
||||
[ -n "${_tp_candidate}" ] || continue
|
||||
case "${_tp_candidate}" in
|
||||
\\\\*|//*) continue ;;
|
||||
[A-Za-z]:[\\/]*)
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
_tp_cygpath="/usr/bin/cygpath.exe"
|
||||
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
||||
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
||||
|| continue
|
||||
;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
is_windowsapps_path "${_tp_candidate}" && continue
|
||||
[ -f "${_tp_candidate}" ] || continue
|
||||
[ -x "${_tp_candidate}" ] || continue
|
||||
printf "%s\n" "${_tp_candidate}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
||||
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
||||
# Prints the canonical absolute path on success; prints nothing and returns 1
|
||||
# on a full miss so containment fails closed. No Python spawn on the happy
|
||||
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
||||
canonicalize() {
|
||||
target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
||||
printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
_canonical_python="$(trusted_python)" || _canonical_python=""
|
||||
if [ -n "${_canonical_python}" ]; then
|
||||
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
||||
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
||||
# path under the project root (the CWD the script runs from). A symlink inside
|
||||
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
||||
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
||||
# the caller treats the candidate as unresolved and falls back safely.
|
||||
#
|
||||
# The root canonicalizes via the relative token "." rather than the $PWD
|
||||
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
||||
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
||||
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
||||
# match below fails and resolution silently goes dark. "." resolves through
|
||||
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
||||
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
||||
# backslash-normalized before comparison for Windows-native canonicalizers.
|
||||
# The root is computed once per run: the newest-mtime scan calls this guard
|
||||
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
||||
#
|
||||
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
||||
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
||||
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
||||
# relative "." root — byte-identical to the legacy check.
|
||||
ROOT_REAL=""
|
||||
ROOT_REAL_SET=0
|
||||
is_within_root() {
|
||||
candidate="$1"
|
||||
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
||||
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
||||
norm_slashes "${ROOT_REAL}"
|
||||
ROOT_REAL="${NORM_OUT}"
|
||||
ROOT_REAL_SET=1
|
||||
fi
|
||||
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
||||
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
||||
# long-form spelling), while the root canonicalizes from "." (the process
|
||||
# cwd, which a caller may have set with an 8.3 short-form string). A
|
||||
# Windows-native realpath does not unify those spellings, so canonicalizing
|
||||
# both sides from the same cwd base is the only spelling-stable comparison.
|
||||
# The emitted result keeps the original absolute candidate — only the
|
||||
# containment check uses the relative form.
|
||||
# Pinned resolution skips the rewrite: candidate and root then share the
|
||||
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
||||
if [ -n "${PWF_ROOT_PIN}" ]; then
|
||||
check_target="${candidate}"
|
||||
else
|
||||
echo "No active plan set."
|
||||
case "${candidate}" in
|
||||
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
||||
*) check_target="${candidate}" ;;
|
||||
esac
|
||||
fi
|
||||
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
||||
norm_slashes "${cand_real}"
|
||||
cand_real="${NORM_OUT}"
|
||||
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
||||
# Slug validation blocks textual traversal, but only successful
|
||||
# canonicalization can rule out a symlink/junction escape.
|
||||
return 1
|
||||
fi
|
||||
case "${cand_real}" in
|
||||
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Each phase contributes at most one status. An explicit Status line wins
|
||||
# over an inline heading marker. Fenced examples and unrelated sections are
|
||||
# not phases. Keep this parser aligned with the PowerShell helper.
|
||||
phase_status() {
|
||||
awk '
|
||||
function finish() {
|
||||
if (phase) {
|
||||
status = primary != "" ? primary : inline_status
|
||||
if (status == "complete") complete++
|
||||
else if (status == "in_progress") in_progress++
|
||||
else if (status == "pending") pending++
|
||||
}
|
||||
phase = 0; primary = ""; inline_status = ""
|
||||
}
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
trimmed = line
|
||||
sub(/^ */, "", trimmed)
|
||||
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
|
||||
marker = substr(trimmed, 1, 1)
|
||||
run = 0
|
||||
while (substr(trimmed, run + 1, 1) == marker) run++
|
||||
if (fence == "") { fence = marker; fence_length = run }
|
||||
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
|
||||
next
|
||||
}
|
||||
if (fence != "") next
|
||||
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
|
||||
finish(); phase = 1; total++
|
||||
if (match(line, /\[(complete|in_progress|pending)\]/))
|
||||
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
|
||||
finish()
|
||||
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
|
||||
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
|
||||
sub(/[ \t].*$/, "", line)
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
END {
|
||||
finish()
|
||||
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
|
||||
}
|
||||
' < "$1"
|
||||
}
|
||||
|
||||
# A pre-existing pointer must be a contained regular file before it is
|
||||
# replaced: a link would be followed or its shared inode overwritten.
|
||||
pointer_is_unsafe() {
|
||||
{ [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
|
||||
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }
|
||||
}
|
||||
|
||||
# Constant-time check for callers that are about to create a plan: the
|
||||
# planning root, when present, must be inside the project, and an existing
|
||||
# pointer must be replaceable. Nothing is read, listed, or written.
|
||||
verify_root() {
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
current_active() {
|
||||
# An unreadable pointer is treated as unset; under set -e the read
|
||||
# would otherwise abort listing.
|
||||
if [ -f "${ACTIVE_FILE}" ] && [ -r "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
|
||||
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
|
||||
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
|
||||
# Treat it as an encoding marker, not part of the shared plan slug.
|
||||
_utf8_bom="$(printf '\357\273\277')"
|
||||
case "${_current}" in
|
||||
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
|
||||
esac
|
||||
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
list_plans() {
|
||||
if [ ! -d "${PLAN_ROOT}" ]; then
|
||||
printf '%s\n' 'No planning directory found.'
|
||||
return 0
|
||||
fi
|
||||
if ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
return 1
|
||||
fi
|
||||
_active="$(current_active)"
|
||||
_found=0
|
||||
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
|
||||
_plan_file="${_dir}/task_plan.md"
|
||||
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
|
||||
is_within_root "${_plan_file}" || continue
|
||||
_status="$(phase_status "${_plan_file}")" || continue
|
||||
_marker=''
|
||||
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
|
||||
printf '%s\n' "- ${_id}${_marker} - ${_status}"
|
||||
_found=1
|
||||
done
|
||||
if [ "${_found}" -eq 0 ]; then
|
||||
printf '%s\n' 'No named plans found.'
|
||||
else
|
||||
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
printf '%s\n' 'Error: list plans, verify the root, or set PLAN_ID in separate calls.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--list|-l) list_plans; exit $? ;;
|
||||
--verify-root) verify_root; exit $? ;;
|
||||
--help|-h)
|
||||
printf '%s\n' 'Usage: set-active-plan.sh [--list|--verify-root|PLAN_ID]' \
|
||||
'Lists saved named plans in the current project without selecting a plan.' \
|
||||
'--verify-root checks the planning root and pointer without listing or selecting.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = '' ]; then
|
||||
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
|
||||
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
|
||||
exit 1
|
||||
fi
|
||||
plan_id="$(current_active)"
|
||||
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)"
|
||||
else
|
||||
printf '%s\n' 'No active plan set.'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PLAN_ID="$1"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
|
||||
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
|
||||
if ! slug_is_valid "${PLAN_ID}"; then
|
||||
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PLAN_ROOT}"
|
||||
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
|
||||
|
||||
echo "Active plan set to: ${PLAN_ID}"
|
||||
echo "Path: ${PLAN_DIR}"
|
||||
echo ""
|
||||
echo "To pin this terminal session only:"
|
||||
echo " export PLAN_ID=${PLAN_ID}"
|
||||
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
|
||||
if [ ! -d "${PLAN_DIR}" ]; then
|
||||
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
|
||||
"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
|
||||
fi
|
||||
if pointer_is_unsafe; then
|
||||
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
|
||||
exit 1
|
||||
fi
|
||||
# Replace the pointer atomically instead of truncating a possible hardlink.
|
||||
# mktemp creates a private, exclusive file beside the destination.
|
||||
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
|
||||
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "${temp_file}"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
|
||||
# mktemp creates the file 0600; the shared pointer must stay readable by
|
||||
# every session, so apply the caller's umask instead (=rw without a who
|
||||
# clause is umask-relative in POSIX chmod).
|
||||
chmod =rw "${temp_file}" 2>/dev/null || true
|
||||
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
|
||||
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
|
||||
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# dsh-planning-with-files
|
||||
|
||||
Native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) plugin for [planning-with-files](https://github.com/OthmanAdi/planning-with-files): persistent file-based planning for AI coding agents. The plan lives on disk in `task_plan.md`, `findings.md` and `progress.md`; this plugin keeps it in the model's context on every prompt and can hold a gated turn open until the plan reports complete.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx skills add OthmanAdi/planning-with-files --skill planning-with-files -g
|
||||
dsh plugin --profile web add dsh-planning-with-files
|
||||
```
|
||||
|
||||
The first line installs the skill text into `~/.agents/skills/planning-with-files/`, one of the paths dsh reads natively. The second adds this bundle to the `web` profile; restart `dsh web` afterwards. The same `dsh plugin --profile <name> add dsh-planning-with-files` works for the `headless`, `sdk` and `acp` profiles. `dsh --profile web --dump-config` shows the `dsh-planning-with-files` layer.
|
||||
|
||||
Built against the published `@deepseek-ai/dsh` 0.1.5-rc.2 and the `@deepseek-ai/*` 0.1.5-rc.2 libraries it installs.
|
||||
|
||||
## What the plugin does
|
||||
|
||||
| Lifecycle point | Behavior |
|
||||
|---|---|
|
||||
| `agent/pre-step` | Appends the active plan to the step that carries the user's prompt: the framed head of `task_plan.md`, the normalized tail of `progress.md`, a pointer to `findings.md`. Resolves `PLAN_ID`; with one named plan, `.planning/.active_plan` or the plan directory itself; two or more named plans without `PLAN_ID` get the `Multiple plans are available` notice instead of a plan; then the legacy root file. Steps that carry only tool results, steering or the plugin's own messages get nothing |
|
||||
| `tools/post-execute` | Attaches a progress reminder to successful `write` and `edit` calls, and to mutating `str_replace_editor` calls (`create`, `str_replace`, `insert`) where a profile mounts that tool, while a plan exists |
|
||||
| `session/event` with a successful `compaction/end` | The next step of that agent (the continuation of an automatically compacted turn, or the prompt after a manual `/compact`) carries the compaction note (plan pointer, flush instruction, attestation hash) and the framed plan as one message, so the continuation resumes at the current phase |
|
||||
| `agent/turn-stopping` | Completion gate in gated mode: while an `in_progress` phase remains, the plugin steers the agent into another step with the gate reason. Block cap `PWF_GATE_CAP` (default 20) and ledger stall detection release the turn; subagent children are never held |
|
||||
|
||||
Commands: `/pwf [--autonomous|--gated] [--template analytics] [plan name]` creates the planning files (a name creates `.planning/YYYY-MM-DD-<slug>/` and makes it active; `--gated` or `--autonomous` writes the v3 markers and attests the plan) and queues the framed plan for the next model request; `/pwf-status` reports the active plan. Tools: `pwf_init`, `pwf_status`, `pwf_check` with the same contract as the OpenCode plugin, resolving the plan from the calling agent's session workspace.
|
||||
|
||||
Environment, read from the `dsh` process: `PLAN_ID=<slug>` binds a plan and ends resolution when the slug names none; `PWF_PLAN_ROOT=<absolute path>` pins the project root and fails closed when it does not resolve; `PLANNING_DISABLED=1` silences every listener, command and tool; `PWF_GATE_CAP=<n>` caps the gate blocks. Autonomous and gated plans inject only when `.attestation` (slug) or `.plan-attestation` (root) matches the SHA-256 of `task_plan.md`. A live plan in a direct child project makes a cwd guess ambiguous and nothing is injected, with a one-line notice.
|
||||
|
||||
Row configuration: `enabled` (default `true`) and `gate` (default `true`, set `false` to keep injection and drop the completion gate).
|
||||
|
||||
Full guide: [docs/deepseek-harness.md](https://github.com/OthmanAdi/planning-with-files/blob/master/docs/deepseek-harness.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user