@theglitchking/persistent-planning 3.1.0 → 3.4.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.documentation/INDEX.md +15 -7
  4. package/.documentation/REGISTRY.md +2 -2
  5. package/.documentation/agents/INDEX.md +2 -2
  6. package/.documentation/agents/REGISTRY.md +2 -2
  7. package/.documentation/api/INDEX.md +2 -2
  8. package/.documentation/api/REGISTRY.md +2 -2
  9. package/.documentation/architecture/INDEX.md +5 -3
  10. package/.documentation/architecture/REGISTRY.md +8 -4
  11. package/.documentation/architecture/lg-mode.md +16 -0
  12. package/.documentation/architecture/lg-plan-artifact-lifecycle.md +104 -0
  13. package/.documentation/architecture/skill-linking-and-reclaim.md +84 -0
  14. package/.documentation/backups/INDEX.md +2 -2
  15. package/.documentation/backups/REGISTRY.md +2 -2
  16. package/.documentation/database/INDEX.md +2 -2
  17. package/.documentation/database/REGISTRY.md +2 -2
  18. package/.documentation/devops/INDEX.md +2 -2
  19. package/.documentation/devops/REGISTRY.md +2 -2
  20. package/.documentation/features/INDEX.md +2 -2
  21. package/.documentation/features/REGISTRY.md +2 -2
  22. package/.documentation/plans/INDEX.md +2 -2
  23. package/.documentation/plans/REGISTRY.md +2 -2
  24. package/.documentation/procedures/INDEX.md +4 -3
  25. package/.documentation/procedures/REGISTRY.md +6 -4
  26. package/.documentation/procedures/plan-completion-and-archive.md +34 -0
  27. package/.documentation/procedures/plugin-update-delivery.md +99 -0
  28. package/.documentation/quickstart/INDEX.md +2 -2
  29. package/.documentation/quickstart/REGISTRY.md +2 -2
  30. package/.documentation/reference/INDEX.md +10 -3
  31. package/.documentation/reference/REGISTRY.md +8 -4
  32. package/.documentation/reference/mandatory-frontmatter.md +71 -0
  33. package/.documentation/reference/skill-version-marker.md +70 -0
  34. package/.documentation/security/INDEX.md +2 -2
  35. package/.documentation/security/REGISTRY.md +2 -2
  36. package/.documentation/standards/INDEX.md +2 -2
  37. package/.documentation/standards/REGISTRY.md +2 -2
  38. package/.documentation/standards/mandatory-closing-phases.md +21 -1
  39. package/.documentation/testing/INDEX.md +2 -2
  40. package/.documentation/testing/REGISTRY.md +2 -2
  41. package/.documentation/testing/test-suite.md +38 -0
  42. package/.documentation/troubleshooting/INDEX.md +8 -8
  43. package/.documentation/troubleshooting/REGISTRY.md +20 -5
  44. package/.documentation/troubleshooting/plan-never-reads-complete.md +109 -0
  45. package/.documentation/troubleshooting/stale-skill-dir-drift.md +115 -0
  46. package/.documentation/workflows/INDEX.md +2 -2
  47. package/.documentation/workflows/REGISTRY.md +2 -2
  48. package/CHANGELOG.md +149 -0
  49. package/bin/persistent-planning.js +56 -6
  50. package/hooks/session-start.js +115 -3
  51. package/package.json +1 -1
  52. package/scripts/archive-plan.sh +3 -6
  53. package/scripts/init-atom.sh +7 -0
  54. package/scripts/init-phase.sh +58 -6
  55. package/scripts/init-planning.sh +18 -6
  56. package/scripts/init-task.sh +7 -0
  57. package/scripts/lib/planning.sh +122 -0
  58. package/scripts/lib/skill-link.js +177 -0
  59. package/scripts/link-skills.js +38 -0
  60. package/scripts/plan-status.sh +85 -10
  61. package/skills/persistent-planning/SKILL.md +5 -2
  62. package/templates/lg/phase.md +7 -4
  63. package/templates/lg/task.md +3 -2
@@ -1,17 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // persistent-planning SessionStart hook.
3
3
  //
4
- // Two jobs:
4
+ // Three jobs:
5
5
  // 1. The update check, delegated entirely to @theglitchking/claude-plugin-runtime.
6
6
  // 2. A completion nudge: if any plan under .planning/ has every box checked
7
7
  // but is still sitting in the active tree, say so, so the agent archives it
8
8
  // instead of rediscovering a finished plan next session.
