planning-with-files 3.10.2 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,158 +1,202 @@
1
- #!/bin/sh
2
- # planning-with-files: set the status of one phase in task_plan.md (v3).
3
- #
4
- # This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
5
- # lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
6
- # All status edits go through this read-modify-write under an advisory flock on
7
- # the <plan-dir>/.write_lock sentinel, with an atomic temp-file + mv swap so a
8
- # torn write can never leave a half-rewritten plan on disk (architecture C4).
9
- #
10
- # Note: editing task_plan.md changes its SHA, so the orchestrator must
11
- # re-attest at phase boundaries (see attest-plan.sh).
12
- #
13
- # Plan-dir resolution (via resolve-plan-dir.sh):
14
- # 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/
15
- # 2. ./.planning/.active_plan
16
- # 3. Newest ./.planning/<dir>/ by mtime
17
- # 4. Legacy: project root ./task_plan.md
18
- #
19
- # Usage:
20
- # sh scripts/phase-status.sh <phase-number> <pending|in_progress|complete>
21
- #
22
- # Exits 1 with a message if the phase does not exist or the status is invalid.
23
-
24
- set -u
25
-
26
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
27
- RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
28
-
29
- usage() {
30
- printf "Usage: %s <phase-number> <pending|in_progress|complete>\n" "$0" >&2
31
- }
32
-
33
- resolve_plan_file() {
34
- plan_dir=""
35
- if [ -f "${RESOLVER}" ]; then
36
- plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
37
- fi
38
- if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
39
- printf "%s\n" "${plan_dir}/task_plan.md"
40
- return 0
41
- fi
42
- if [ -f "./task_plan.md" ]; then
43
- printf "%s\n" "./task_plan.md"
44
- return 0
45
- fi
46
- return 1
47
- }
48
-
49
- PHASE_NUM="${1:-}"
50
- NEW_STATUS="${2:-}"
51
-
52
- if [ -z "${PHASE_NUM}" ] || [ -z "${NEW_STATUS}" ]; then
53
- usage
54
- exit 1
55
- fi
56
-
57
- # Validate phase number is a positive integer.
58
- case "${PHASE_NUM}" in
59
- ''|*[!0-9]*)
60
- printf "[phase-status] phase number must be a positive integer, got '%s'.\n" "${PHASE_NUM}" >&2
61
- exit 1
62
- ;;
63
- esac
64
-
65
- # Validate status value against the allowlist.
66
- case "${NEW_STATUS}" in
67
- pending|in_progress|complete) : ;;
68
- *)
69
- printf "[phase-status] invalid status '%s' (allowed: pending, in_progress, complete).\n" "${NEW_STATUS}" >&2
70
- exit 1
71
- ;;
72
- esac
73
-
74
- PLAN_FILE="$(resolve_plan_file)" || {
75
- printf "[phase-status] No task_plan.md found. Create a plan first.\n" >&2
76
- exit 1
77
- }
78
-
79
- PLAN_DIR="$(dirname "${PLAN_FILE}")"
80
- LOCK_FILE="${PLAN_DIR}/.write_lock"
81
-
82
- # Confirm the phase heading exists before touching the file.
83
- if ! grep -q "### Phase ${PHASE_NUM}\b" "${PLAN_FILE}" 2>/dev/null; then
84
- # Fall back to a looser match for headings like "### Phase 1:" where \b may
85
- # not be honored by a minimal grep.
86
- if ! grep -Eq "^### Phase ${PHASE_NUM}([^0-9]|$)" "${PLAN_FILE}" 2>/dev/null; then
87
- printf "[phase-status] Phase %s not found in %s.\n" "${PHASE_NUM}" "${PLAN_FILE}" >&2
88
- exit 1
89
- fi
90
- fi
91
-
92
- # Rewrite only the FIRST "**Status:**" line that follows the "### Phase N"
93
- # heading. awk tracks whether we are inside the target phase block; once we
94
- # rewrite its status line we stop matching so later phases are untouched.
95
- rewrite() {
96
- src="$1"
97
- dst="$2"
98
- awk -v target="${PHASE_NUM}" -v newstatus="${NEW_STATUS}" '
99
- BEGIN { in_block = 0; done = 0 }
100
- {
101
- line = $0
102
- if (line ~ /^### Phase /) {
103
- # Extract the phase number right after "### Phase ".
104
- rest = line
105
- sub(/^### Phase /, "", rest)
106
- num = rest
107
- sub(/[^0-9].*$/, "", num)
108
- if (num == target && done == 0) {
109
- in_block = 1
110
- } else {
111
- in_block = 0
112
- }
113
- } else if (in_block == 1 && done == 0 && line ~ /\*\*Status:\*\*/) {
114
- # Preserve leading whitespace/bullet before "**Status:**".
115
- prefix = line
116
- sub(/\*\*Status:\*\*.*$/, "", prefix)
117
- line = prefix "**Status:** " newstatus
118
- in_block = 0
119
- done = 1
120
- }
121
- print line
122
- }
123
- END { if (done == 0) exit 3 }
124
- ' "${src}" > "${dst}"
125
- }
126
-
127
- TMP_FILE="${PLAN_FILE}.tmp.$$"
128
-
129
- do_write() {
130
- if ! rewrite "${PLAN_FILE}" "${TMP_FILE}"; then
131
- rm -f "${TMP_FILE}" 2>/dev/null
132
- printf "[phase-status] No **Status:** line found for Phase %s.\n" "${PHASE_NUM}" >&2
133
- return 1
134
- fi
135
- mv -f "${TMP_FILE}" "${PLAN_FILE}"
136
- return 0
137
- }
138
-
139
- rc=0
140
- if command -v flock >/dev/null 2>&1; then
141
- (
142
- flock -w 5 9 || true
143
- do_write
144
- ) 9>"${LOCK_FILE}" 2>/dev/null
145
- rc=$?
146
- rm -f "${LOCK_FILE}" 2>/dev/null || true
147
- else
148
- do_write
149
- rc=$?
150
- fi
151
-
152
- if [ "${rc}" -ne 0 ]; then
153
- rm -f "${TMP_FILE}" 2>/dev/null
154
- exit 1
155
- fi
156
-
157
- printf "[phase-status] Phase %s -> %s in %s\n" "${PHASE_NUM}" "${NEW_STATUS}" "${PLAN_FILE}"
158
- exit 0
1
+ #!/bin/sh
2
+ # planning-with-files: set the status of one phase in task_plan.md (v3).
3
+ #
4
+ # This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
5
+ # lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
6
+ # All status edits go through this read-modify-write under the portable
7
+ # <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
8
+ # temp-file + mv swap so a torn write can never leave a half-rewritten plan on
9
+ # disk (architecture C4).
10
+ #
11
+ # Note: editing task_plan.md changes its SHA, so the orchestrator must
12
+ # re-attest at phase boundaries (see attest-plan.sh).
13
+ #
14
+ # Plan-dir resolution (via resolve-plan-dir.sh):
15
+ # 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/
16
+ # 2. ./.planning/.active_plan
17
+ # 3. Newest ./.planning/<dir>/ by mtime
18
+ # 4. Legacy: project root ./task_plan.md
19
+ #
20
+ # Usage:
21
+ # sh scripts/phase-status.sh <phase-number> <pending|in_progress|complete>
22
+ #
23
+ # Exits 1 with a message if the phase does not exist or the status is invalid.
24
+
25
+ set -u
26
+
27
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
28
+ RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
29
+
30
+ usage() {
31
+ printf "Usage: %s <phase-number> <pending|in_progress|complete>\n" "$0" >&2
32
+ }
33
+
34
+ resolve_plan_file() {
35
+ plan_dir=""
36
+ if [ -f "${RESOLVER}" ]; then
37
+ plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
38
+ fi
39
+ if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
40
+ printf "%s\n" "${plan_dir}/task_plan.md"
41
+ return 0
42
+ fi
43
+ if [ -f "./task_plan.md" ]; then
44
+ printf "%s\n" "./task_plan.md"
45
+ return 0
46
+ fi
47
+ return 1
48
+ }
49
+
50
+ PHASE_NUM="${1:-}"
51
+ NEW_STATUS="${2:-}"
52
+
53
+ if [ -z "${PHASE_NUM}" ] || [ -z "${NEW_STATUS}" ]; then
54
+ usage
55
+ exit 1
56
+ fi
57
+
58
+ # Validate phase number is a positive integer.
59
+ case "${PHASE_NUM}" in
60
+ ''|*[!0-9]*)
61
+ printf "[phase-status] phase number must be a positive integer, got '%s'.\n" "${PHASE_NUM}" >&2
62
+ exit 1
63
+ ;;
64
+ esac
65
+
66
+ # Validate status value against the allowlist.
67
+ case "${NEW_STATUS}" in
68
+ pending|in_progress|complete) : ;;
69
+ *)
70
+ printf "[phase-status] invalid status '%s' (allowed: pending, in_progress, complete).\n" "${NEW_STATUS}" >&2
71
+ exit 1
72
+ ;;
73
+ esac
74
+
75
+ PLAN_FILE="$(resolve_plan_file)" || {
76
+ printf "[phase-status] No task_plan.md found. Create a plan first.\n" >&2
77
+ exit 1
78
+ }
79
+
80
+ PLAN_DIR="$(dirname "${PLAN_FILE}")"
81
+ LOCK_ROOT="${PLAN_DIR}/.pwf-locks"
82
+ LOCK_DIR="${LOCK_ROOT}/phase-status.lock"
83
+ LOCK_TOKEN=""
84
+ LOCK_ACQUIRED=0
85
+
86
+ release_lock() {
87
+ if [ "${LOCK_ACQUIRED}" -ne 1 ] || [ -z "${LOCK_TOKEN}" ]; then
88
+ return 0
89
+ fi
90
+ owner_file="${LOCK_DIR}/.owner"
91
+ owner_value="$(cat "${owner_file}" 2>/dev/null || true)"
92
+ if [ "${owner_value}" = "${LOCK_TOKEN}" ]; then
93
+ rm -f "${owner_file}" 2>/dev/null || true
94
+ rmdir "${LOCK_DIR}" 2>/dev/null || true
95
+ fi
96
+ LOCK_ACQUIRED=0
97
+ }
98
+
99
+ acquire_lock() {
100
+ mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
101
+ printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
102
+ return 1
103
+ }
104
+ LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
105
+ started_at="$(date +%s 2>/dev/null || printf 0)"
106
+ attempts=0
107
+ while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
108
+ attempts=$((attempts + 1))
109
+ now="$(date +%s 2>/dev/null || printf 0)"
110
+ if { [ "${started_at}" -gt 0 ] 2>/dev/null \
111
+ && [ $((now - started_at)) -ge 5 ]; } \
112
+ || [ "${attempts}" -ge 50 ]; then
113
+ printf "[phase-status] Timed out waiting for lock %s. No plan changes were made.\n" "${LOCK_DIR}" >&2
114
+ return 75
115
+ fi
116
+ sleep 0.1
117
+ done
118
+ if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
119
+ rmdir "${LOCK_DIR}" 2>/dev/null || true
120
+ printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
121
+ return 1
122
+ fi
123
+ LOCK_ACQUIRED=1
124
+ return 0
125
+ }
126
+
127
+ trap 'release_lock' EXIT
128
+ trap 'release_lock; exit 1' HUP INT TERM
129
+
130
+ acquire_lock
131
+ lock_rc=$?
132
+ if [ "${lock_rc}" -ne 0 ]; then
133
+ exit "${lock_rc}"
134
+ fi
135
+
136
+ # Confirm the phase heading exists while holding the same lock as the rewrite.
137
+ if ! grep -q "### Phase ${PHASE_NUM}\b" "${PLAN_FILE}" 2>/dev/null; then
138
+ # Fall back to a looser match for headings like "### Phase 1:" where \b may
139
+ # not be honored by a minimal grep.
140
+ if ! grep -Eq "^### Phase ${PHASE_NUM}([^0-9]|$)" "${PLAN_FILE}" 2>/dev/null; then
141
+ printf "[phase-status] Phase %s not found in %s.\n" "${PHASE_NUM}" "${PLAN_FILE}" >&2
142
+ exit 1
143
+ fi
144
+ fi
145
+
146
+ # Rewrite only the FIRST "**Status:**" line that follows the "### Phase N"
147
+ # heading. awk tracks whether we are inside the target phase block; once we
148
+ # rewrite its status line we stop matching so later phases are untouched.
149
+ rewrite() {
150
+ src="$1"
151
+ dst="$2"
152
+ awk -v target="${PHASE_NUM}" -v newstatus="${NEW_STATUS}" '
153
+ BEGIN { in_block = 0; done = 0 }
154
+ {
155
+ line = $0
156
+ if (line ~ /^### Phase /) {
157
+ # Extract the phase number right after "### Phase ".
158
+ rest = line
159
+ sub(/^### Phase /, "", rest)
160
+ num = rest
161
+ sub(/[^0-9].*$/, "", num)
162
+ if (num == target && done == 0) {
163
+ in_block = 1
164
+ } else {
165
+ in_block = 0
166
+ }
167
+ } else if (in_block == 1 && done == 0 && line ~ /\*\*Status:\*\*/) {
168
+ # Preserve leading whitespace/bullet before "**Status:**".
169
+ prefix = line
170
+ sub(/\*\*Status:\*\*.*$/, "", prefix)
171
+ line = prefix "**Status:** " newstatus
172
+ in_block = 0
173
+ done = 1
174
+ }
175
+ print line
176
+ }
177
+ END { if (done == 0) exit 3 }
178
+ ' "${src}" > "${dst}"
179
+ }
180
+
181
+ TMP_FILE="${PLAN_FILE}.tmp.$$"
182
+
183
+ do_write() {
184
+ if ! rewrite "${PLAN_FILE}" "${TMP_FILE}"; then
185
+ rm -f "${TMP_FILE}" 2>/dev/null
186
+ printf "[phase-status] No **Status:** line found for Phase %s.\n" "${PHASE_NUM}" >&2
187
+ return 1
188
+ fi
189
+ mv -f "${TMP_FILE}" "${PLAN_FILE}"
190
+ return 0
191
+ }
192
+
193
+ rc=0
194
+ do_write || rc=$?
195
+
196
+ if [ "${rc}" -ne 0 ]; then
197
+ rm -f "${TMP_FILE}" 2>/dev/null
198
+ exit 1
199
+ fi
200
+
201
+ printf "[phase-status] Phase %s -> %s in %s\n" "${PHASE_NUM}" "${NEW_STATUS}" "${PLAN_FILE}"
202
+ exit 0
@@ -18,6 +18,64 @@ param(
18
18
 
19
19
  $projectRoot = (Get-Location).Path
20
20
 
21
+ # Resolve-Path is lexical for Windows junctions: it can return the junction's
22
+ # spelling rather than the directory opened by the filesystem. Use a directory
23
+ # handle and GetFinalPathNameByHandleW on Windows so containment is decided from
24
+ # the object the kernel actually opened.
25
+ $script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
26
+ if ($script:IsWindowsHost -and -not ("PwfResolverNative" -as [type])) {
27
+ Add-Type -TypeDefinition @'
28
+ using System;
29
+ using System.ComponentModel;
30
+ using System.Runtime.InteropServices;
31
+ using System.Text;
32
+ using Microsoft.Win32.SafeHandles;
33
+
34
+ public static class PwfResolverNative {
35
+ private const uint FILE_SHARE_READ = 0x00000001;
36
+ private const uint FILE_SHARE_WRITE = 0x00000002;
37
+ private const uint FILE_SHARE_DELETE = 0x00000004;
38
+ private const uint OPEN_EXISTING = 3;
39
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
40
+
41
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
42
+ private static extern SafeFileHandle CreateFileW(
43
+ string name, uint access, uint share, IntPtr security,
44
+ uint creation, uint flags, IntPtr template);
45
+
46
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
47
+ private static extern uint GetFinalPathNameByHandleW(
48
+ SafeFileHandle handle, StringBuilder path, uint length, uint flags);
49
+
50
+ public static string FinalDirectoryPath(string path) {
51
+ using (SafeFileHandle handle = CreateFileW(
52
+ path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
53
+ IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero)) {
54
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
55
+ StringBuilder buffer = new StringBuilder(32768);
56
+ uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
57
+ if (length == 0 || length >= buffer.Capacity)
58
+ throw new Win32Exception(Marshal.GetLastWin32Error());
59
+ string result = buffer.ToString();
60
+ if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
61
+ return @"\\" + result.Substring(8);
62
+ if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
63
+ return result.Substring(4);
64
+ return result;
65
+ }
66
+ }
67
+ }
68
+ '@
69
+ }
70
+
71
+ function Get-FinalDirectoryPath {
72
+ param([string]$Path)
73
+ if ($script:IsWindowsHost) {
74
+ return [PwfResolverNative]::FinalDirectoryPath($Path)
75
+ }
76
+ return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
77
+ }
78
+
21
79
  # PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
22
80
  # resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
23
81
  # project resolves the parent's plan and never sees the nested one;
@@ -30,9 +88,12 @@ $projectRoot = (Get-Location).Path
30
88
  # user-facing notice; stdout here is the data channel). Containment is then
31
89
  # checked against the pinned root. Unset keeps legacy behavior unchanged.
32
90
  if ($env:PWF_PLAN_ROOT) {
33
- if (Test-Path -LiteralPath $env:PWF_PLAN_ROOT -PathType Container) {
34
- $projectRoot = $env:PWF_PLAN_ROOT
35
- $PlanRoot = Join-Path $env:PWF_PLAN_ROOT ".planning"
91
+ $pin = $env:PWF_PLAN_ROOT
92
+ $isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
93
+ $isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
94
+ if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
95
+ $projectRoot = $pin
96
+ $PlanRoot = Join-Path $pin ".planning"
36
97
  } else {
37
98
  exit 0
38
99
  }
@@ -55,8 +116,8 @@ function Test-ValidSlug {
55
116
  function Test-WithinRoot {
56
117
  param([string]$Candidate)
57
118
  try {
58
- $rootReal = (Resolve-Path -LiteralPath $projectRoot -ErrorAction Stop).Path
59
- $candReal = (Resolve-Path -LiteralPath $Candidate -ErrorAction Stop).Path
119
+ $rootReal = Get-FinalDirectoryPath $projectRoot
120
+ $candReal = Get-FinalDirectoryPath $Candidate
60
121
  } catch {
61
122
  return $false
62
123
  }
@@ -79,8 +140,17 @@ if ($env:PLAN_ID) {
79
140
  }
80
141
  }
81
142
 
82
- if (Test-Path $activeFile) {
83
- $planId = (Get-Content $activeFile -Raw).Trim()
143
+ # Get-Item observes the link object even when its target is missing, unlike
144
+ # Test-Path which follows the target. An active pointer that is a directory or
145
+ # reparse point is an unsafe/ambiguous selector and must terminate resolution;
146
+ # falling through would silently select and expose the newest unrelated plan.
147
+ $activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
148
+ if ($activeItem) {
149
+ if ($activeItem.PSIsContainer -or
150
+ (($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
151
+ exit 0
152
+ }
153
+ $planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
84
154
  if ($planId -and (Test-ValidSlug $planId)) {
85
155
  $candidate = Join-Path $PlanRoot $planId
86
156
  if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
@@ -31,7 +31,12 @@ PLAN_ROOT="${1:-${PWD}/.planning}"
31
31
  # With the variable unset, behavior is byte-identical to the legacy shape.
32
32
  PWF_ROOT_PIN=""
33
33
  if [ -n "${PWF_PLAN_ROOT:-}" ]; then
34
- if [ -d "${PWF_PLAN_ROOT}" ]; then
34
+ case "${PWF_PLAN_ROOT}" in
35
+ \\\\*|//*|[A-Za-z]:[!\\/]*) _pwf_pin_absolute=0 ;;
36
+ /*|[A-Za-z]:[\\/]*) _pwf_pin_absolute=1 ;;
37
+ *) _pwf_pin_absolute=0 ;;
38
+ esac
39
+ if [ "$_pwf_pin_absolute" = "1" ] && [ -d "${PWF_PLAN_ROOT}" ]; then
35
40
  PWF_ROOT_PIN="${PWF_PLAN_ROOT}"
36
41
  PLAN_ROOT="${PWF_PLAN_ROOT}/.planning"
37
42
  else