@theglitchking/persistent-planning 3.0.1 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.documentation/INDEX.md +224 -0
- package/.documentation/README.md +90 -0
- package/.documentation/REGISTRY.md +80 -0
- package/.documentation/agents/INDEX.md +43 -0
- package/.documentation/agents/REGISTRY.md +38 -0
- package/.documentation/api/INDEX.md +43 -0
- package/.documentation/api/REGISTRY.md +38 -0
- package/.documentation/architecture/INDEX.md +50 -0
- package/.documentation/architecture/REGISTRY.md +57 -0
- package/{docs/reference.md → .documentation/architecture/context-engineering-principles.md} +20 -1
- package/{docs → .documentation/architecture}/lg-mode.md +33 -8
- package/.documentation/architecture/lg-plan-artifact-lifecycle.md +104 -0
- package/.documentation/architecture/skill-linking-and-reclaim.md +84 -0
- package/.documentation/archive/README.md +46 -0
- package/.documentation/backups/INDEX.md +43 -0
- package/.documentation/backups/REGISTRY.md +38 -0
- package/.documentation/database/INDEX.md +43 -0
- package/.documentation/database/REGISTRY.md +38 -0
- package/.documentation/devops/INDEX.md +43 -0
- package/.documentation/devops/REGISTRY.md +38 -0
- package/.documentation/drafts/.gitkeep +1 -0
- package/.documentation/features/INDEX.md +43 -0
- package/.documentation/features/REGISTRY.md +38 -0
- package/.documentation/plans/INDEX.md +43 -0
- package/.documentation/plans/REGISTRY.md +38 -0
- package/.documentation/procedures/INDEX.md +43 -0
- package/.documentation/procedures/REGISTRY.md +53 -0
- package/.documentation/procedures/plan-completion-and-archive.md +185 -0
- package/.documentation/procedures/plugin-update-delivery.md +99 -0
- package/.documentation/quickstart/INDEX.md +42 -0
- package/.documentation/quickstart/REGISTRY.md +51 -0
- package/{docs → .documentation/quickstart}/examples.md +20 -0
- package/.documentation/reference/INDEX.md +49 -0
- package/.documentation/reference/REGISTRY.md +55 -0
- package/.documentation/reference/mandatory-frontmatter.md +71 -0
- package/.documentation/reference/skill-version-marker.md +70 -0
- package/{docs → .documentation/reference}/workspace-json.md +11 -5
- package/.documentation/security/INDEX.md +43 -0
- package/.documentation/security/REGISTRY.md +38 -0
- package/.documentation/standards/INDEX.md +43 -0
- package/.documentation/standards/REGISTRY.md +53 -0
- package/{docs → .documentation/standards}/atom-granularity.md +12 -5
- package/.documentation/standards/mandatory-closing-phases.md +145 -0
- package/.documentation/testing/INDEX.md +42 -0
- package/.documentation/testing/REGISTRY.md +51 -0
- package/.documentation/testing/test-suite.md +126 -0
- package/.documentation/troubleshooting/INDEX.md +43 -0
- package/.documentation/troubleshooting/REGISTRY.md +53 -0
- package/.documentation/troubleshooting/plan-never-reads-complete.md +109 -0
- package/.documentation/troubleshooting/stale-skill-dir-drift.md +115 -0
- package/.documentation/workflows/INDEX.md +43 -0
- package/.documentation/workflows/REGISTRY.md +38 -0
- package/CHANGELOG.md +198 -0
- package/README.md +67 -7
- package/bin/persistent-planning.js +56 -6
- package/commands/archive-plan.md +66 -0
- package/commands/plan-status.md +62 -0
- package/commands/start-atom.md +3 -3
- package/commands/start-planning.md +2 -2
- package/commands/start-task.md +2 -2
- package/hooks/session-start.js +191 -8
- package/package.json +6 -3
- package/scripts/archive-plan.sh +143 -0
- package/scripts/init-atom.sh +7 -0
- package/scripts/init-phase.sh +58 -6
- package/scripts/init-planning.sh +39 -4
- package/scripts/init-task.sh +7 -0
- package/scripts/lib/planning.sh +122 -0
- package/scripts/lib/skill-link.js +177 -0
- package/scripts/link-skills.js +38 -0
- package/scripts/plan-status.sh +196 -0
- package/skills/persistent-planning/SKILL.md +37 -9
- package/templates/lg/phase.md +22 -3
- package/templates/lg/task.md +4 -3
package/scripts/lib/planning.sh
CHANGED
|
@@ -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
|
+
}
|
package/scripts/link-skills.js
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Postinstall — delegates to @theglitchking/claude-plugin-runtime.
|
|
3
3
|
// See https://github.com/TheGlitchKing/claude-plugin-runtime/blob/main/docs/PLUGIN_AUTHORING_SCAFFOLD.md
|
|
4
|
+
//
|
|
5
|
+
// One thing runs before the delegation: reclaimStaleSkillDirs(). The runtime's linker
|
|
6
|
+
// skips a destination that is a real directory rather than a symlink, and skips it
|
|
7
|
+
// forever — so v1 leftovers never heal. Renaming the real dir aside first leaves a
|
|
8
|
+
// clean destination the runtime then links normally.
|
|
4
9
|
|
|
5
10
|
import { runPostinstall } from "@theglitchking/claude-plugin-runtime";
|
|
6
11
|
import { dirname, resolve } from "node:path";
|
|
7
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { reclaimStaleSkillDirs, writeVersionMarkers, ENV_PREFIX } from "./lib/skill-link.js";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
8
15
|
|
|
9
16
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const consumerRoot = process.env.INIT_CWD || process.cwd();
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// Skip when the runtime itself would skip: dev-in-place, or linking disabled.
|
|
21
|
+
const devInPlace = resolve(consumerRoot) === resolve(packageRoot);
|
|
22
|
+
if (!devInPlace && process.env[`${ENV_PREFIX}_SKIP_LINK`] !== "1") {
|
|
23
|
+
const r = reclaimStaleSkillDirs(consumerRoot, packageRoot, {
|
|
24
|
+
skillsSubdir: "skills",
|
|
25
|
+
log: (m) => console.log(m),
|
|
26
|
+
});
|
|
27
|
+
if (r.reclaimed.length) {
|
|
28
|
+
console.log(
|
|
29
|
+
`[persistent-planning] reclaimed ${r.reclaimed.length} stale skill dir(s); ` +
|
|
30
|
+
`originals kept alongside as .bak-* — delete them once you are satisfied.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Reclaim is best-effort. A failure here must never block the install.
|
|
36
|
+
console.warn(`[persistent-planning] skill reclaim skipped: ${err?.message || err}`);
|
|
37
|
+
}
|
|
10
38
|
|
|
11
39
|
try {
|
|
12
40
|
runPostinstall({
|
|
@@ -21,3 +49,13 @@ try {
|
|
|
21
49
|
} catch (err) {
|
|
22
50
|
console.warn(`[persistent-planning] postinstall failed: ${err?.message || err}`);
|
|
23
51
|
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Stamp the artifact that actually executes, so drift is detectable at all.
|
|
55
|
+
// Only healthy symlinks are stamped — never a real dir, which would mark stale
|
|
56
|
+
// code as current.
|
|
57
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
58
|
+
writeVersionMarkers(consumerRoot, packageRoot, version, { skillsSubdir: "skills" });
|
|
59
|
+
} catch {
|
|
60
|
+
// Marker is a diagnostic aid, not a dependency. Absent is a valid state.
|
|
61
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
###############################################################################
|
|
3
|
+
# plan-status.sh - Report completion status for every plan under .planning/
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# bash scripts/plan-status.sh # table of every active plan
|
|
7
|
+
# bash scripts/plan-status.sh --nudge # one line if plans are archivable, else silence
|
|
8
|
+
# bash scripts/plan-status.sh --complete # print the slug of each complete plan, one per line
|
|
9
|
+
#
|
|
10
|
+
# A plan is COMPLETE when every checkbox in it is checked, or when its
|
|
11
|
+
# top-level artifact declares `status: done` in frontmatter. Completion is
|
|
12
|
+
# derived from the artifacts themselves so no separate state file can drift
|
|
13
|
+
# away from what the plan actually says.
|
|
14
|
+
#
|
|
15
|
+
# This is the single implementation of the scan: the SessionStart hook and the
|
|
16
|
+
# /plan-status slash command both shell out to it.
|
|
17
|
+
###############################################################################
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
source "${SCRIPT_DIR}/lib/planning.sh"
|
|
22
|
+
|
|
23
|
+
MODE="table"
|
|
24
|
+
case "${1:-}" in
|
|
25
|
+
--nudge) MODE="nudge" ;;
|
|
26
|
+
--complete) MODE="complete" ;;
|
|
27
|
+
-h|--help)
|
|
28
|
+
sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
29
|
+
exit 0
|
|
30
|
+
;;
|
|
31
|
+
"") ;;
|
|
32
|
+
*)
|
|
33
|
+
planning_err "Unknown option: $1 (try --help)"
|
|
34
|
+
exit 1
|
|
35
|
+
;;
|
|
36
|
+
esac
|
|
37
|
+
|
|
38
|
+
PLANNING="$(planning_dir)"
|
|
39
|
+
[[ -d "$PLANNING" ]] || { [[ "$MODE" == "table" ]] && planning_warn "No .planning/ directory here."; exit 0; }
|
|
40
|
+
|
|
41
|
+
# plan_root_artifact <plan-dir> -> path of the plan's top-level file, or empty
|
|
42
|
+
plan_root_artifact() {
|
|
43
|
+
for f in "$1/phase.md" "$1/task_plan.md" "$1/task.md"; do
|
|
44
|
+
[[ -f "$f" ]] && { echo "$f"; return; }
|
|
45
|
+
done
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# frontmatter_field <file> <key> -> value of `<key>:` inside the leading --- block
|
|
49
|
+
#
|
|
50
|
+
# Scoped to the frontmatter on purpose. Grepping a whole file for a frontmatter field
|
|
51
|
+
# matches it in prose, tables and fenced examples too — a notes.md documenting the
|
|
52
|
+
# contract then reads as an artifact that declares it (issue #14).
|
|
53
|
+
frontmatter_field() {
|
|
54
|
+
[[ -f "$1" ]] || return
|
|
55
|
+
awk -v key="$2" '
|
|
56
|
+
NR==1 && $0 != "---" { exit }
|
|
57
|
+
NR>1 && $0 == "---" { exit }
|
|
58
|
+
index($0, key ":") == 1 {
|
|
59
|
+
sub("^" key ":[[:space:]]*", "")
|
|
60
|
+
gsub(/["\047]/, "")
|
|
61
|
+
sub(/[[:space:]]+$/, "")
|
|
62
|
+
print; exit
|
|
63
|
+
}' "$1"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# frontmatter_status <file> -> value of `status:` inside the leading --- block
|
|
67
|
+
frontmatter_status() {
|
|
68
|
+
frontmatter_field "$1" "status"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Emits "<total> <checked>" for every markdown file under a plan directory.
|
|
72
|
+
#
|
|
73
|
+
# Checkboxes inside fenced code blocks are examples, not work. A plan whose notes.md
|
|
74
|
+
# quotes markdown — which is what notes.md is for — used to have those quoted boxes
|
|
75
|
+
# counted against it, and a quoted `- [ ]` inflates only the denominator, so the plan
|
|
76
|
+
# became permanently uncompletable with no visible reason (issue #14).
|
|
77
|
+
#
|
|
78
|
+
# An unterminated fence swallows the rest of the file. That is the safe direction:
|
|
79
|
+
# ambiguous content is not counted as outstanding work.
|
|
80
|
+
count_boxes() {
|
|
81
|
+
local total=0 checked=0 f out
|
|
82
|
+
while IFS= read -r f; do
|
|
83
|
+
[[ -n "$f" ]] || continue
|
|
84
|
+
out=$(awk '
|
|
85
|
+
# Fence open/close: ``` or ~~~, optionally indented, optional info string.
|
|
86
|
+
# A fence closes only on the same character.
|
|
87
|
+
/^[[:space:]]*(```|~~~)/ {
|
|
88
|
+
line = $0
|
|
89
|
+
sub(/^[[:space:]]*/, "", line)
|
|
90
|
+
ch = substr(line, 1, 1)
|
|
91
|
+
if (!infence) { infence = 1; fencechar = ch }
|
|
92
|
+
else if (ch == fencechar) { infence = 0 }
|
|
93
|
+
next
|
|
94
|
+
}
|
|
95
|
+
infence { next }
|
|
96
|
+
/^[[:space:]]*- \[[ xX]\] / { t++ }
|
|
97
|
+
/^[[:space:]]*- \[[xX]\] / { c++ }
|
|
98
|
+
END { print t + 0, c + 0 }
|
|
99
|
+
' "$f" 2>/dev/null) || continue
|
|
100
|
+
total=$((total + ${out%% *}))
|
|
101
|
+
checked=$((checked + ${out##* }))
|
|
102
|
+
done < <(find "$1" -type f -name '*.md' 2>/dev/null)
|
|
103
|
+
echo "$total $checked"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Files under a plan whose FRONTMATTER declares <key>: <value>.
|
|
107
|
+
frontmatter_files_with() {
|
|
108
|
+
local dir="$1" key="$2" want="$3" f
|
|
109
|
+
while IFS= read -r f; do
|
|
110
|
+
[[ -n "$f" ]] || continue
|
|
111
|
+
[[ "$(frontmatter_field "$f" "$key")" == "$want" ]] && echo "$f"
|
|
112
|
+
done < <(find "$dir" -type f -name '*.md' 2>/dev/null)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Counts mandatory tasks under a plan that are not yet done.
|
|
116
|
+
#
|
|
117
|
+
# The two closing tasks carry `mandatory: true`. A plan cannot be COMPLETE while one
|
|
118
|
+
# of them is unfinished, however the boxes happen to add up — that gate was stated in
|
|
119
|
+
# the templates and enforced nowhere (issue #12).
|
|
120
|
+
#
|
|
121
|
+
# Additive by design: a plan with no `mandatory: true` artifacts behaves exactly as it
|
|
122
|
+
# did before. Retroactively reopening finished plans would be worse than the bug.
|
|
123
|
+
unfinished_mandatory() {
|
|
124
|
+
local n=0 f st
|
|
125
|
+
while IFS= read -r f; do
|
|
126
|
+
[[ -n "$f" ]] || continue
|
|
127
|
+
st=$(frontmatter_status "$f")
|
|
128
|
+
[[ "$st" == "done" || "$st" == "archived" ]] || n=$((n + 1))
|
|
129
|
+
done < <(frontmatter_files_with "$1" "mandatory" "true")
|
|
130
|
+
echo "$n"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
COMPLETE=()
|
|
134
|
+
ROWS=()
|
|
135
|
+
|
|
136
|
+
for plan_path in "$PLANNING"/*/; do
|
|
137
|
+
[[ -d "$plan_path" ]] || continue
|
|
138
|
+
slug=$(basename "$plan_path")
|
|
139
|
+
case "$slug" in .meta|.archive|archive) continue ;; esac
|
|
140
|
+
|
|
141
|
+
root=$(plan_root_artifact "${plan_path%/}")
|
|
142
|
+
[[ -n "$root" ]] || continue
|
|
143
|
+
|
|
144
|
+
read -r total checked <<<"$(count_boxes "${plan_path%/}")"
|
|
145
|
+
status=$(frontmatter_status "$root")
|
|
146
|
+
blocked=$(frontmatter_files_with "${plan_path%/}" "status" "blocked" | wc -l | tr -d ' ')
|
|
147
|
+
pending_mandatory=$(unfinished_mandatory "${plan_path%/}")
|
|
148
|
+
|
|
149
|
+
if [[ "$status" == "archived" ]]; then
|
|
150
|
+
verdict="archived"
|
|
151
|
+
elif [[ "$status" == "done" ]] || { [[ "$total" -gt 0 ]] && [[ "$checked" -eq "$total" ]]; }; then
|
|
152
|
+
if [[ "$pending_mandatory" -gt 0 ]]; then
|
|
153
|
+
# Every box ticked, but a mandatory closer is not done. Not complete.
|
|
154
|
+
verdict="in progress"
|
|
155
|
+
else
|
|
156
|
+
verdict="COMPLETE"
|
|
157
|
+
COMPLETE+=("$slug")
|
|
158
|
+
fi
|
|
159
|
+
elif [[ "$blocked" -gt 0 ]]; then
|
|
160
|
+
verdict="blocked"
|
|
161
|
+
elif [[ "$total" -eq 0 ]]; then
|
|
162
|
+
verdict="empty"
|
|
163
|
+
else
|
|
164
|
+
verdict="in progress"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
ROWS+=("$(printf '%-38s %-12s %s/%s boxes' "$slug" "$verdict" "$checked" "$total")")
|
|
168
|
+
done
|
|
169
|
+
|
|
170
|
+
case "$MODE" in
|
|
171
|
+
complete)
|
|
172
|
+
printf '%s\n' "${COMPLETE[@]+"${COMPLETE[@]}"}"
|
|
173
|
+
;;
|
|
174
|
+
nudge)
|
|
175
|
+
if [[ ${#COMPLETE[@]} -gt 0 ]]; then
|
|
176
|
+
printf 'persistent-planning: %d plan(s) complete but not archived — %s. Archive with: /archive-plan <slug>\n' \
|
|
177
|
+
"${#COMPLETE[@]}" "$(IFS=', '; echo "${COMPLETE[*]}")"
|
|
178
|
+
fi
|
|
179
|
+
;;
|
|
180
|
+
table)
|
|
181
|
+
if [[ ${#ROWS[@]} -eq 0 ]]; then
|
|
182
|
+
planning_warn "No plans found under .planning/."
|
|
183
|
+
exit 0
|
|
184
|
+
fi
|
|
185
|
+
planning_log "Plans under .planning/"
|
|
186
|
+
echo ""
|
|
187
|
+
printf ' %-38s %-12s %s\n' "PLAN" "STATUS" "PROGRESS"
|
|
188
|
+
printf ' %s\n' "$(printf '%.0s-' {1..70})"
|
|
189
|
+
printf ' %s\n' "${ROWS[@]}"
|
|
190
|
+
echo ""
|
|
191
|
+
if [[ ${#COMPLETE[@]} -gt 0 ]]; then
|
|
192
|
+
planning_ok "${#COMPLETE[@]} plan(s) ready to archive:"
|
|
193
|
+
for s in "${COMPLETE[@]}"; do echo " bash scripts/archive-plan.sh $s"; done
|
|
194
|
+
fi
|
|
195
|
+
;;
|
|
196
|
+
esac
|
|
@@ -160,6 +160,19 @@ Create this file FIRST for any complex task:
|
|
|
160
160
|
- [ ] Phase 2: Research/gather information
|
|
161
161
|
- [ ] Phase 3: Execute/build
|
|
162
162
|
- [ ] Phase 4: Review and deliver
|
|
163
|
+
- [ ] Phase 5: Validate success through comprehensive testing (MANDATORY)
|
|
164
|
+
- [ ] 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)
|
|
165
|
+
|
|
166
|
+
> Phases 5 and 6 are mandatory and MUST stay the last two phases of this plan.
|
|
167
|
+
> Insert new work above them; never after. Renumber them if you add phases.
|
|
168
|
+
>
|
|
169
|
+
> - **Validate success**: prove the work with tests that fail if the change breaks.
|
|
170
|
+
> - **Documentation pass**: create/update/deprecate every doc this change touches --
|
|
171
|
+
> what it is, where it lives, how to fix it, how to operate it, why it matters.
|
|
172
|
+
> If hit-em-with-the-docs is installed, you MUST use it for this pass --
|
|
173
|
+
> `npx hewtd integrate <file> -a` to create, `npx hewtd archive <file>` to retire,
|
|
174
|
+
> `npx hewtd maintain --quick` to regenerate indexes. Never hand-edit INDEX.md or
|
|
175
|
+
> REGISTRY.md; they are generated.
|
|
163
176
|
|
|
164
177
|
## Key Questions
|
|
165
178
|
1. [Question to answer]
|
|
@@ -217,17 +230,29 @@ Large outputs go to files, not context. Keep only paths in working memory.
|
|
|
217
230
|
### 5. Log All Errors
|
|
218
231
|
Every error goes in the "Errors Encountered" section. This builds knowledge for future tasks.
|
|
219
232
|
|
|
220
|
-
### 6.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
233
|
+
### 6. Archive on Completion, Don't Delete
|
|
234
|
+
A plan is **complete** when every checkbox in it is checked (or its top-level
|
|
235
|
+
artifact declares `status: done`). Completion is derived from the artifacts, so
|
|
236
|
+
checking the last box IS the completion signal -- there is no separate state to
|
|
237
|
+
update and no way for it to drift.
|
|
225
238
|
|
|
226
|
-
|
|
239
|
+
When a plan is complete, archive it rather than deleting it:
|
|
227
240
|
```bash
|
|
228
|
-
|
|
241
|
+
/plan-status # which plans are COMPLETE?
|
|
242
|
+
/archive-plan [task-name] # -> .planning/.archive/[task-name]/
|
|
229
243
|
```
|
|
230
244
|
|
|
245
|
+
Archiving moves the plan to the gitignored `.planning/.archive/` and stamps it
|
|
246
|
+
`status: archived` + `archived_on`. The active `.planning/` tree then shows only
|
|
247
|
+
live work. Nothing is destroyed -- restore with
|
|
248
|
+
`mv .planning/.archive/[task-name] .planning/[task-name]`.
|
|
249
|
+
|
|
250
|
+
The SessionStart hook checks for complete-but-unarchived plans and says so at the
|
|
251
|
+
top of the session, so a finished plan gets retired instead of rediscovered.
|
|
252
|
+
|
|
253
|
+
Deleting is still available (`rm -rf .planning/[task-name]/`) but throws away the
|
|
254
|
+
record of what was done and why. Prefer the archive.
|
|
255
|
+
|
|
231
256
|
## When to Use This Pattern
|
|
232
257
|
|
|
233
258
|
**Use 3-file pattern for:**
|
|
@@ -254,14 +279,17 @@ rm -rf .planning/
|
|
|
254
279
|
| Overwrite previous plans | Use `/start-planning` (auto-creates unique task dir) |
|
|
255
280
|
| Start executing immediately | Run `/start-planning "task name"` FIRST |
|
|
256
281
|
| Switch tasks without saving | Update task_plan.md before switching |
|
|
282
|
+
| `rm -rf` a finished plan | `/archive-plan <slug>` -- keeps the record, clears the tree |
|
|
283
|
+
| Leave finished plans in `.planning/` | Archive them; the active tree should show only live work |
|
|
284
|
+
| Track completion in your head or in chat | Check the boxes -- the artifact is the status |
|
|
257
285
|
|
|
258
286
|
## Advanced Patterns
|
|
259
287
|
|
|
260
|
-
See [
|
|
288
|
+
See [Manus Context Engineering Principles](../../.documentation/architecture/context-engineering-principles.md) for:
|
|
261
289
|
- Attention manipulation techniques
|
|
262
290
|
- Error recovery patterns
|
|
263
291
|
- Context optimization principles
|
|
264
292
|
|
|
265
|
-
See [
|
|
293
|
+
See [Worked Examples](../../.documentation/quickstart/examples.md) for:
|
|
266
294
|
- Real task examples
|
|
267
295
|
- Complex workflow patterns
|
package/templates/lg/phase.md
CHANGED
|
@@ -20,7 +20,21 @@ Each task is a bounded deliverable. Tasks default to dependencies-first scheduli
|
|
|
20
20
|
Tasks with no inter-dependencies can be marked `parallelizable: true` in their frontmatter
|
|
21
21
|
so subagent teams can pick them up concurrently.]
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
_(no tasks yet — run `/start-task "Task name" --parent PHASE_SLUG_PLACEHOLDER`)_
|
|
24
|
+
- [ ] **Validate success through comprehensive testing** (`validate-success-through-comprehensive-testing`) (MANDATORY — second-to-last)
|
|
25
|
+
- [ ] **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..** (`documentation-pass-create-update-deprecate-docs`) (MANDATORY — last)
|
|
26
|
+
|
|
27
|
+
> The two mandatory closing tasks above MUST remain the last two tasks of this phase.
|
|
28
|
+
> Add new tasks above them; never after. A phase cannot be marked `done` until both are
|
|
29
|
+
> `done`, and neither may be `parallelizable` — they gate on everything before them.
|
|
30
|
+
>
|
|
31
|
+
> - **Validate success**: prove the phase's work with tests that fail if it breaks.
|
|
32
|
+
> - **Documentation pass**: create/update/deprecate every doc this phase touches —
|
|
33
|
+
> what it is, where it lives, how to fix it, how to operate it, why it matters.
|
|
34
|
+
> If hit-em-with-the-docs is installed, you MUST use it for this pass --
|
|
35
|
+
> `npx hewtd integrate <file> -a` to create, `npx hewtd archive <file>` to retire,
|
|
36
|
+
> `npx hewtd maintain --quick` to regenerate indexes. Never hand-edit INDEX.md or
|
|
37
|
+
> REGISTRY.md; they are generated.
|
|
24
38
|
|
|
25
39
|
## Decisions Made
|
|
26
40
|
[Phase-level decisions that affect multiple tasks. Format: `**Decision**: rationale`.]
|
|
@@ -30,8 +44,13 @@ so subagent teams can pick them up concurrently.]
|
|
|
30
44
|
|
|
31
45
|
Status enum: `draft | active | paused | done | archived`
|
|
32
46
|
|
|
33
|
-
When all tasks are `done`, mark this phase `done`.
|
|
34
|
-
|
|
47
|
+
When all tasks are `done`, mark this phase `done`. Checking every box in this phase
|
|
48
|
+
IS the completion signal — `/plan-status` reads it straight off the artifacts.
|
|
49
|
+
|
|
50
|
+
When complete, retire it with `/archive-plan PHASE_SLUG_PLACEHOLDER`: the whole
|
|
51
|
+
directory (tasks and atoms with it) moves to the gitignored
|
|
52
|
+
`.planning/.archive/PHASE_SLUG_PLACEHOLDER/` and the frontmatter above is stamped
|
|
53
|
+
`status: archived` + `archived_on`. Nothing is destroyed.
|
|
35
54
|
|
|
36
55
|
---
|
|
37
56
|
|