9
+ // 3. A drift warning: if .claude/skills/persistent-planning is a real directory
10
+ // rather than a symlink into this package, it is a frozen v1 copy and every
11
+ // /start-planning is running stale code. See issue #10.
12
+ // 4. Under updatePolicy: auto, actually repair that drift. This hook is the only
13
+ // code that runs every session in a marketplace install -- that shape has no
14
+ // node_modules in the plugin cache and never runs npm postinstall -- so it is
15
+ // the only delivery path a fix can reach existing broken installs through.
9
16
 
10
17
  import { runSessionStart } from "@theglitchking/claude-plugin-runtime";
11
18
  import { spawnSync } from "node:child_process";
12
19
  import { dirname, join, resolve } from "node:path";
13
- import { existsSync } from "node:fs";
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
21
  import { fileURLToPath } from "node:url";
22
+ import { driftedSkills, readSkillVersion } from "../scripts/lib/skill-link.js";
15
23
 
16
24
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
25
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -33,7 +41,111 @@ function planNudge() {
33
41
  }
34
42
  }
35
43
 
36
- const notice = planNudge();
44
+ function updatePolicy() {
45
+ try {
46
+ const raw = readFileSync(join(projectRoot, ".claude", "persistent-planning.json"), "utf8");
47
+ return JSON.parse(raw)?.updatePolicy || "nudge";
48
+ } catch {
49
+ return "nudge";
50
+ }
51
+ }
52
+
53
+ function packageVersion() {
54
+ try {
55
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ const CONFIG = join(projectRoot, ".claude", "persistent-planning.json");
62
+
63
+ function readConfig() {
64
+ try { return JSON.parse(readFileSync(CONFIG, "utf8")) || {}; } catch { return {}; }
65
+ }
66
+
67
+ /**
68
+ * Repair a drifted skill dir, at most once per plugin version.
69
+ *
70
+ * Runs the package's own linker, which reclaims the stale dir, symlinks, and stamps
71
+ * the version marker. Spawned rather than imported so a failure is contained in a
72
+ * child process and cannot take the hook down with it.
73
+ *
74
+ * Returns a one-line summary to fold into the session notice, or "".
75
+ */
76
+ function autoRepair() {
77
+ try {
78
+ const policy = updatePolicy();
79
+ if (policy !== "auto") return ""; // nudge/off: warn only
80
+
81
+ // Fast path first — two lstats, and the overwhelmingly common answer is "nothing
82
+ // to do". Never pay for the migration on a healthy install.
83
+ if (!driftedSkills(projectRoot, packageRoot, "skills").length) return "";
84
+
85
+ const version = packageVersion();
86
+ const cfg = readConfig();
87
+ if (version && cfg.repairedSkillsForVersion === version) return ""; // once per version
88
+
89
+ const linker = join(packageRoot, "scripts", "link-skills.js");
90
+ if (!existsSync(linker)) return "";
91
+
92
+ const r = spawnSync(process.execPath, [linker], {
93
+ cwd: projectRoot,
94
+ env: { ...process.env, INIT_CWD: projectRoot },
95
+ encoding: "utf8",
96
+ timeout: 10000,
97
+ });
98
+
99
+ // Stamp regardless of outcome: a repair that cannot succeed must not retry every
100
+ // session. A later release moves the version and gets one fresh attempt.
101
+ try {
102
+ writeFileSync(CONFIG, JSON.stringify({ ...cfg, repairedSkillsForVersion: version }, null, 2) + "\n", "utf8");
103
+ } catch { /* config unwritable — the drift warning still fires every session */ }
104
+
105
+ const remaining = driftedSkills(projectRoot, packageRoot, "skills");
106
+ if (remaining.length || r.status !== 0) return ""; // let driftNudge() report it
107
+ return "[persistent-planning] repaired a stale skill directory (originals kept as .bak-*).";
108
+ } catch {
109
+ return "";
110
+ }
111
+ }
112
+
113
+ function driftNudge() {
114
+ try {
115
+ // A user who turned updates off did not ask to be nagged about them either.
116
+ if (updatePolicy() === "off") return "";
117
+ const drifted = driftedSkills(projectRoot, packageRoot, "skills");
118
+ if (!drifted.length) return "";
119
+
120
+ let pkgVersion = "unknown";
121
+ try {
122
+ pkgVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
123
+ } catch { /* keep "unknown" */ }
124
+
125
+ const lines = drifted.map((d) => {
126
+ const found = readSkillVersion(projectRoot, d.name) || "unversioned (pre-3.1.0 copy)";
127
+ const why = d.state === "real-dir"
128
+ ? "is a real directory, not a symlink into the installed package"
129
+ : `is a symlink to ${d.target}, which is not this package`;
130
+ return ` - ${d.dest}\n ${why}\n running: ${found} | installed: ${pkgVersion}`;
131
+ });
132
+
133
+ return [
134
+ "[persistent-planning] STALE SKILL DIRECTORY — commands are running frozen code.",
135
+ ...lines,
136
+ " Plans generated from a pre-3.1.0 copy silently omit the mandatory validate and",
137
+ " documentation phases and the whole 'On Completion' archive block.",
138
+ " Fix: npx persistent-planning relink",
139
+ ].join("\n");
140
+ } catch {
141
+ // Fail open. A broken drift check must never cost anyone their session start.
142
+ return "";
143
+ }
144
+ }
145
+
146
+ // Repair before reporting, so a successful auto-repair does not also warn.
147
+ const repaired = autoRepair();
148
+ const notice = [planNudge(), repaired, driftNudge()].filter(Boolean).join("\n\n");
37
149
 
38
150
  if (!notice) {
39
151
  await runSessionStart({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/persistent-planning",
3
- "version": "3.1.0",
3
+ "version": "3.4.0",
4
4
  "description": "Persistent markdown-based planning with task directories, progress tracking, and context engineering for multi-step Claude Code workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,13 +51,10 @@ ARCHIVE="${PLANNING}/.archive"
51
51
  TODAY=$(planning_today)
52
52
 
53
53
  # .planning/.archive/ must be gitignored: completed plans are local history, not
54
- # something every clone should carry. Idempotent.
54
+ # something every clone should carry. Shared with the init scripts so there is one
55
+ # rule, not two — it also warns when a blanket .planning/ entry is hiding everything.
55
56
  ensure_gitignored() {
56
- local gitignore="${ROOT}/.gitignore"
57
- grep -qE '^\.planning/?$' "$gitignore" 2>/dev/null && return 0 # whole tree already ignored
58
- grep -qE '^\.planning/\.archive/?$' "$gitignore" 2>/dev/null && return 0
59
- printf '\n# Completed plans retired by scripts/archive-plan.sh (local history)\n.planning/.archive/\n' >> "$gitignore"
60
- planning_ok "Added .planning/.archive/ to .gitignore"
57
+ planning_ensure_archive_gitignored "${ROOT}" 1
61
58
  }
62
59
 
63
60
  plan_root_artifact() {
@@ -130,6 +130,13 @@ planning_render_and_log \
130
130
  "ATOM_DATE_PLACEHOLDER=${TODAY}" \
131
131
  "ATOM_SEQUENCE_PLACEHOLDER=${NEXT_SEQUENCE}"
132
132
 
133
+ # Write the atom into its task's list. Same gap as init-task.sh had with
134
+ # phase.md: the atom file was created and never referenced anywhere (#12).
135
+ planning_insert_list_item \
136
+ "${TASK_DIR}/task.md" \
137
+ "## Atoms" \
138
+ "- [ ] **${ATOM_NAME}** (\`atoms/${ATOM_SLUG}.md\`, sequence: ${NEXT_SEQUENCE})"
139
+
133
140
  echo ""
134
141
  planning_ok "Atom '${ATOM_NAME}' ready"
135
142
  echo ""
@@ -125,12 +125,64 @@ planning_render_and_log \
125
125
  "NOTES_SCOPE_PLACEHOLDER=${PHASE_SLUG}" \
126
126
  "NOTES_DATE_PLACEHOLDER=${TODAY}"
127
127
 
128
- # Add .planning/ to .gitignore if missing (preserve existing behavior)
129
- GITIGNORE="${ROOT}/.gitignore"
130
- if [[ -f "$GITIGNORE" ]] && ! grep -q "^\.planning/" "$GITIGNORE"; then
131
- echo ".planning/" >> "$GITIGNORE"
132
- planning_ok "Added .planning/ to .gitignore"
133
- fi
128
+ # Scaffold the two mandatory closing tasks as real task directories.
129
+ #
130
+ # They used to exist only as two checkbox lines in phase.md, so the most important
131
+ # tasks in every plan were the only ones with no artifact for a subagent to read and
132
+ # nowhere to record decisions — and every real plan hand-built them (issue #12).
133
+ scaffold_closer() { # <slug> <title> <atom>...
134
+ local slug="$1" title="$2"; shift 2
135
+ local dir="${PHASE_DIR}/${slug}"
136
+ mkdir -p "${dir}/atoms"
137
+
138
+ planning_render_and_log \
139
+ "${TEMPLATE_DIR}/task.md" \
140
+ "${dir}/task.md" \
141
+ "closer task at ${dir}/task.md" \
142
+ "TASK_TITLE_PLACEHOLDER=${title}" \
143
+ "TASK_SLUG_PLACEHOLDER=${slug}" \
144
+ "PHASE_SLUG_PLACEHOLDER=${PHASE_SLUG}" \
145
+ "TASK_DATE_PLACEHOLDER=${TODAY}"
146
+
147
+ planning_render_and_log \
148
+ "${TEMPLATE_DIR}/notes.md" \
149
+ "${dir}/notes.md" \
150
+ "closer notes at ${dir}/notes.md" \
151
+ "NOTES_TITLE_PLACEHOLDER=${title} — Notes" \
152
+ "NOTES_SCOPE_PLACEHOLDER=${PHASE_SLUG}/${slug}" \
153
+ "NOTES_DATE_PLACEHOLDER=${TODAY}"
154
+
155
+ # mandatory: true is what plan-status.sh gates on. parallelizable stays false —
156
+ # a closer gates on everything before it.
157
+ [[ -f "${dir}/task.md" ]] && sed -i 's/^mandatory: false$/mandatory: true/' "${dir}/task.md"
158
+
159
+ local atom
160
+ for atom in "$@"; do
161
+ planning_insert_list_item "${dir}/task.md" "## Atoms" "- [ ] ${atom}"
162
+ done
163
+ }
164
+
165
+ scaffold_closer \
166
+ "validate-success-through-comprehensive-testing" \
167
+ "Validate success through comprehensive testing" \
168
+ "Prove every claim this phase makes with a test that fails if it breaks" \
169
+ "Test the invariant after mutation, not the shape of a freshly rendered template" \
170
+ "Cover the failure mode that prompted the work, not just the happy path" \
171
+ "Run the full suite green before closing this task"
172
+
173
+ scaffold_closer \
174
+ "documentation-pass-create-update-deprecate-docs" \
175
+ "Documentation pass — create/update/deprecate docs" \
176
+ "Create docs for what was built: what it is, where it lives, how it works" \
177
+ "Add a troubleshooting entry for the symptom someone will actually search for" \
178
+ "Update every existing doc this phase made wrong — a stale doc is worse than a missing one" \
179
+ "Retire superseded docs with \`npx hewtd archive <file>\` — never by deleting" \
180
+ "If hit-em-with-the-docs is installed you MUST use it: \`npx hewtd integrate <file> -a\` to create, \`npx hewtd maintain --quick\` to regenerate indexes" \
181
+ "Run \`npx hewtd audit\` and resolve every error" \
182
+ "Never hand-edit INDEX.md or REGISTRY.md — they are generated"
183
+
184
+ # Only completed plans are local history — see planning_ensure_archive_gitignored.
185
+ planning_ensure_archive_gitignored "${ROOT}"
134
186
 
135
187
  echo ""
136
188
  planning_ok "Phase '${PHASE_NAME}' ready at .planning/${PHASE_SLUG}/"
@@ -106,7 +106,7 @@ if [ ! -f "$TASK_PLAN_FILE" ]; then
106
106
  - [ ] Phase 3: Execute/build
107
107
  - [ ] Phase 4: Review and deliver
108
108
  - [ ] Phase 5: Validate success through comprehensive testing (MANDATORY)
109
- - [ ] Phase 6: Documentation pass -- create/update/deprecate docs (MANDATORY)
109
+ - [ ] Phase 6: Documentation pass -- create/update/deprecate as many docs as needed to capture what was done, where it lives, how to troubleshoot it, etc. etc.. etc.. (MANDATORY)
110
110
 
111
111
  > Phases 5 and 6 are mandatory and MUST stay the last two phases of this plan.
112
112
  > Insert new work above them; never after. Renumber them if you add phases.
@@ -114,7 +114,10 @@ if [ ! -f "$TASK_PLAN_FILE" ]; then
114
114
  > - **Validate success**: prove the work with tests that fail if the change breaks.
115
115
  > - **Documentation pass**: create/update/deprecate every doc this change touches --
116
116
  > what it is, where it lives, how to fix it, how to operate it, why it matters.
117
- > If hit-em-with-the-docs is installed, use it for this pass.
117
+ > If hit-em-with-the-docs is installed, you MUST use it for this pass --
118
+ > `npx hewtd integrate <file> -a` to create, `npx hewtd archive <file>` to retire,
119
+ > `npx hewtd maintain --quick` to regenerate indexes. Never hand-edit INDEX.md or
120
+ > REGISTRY.md; they are generated.
118
121
 
119
122
  ## Key Questions
120
123
  1. [Question to answer]
@@ -205,12 +208,21 @@ else
205
208
  fi
206
209
 
207
210
  # Step 5: Update .gitignore
211
+ #
212
+ # Only completed plans are local history. Active plans are what a teammate or a
213
+ # subagent reads, so they stay shareable (issue #15). This script is deliberately
214
+ # self-contained -- no lib/planning.sh -- so the rule is duplicated here rather than
215
+ # sourced; scripts/lib/planning.sh has the lg-mode copy.
208
216
  if [ -f "${PROJECT_ROOT}/.gitignore" ]; then
209
- if ! grep -q "^\.planning/" "${PROJECT_ROOT}/.gitignore"; then
210
- echo ".planning/" >> "${PROJECT_ROOT}/.gitignore"
211
- echo -e "${GREEN}+${NC} Added .planning/ to .gitignore"
217
+ if grep -qE "^\.planning/?$" "${PROJECT_ROOT}/.gitignore"; then
218
+ echo -e "${YELLOW}*${NC} .gitignore ignores all of .planning/ -- plans will not be shared."
219
+ echo -e " Active plans are what teammates and subagents read. To share them,"
220
+ echo -e " replace the '.planning/' line with '.planning/.archive/'."
221
+ elif grep -qE "^\.planning/\.archive/?$" "${PROJECT_ROOT}/.gitignore"; then
222
+ echo -e "${YELLOW}*${NC} .planning/.archive/ already in .gitignore"
212
223
  else
213
- echo -e "${YELLOW}*${NC} .planning/ already in .gitignore"
224
+ printf '\n# Completed plans retired by scripts/archive-plan.sh (local history)\n.planning/.archive/\n' >> "${PROJECT_ROOT}/.gitignore"
225
+ echo -e "${GREEN}+${NC} Added .planning/.archive/ to .gitignore (active plans stay shareable)"
214
226
  fi
215
227
  else
216
228
  echo -e "${YELLOW}*${NC} No .gitignore found (not added)"
@@ -109,6 +109,13 @@ planning_render_and_log \
109
109
  "NOTES_SCOPE_PLACEHOLDER=${PARENT_PHASE}/${TASK_SLUG}" \
110
110
  "NOTES_DATE_PLACEHOLDER=${TODAY}"
111
111
 
112
+ # Write the task into its phase's list, above the mandatory closers. Before this,
113
+ # init-task.sh never touched phase.md and every list was maintained by hand (#12).
114
+ planning_insert_list_item \
115
+ "${PHASE_DIR}/phase.md" \
116
+ "## Tasks" \
117
+ "- [ ] **${TASK_NAME}** (\`${TASK_SLUG}\`)"
118
+
112
119
  echo ""
113
120
  planning_ok "Task '${TASK_NAME}' ready at .planning/${PARENT_PHASE}/${TASK_SLUG}/"
114
121
  echo ""
@@ -159,3 +159,125 @@ planning_render_and_log() {
159
159
  ;;
160
160
  esac
161
161
  }
162
+
163
+ ###############################################################################
164
+ # List maintenance
165
+ #
166
+ # A phase's task list and a task's atom list are stored markdown checkboxes,
167
+ # not derived views — plan-status.sh counts them, and humans edit them. So the
168
+ # init scripts have to write them. Before this existed, init-task.sh never
169
+ # touched phase.md at all and every list was maintained by hand (issue #12).
170
+ ###############################################################################
171
+
172
+ # planning_insert_list_item <file> <section-heading> <line>
173
+ #
174
+ # Inserts <line> into the checkbox list under <section-heading> in <file>:
175
+ # - above the first item marked MANDATORY, so closers stay last by
176
+ # construction rather than by anyone remembering;
177
+ # - after the last item when the section has no MANDATORY entries;
178
+ # - dropping a "(no ... yet" placeholder, checkbox or italic, including its
179
+ # continuation lines, the first time a real item lands.
180
+ #
181
+ # Idempotent: an identical line already in the section is left alone, so
182
+ # re-running with PLANNING_FORCE=1 cannot double-insert.
183
+ #
184
+ # Anchors on the MANDATORY marker, never on line numbers — the list is a
185
+ # human-editable surface and positions do not survive contact with editing.
186
+ planning_insert_list_item() {
187
+ local file="$1" heading="$2" line="$3" tmp
188
+ [[ -f "$file" ]] || return 0
189
+
190
+ tmp="${file}.pp-tmp.$$"
191
+ awk -v heading="$heading" -v newline="$line" '
192
+ { L[NR] = $0; if ($0 == newline) seen = 1 }
193
+ END {
194
+ if (seen) { for (i = 1; i <= NR; i++) print L[i]; exit }
195
+
196
+ # Section bounds: heading .. next "## " heading (or EOF).
197
+ s = 0
198
+ for (i = 1; i <= NR; i++) {
199
+ if (L[i] == heading) { s = i; continue }
200
+ if (s && L[i] ~ /^## /) { e = i - 1; break }
201
+ }
202
+ if (!s) { for (i = 1; i <= NR; i++) print L[i]; exit }
203
+ if (!e) e = NR
204
+
205
+ # Drop the "(no ... yet" placeholder and its indented continuation lines.
206
+ for (i = s; i <= e; i++) {
207
+ if (L[i] ~ /^- \[[ xX]\] \(no / || L[i] ~ /^_\(no /) {
208
+ drop[i] = 1
209
+ for (j = i + 1; j <= e; j++) {
210
+ if (L[j] ~ /^[[:space:]]+[^[:space:]]/) drop[j] = 1; else break
211
+ }
212
+ }
213
+ }
214
+
215
+ # Anchor: first MANDATORY item, else after the last surviving list item,
216
+ # else at the end of the section.
217
+ at = 0
218
+ for (i = s; i <= e; i++)
219
+ if (!drop[i] && L[i] ~ /^- \[[ xX]\] .*MANDATORY/) { at = i; break }
220
+ if (!at) {
221
+ last = 0
222
+ for (i = s; i <= e; i++) if (!drop[i] && L[i] ~ /^- \[[ xX]\] /) last = i
223
+ if (last) {
224
+ for (j = last + 1; j <= e; j++) {
225
+ if (L[j] ~ /^[[:space:]]+[^[:space:]]/) last = j; else break
226
+ }
227
+ at = last + 1
228
+ }
229
+ }
230
+ if (!at) { at = e + 1; while (at > s && L[at-1] == "") at-- }
231
+
232
+ for (i = 1; i <= NR; i++) {
233
+ if (i == at) print newline
234
+ if (!drop[i]) print L[i]
235
+ }
236
+ if (at == NR + 1) print newline
237
+ }
238
+ ' "$file" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
239
+
240
+ if [[ -s "$tmp" ]]; then mv "$tmp" "$file"; else rm -f "$tmp"; fi
241
+ }
242
+
243
+ ###############################################################################
244
+ # .gitignore scope
245
+ #
246
+ # Only completed plans are local history. Active plans are the artifact a team
247
+ # coordinates around and that subagents read via the planning MCP — in lg mode,
248
+ # selected precisely because there IS a team, ignoring them defeats the point.
249
+ #
250
+ # v1.0.0 blanket-ignored .planning/; 3.1.0's archive-plan.sh introduced the narrow
251
+ # entry with the reasoning above but never displaced the old rule, leaving four
252
+ # shipped docs describing a behavior the code did not have (issue #15).
253
+ ###############################################################################
254
+
255
+ # planning_ensure_archive_gitignored <repo-root> [create-if-missing]
256
+ #
257
+ # Adds `.planning/.archive/` when absent. Never rewrites an existing blanket
258
+ # `.planning/` entry — that is the user's file and their call — but says once how to
259
+ # narrow it, because otherwise the plans they are about to create are invisible to
260
+ # everyone else and nothing tells them.
261
+ #
262
+ # The init scripts pass no second argument: creating a .gitignore in a repo that has
263
+ # none is not their business. archive-plan.sh passes 1 — it has just moved files into
264
+ # .archive/ and that path has to be ignored for the move to mean anything.
265
+ planning_ensure_archive_gitignored() {
266
+ local gitignore="${1}/.gitignore" create="${2:-}"
267
+ if [[ ! -f "$gitignore" ]]; then
268
+ [[ "$create" == "1" ]] || return 0
269
+ touch "$gitignore" 2>/dev/null || return 0
270
+ fi
271
+
272
+ if grep -qE '^\.planning/?$' "$gitignore" 2>/dev/null; then
273
+ planning_warn ".gitignore ignores all of .planning/ — plans will not be shared."
274
+ echo " Active plans are what teammates and subagents read. To share them," >&2
275
+ echo " replace the '.planning/' line with '.planning/.archive/'." >&2
276
+ return 0
277
+ fi
278
+
279
+ grep -qE '^\.planning/\.archive/?$' "$gitignore" 2>/dev/null && return 0
280
+
281
+ printf '\n# Completed plans retired by scripts/archive-plan.sh (local history)\n.planning/.archive/\n' >> "$gitignore"
282
+ planning_ok "Added .planning/.archive/ to .gitignore (active plans stay shareable)"
283
+ }
@@ -0,0 +1,177 @@
1
+ // Skill-dir reclaim + drift detection.
2
+ //
3
+ // Why this exists: v1's `persistent-planning install` (removed in v2.0.0) COPIED
4
+ // skills/persistent-planning/ into a consumer's .claude/skills/. The v2+ linker in
5
+ // claude-plugin-runtime only symlinks, and when it finds a real directory at the
6
+ // destination it warns and skips — forever. So v1 leftovers keep executing a frozen
7
+ // copy while the update check reports the (correct, current) plugin version. Two
8
+ // artifacts, nothing reconciling them.
9
+ //
10
+ // The fix is to reclaim the real directory BEFORE the runtime's linker runs: rename
11
+ // it aside, which leaves a clean destination the linker then symlinks normally. That
12
+ // needs no upstream change — see the phase notes for why this lives here for now.
13
+ //
14
+ // Nothing here ever deletes. A reclaimed directory is renamed to
15
+ // <name>.bak-<ISO8601>, and a failure to rename falls through to the old skip.
16
+
17
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
18
+ import { join, relative, resolve, dirname } from "node:path";
19
+
20
+ export const ENV_PREFIX = "PERSISTENT_PLANNING";
21
+
22
+ function lstatSafe(p) {
23
+ try { return lstatSync(p); } catch { return null; }
24
+ }
25
+
26
+ /** Skill directory names shipped by this package. Mirrors the runtime's filter. */
27
+ export function packagedSkillNames(packageRoot, skillsSubdir = "skills") {
28
+ const dir = join(packageRoot, skillsSubdir);
29
+ if (!existsSync(dir)) return [];
30
+ try {
31
+ return readdirSync(dir, { withFileTypes: true })
32
+ .filter((d) => d.isDirectory() && !d.name.endsWith("-workspace"))
33
+ .map((d) => d.name);
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ /**
40
+ * What is actually sitting at .claude/skills/<name>, and is it ours?
41
+ *
42
+ * Returns one of:
43
+ * absent — nothing there; the linker will create the symlink
44
+ * symlink-ok — symlink pointing at this package's copy; healthy
45
+ * symlink-foreign — symlink pointing somewhere else; another install owns it
46
+ * real-dir — a real directory; v1 debris or a hand-managed copy. This is
47
+ * the drift state: nothing in the current system creates one.
48
+ */
49
+ export function describeSkill(consumerRoot, packageRoot, name, skillsSubdir = "skills") {
50
+ const dest = join(consumerRoot, ".claude", "skills", name);
51
+ const src = join(packageRoot, skillsSubdir, name);
52
+ const st = lstatSafe(dest);
53
+ if (!st) return { name, dest, src, state: "absent" };
54
+ if (st.isSymbolicLink()) {
55
+ let target = null;
56
+ try { target = readlinkSync(dest); } catch { /* unreadable link */ }
57
+ const expected = relative(dirname(dest), src);
58
+ if (target === expected) return { name, dest, src, state: "symlink-ok", target };
59
+ // A different-but-equivalent path still resolves to us; treat that as healthy.
60
+ try {
61
+ if (target && resolve(dirname(dest), target) === resolve(src)) {
62
+ return { name, dest, src, state: "symlink-ok", target };
63
+ }
64
+ } catch { /* fall through */ }
65
+ return { name, dest, src, state: "symlink-foreign", target };
66
+ }
67
+ return { name, dest, src, state: "real-dir" };
68
+ }
69
+
70
+ /** Every packaged skill's current state in a consumer repo. */
71
+ export function surveySkills(consumerRoot, packageRoot, skillsSubdir = "skills") {
72
+ return packagedSkillNames(packageRoot, skillsSubdir)
73
+ .map((n) => describeSkill(consumerRoot, packageRoot, n, skillsSubdir));
74
+ }
75
+
76
+ function backupPath(dest) {
77
+ // ISO8601, filesystem-safe. Collisions only within the same second on the same
78
+ // dir, which a suffix resolves.
79
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
80
+ let candidate = `${dest}.bak-${stamp}`;
81
+ let n = 1;
82
+ while (existsSync(candidate)) candidate = `${dest}.bak-${stamp}-${n++}`;
83
+ return candidate;
84
+ }
85
+
86
+ /**
87
+ * Rename any real directory at .claude/skills/<name> aside so the linker can symlink.
88
+ *
89
+ * Idempotent: a healthy symlink is left completely alone, so a second run is a no-op
90
+ * and produces no second backup.
91
+ *
92
+ * Returns { reclaimed: [{name, from, to}], failed: [{name, dest, error}], healthy: n }.
93
+ */
94
+ export function reclaimStaleSkillDirs(consumerRoot, packageRoot, opts = {}) {
95
+ const { skillsSubdir = "skills", log = () => {} } = opts;
96
+ const result = { reclaimed: [], failed: [], healthy: 0 };
97
+
98
+ if (process.env[`${ENV_PREFIX}_NO_RECLAIM`] === "1") {
99
+ log(`[persistent-planning] ${ENV_PREFIX}_NO_RECLAIM=1 — leaving existing skill dirs alone.`);
100
+ return result;
101
+ }
102
+
103
+ for (const info of surveySkills(consumerRoot, packageRoot, skillsSubdir)) {
104
+ if (info.state !== "real-dir") {
105
+ if (info.state === "symlink-ok") result.healthy++;
106
+ continue;
107
+ }
108
+ const to = backupPath(info.dest);
109
+ try {
110
+ renameSync(info.dest, to);
111
+ result.reclaimed.push({ name: info.name, from: info.dest, to });
112
+ log(`[persistent-planning] reclaimed stale skill dir ${info.dest} -> ${to}`);
113
+ } catch (err) {
114
+ // ponytail: never rm -rf a directory a user may have hand-edited. If we cannot
115
+ // move it, say so loudly and leave it — the old skip, but no longer silent.
116
+ result.failed.push({ name: info.name, dest: info.dest, error: err?.message || String(err) });
117
+ log(
118
+ `[persistent-planning] WARNING: ${info.dest} is a real directory, not a symlink,\n` +
119
+ ` so it will keep running a frozen copy of the skill. Could not move it aside:\n` +
120
+ ` ${err?.message || err}\n` +
121
+ ` Move or remove it, then run: npx persistent-planning relink`
122
+ );
123
+ }
124
+ }
125
+ return result;
126
+ }
127
+
128
+ /** Skills still drifted after a reclaim attempt — what SessionStart should warn about. */
129
+ export function driftedSkills(consumerRoot, packageRoot, skillsSubdir = "skills") {
130
+ return surveySkills(consumerRoot, packageRoot, skillsSubdir)
131
+ .filter((s) => s.state === "real-dir" || s.state === "symlink-foreign");
132
+ }
133
+
134
+ // ── Version marker ──────────────────────────────────────────────────────────
135
+ //
136
+ // The drift that started issue #10 was invisible because the update check read the
137
+ // plugin's version while /start-planning executed the skill dir's copy — same number,
138
+ // different artifact. The primary signal is structural (describeSkill: a real dir is
139
+ // always suspect, because nothing in the current system creates one). This marker is
140
+ // the secondary signal: it says how far behind a drifted copy actually is, and it is
141
+ // the only signal available on install shapes where a symlink is impossible.
142
+
143
+ export const MARKER = ".version";
144
+
145
+ /** Version of the skill copy that would actually execute, or null. */
146
+ export function readSkillVersion(consumerRoot, name) {
147
+ try {
148
+ const raw = readFileSync(join(consumerRoot, ".claude", "skills", name, MARKER), "utf8");
149
+ return raw.trim() || null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stamp linked skills with the version they came from.
157
+ *
158
+ * Only ever stamps a healthy symlink — never a real directory. Stamping a real dir
159
+ * would mark stale code as current and defeat the whole check. Best-effort: a
160
+ * read-only package dir (npm cache, plugin cache) just leaves the marker absent,
161
+ * which the drift check treats as "unknown", not "fine".
162
+ */
163
+ export function writeVersionMarkers(consumerRoot, packageRoot, version, opts = {}) {
164
+ const { skillsSubdir = "skills" } = opts;
165
+ if (!version) return { stamped: [], skipped: [] };
166
+ const out = { stamped: [], skipped: [] };
167
+ for (const info of surveySkills(consumerRoot, packageRoot, skillsSubdir)) {
168
+ if (info.state !== "symlink-ok") { out.skipped.push(info.name); continue; }
169
+ try {
170
+ writeFileSync(join(info.dest, MARKER), `${version}\n`, "utf8");
171
+ out.stamped.push(info.name);
172
+ } catch {
173
+ out.skipped.push(info.name);
174
+ }
175
+ }
176
+ return out;
177
+ }