@theglitchking/persistent-planning 3.4.0 → 3.4.1

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.
@@ -6,13 +6,13 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Official marketplace for persistent-planning - Persistent markdown-based planning for Claude Code. Context engineering pattern with task directories, progress tracking, and working memory that persists across sessions.",
9
- "version": "3.4.0"
9
+ "version": "3.4.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "persistent-planning",
14
14
  "description": "Persistent markdown-based planning for Claude Code. Uses on-disk markdown files as working memory for planning, progress tracking, and knowledge storage. Plans persist across sessions and support multiple concurrent tasks.",
15
- "version": "3.4.0",
15
+ "version": "3.4.1",
16
16
  "author": {
17
17
  "name": "TheGlitchKing"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persistent-planning",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "Persistent markdown-based planning with task directories, progress tracking, and context engineering for multi-step Claude Code workflows",
5
5
  "author": {
6
6
  "name": "TheGlitchKing"
@@ -68,6 +68,13 @@ its own.
68
68
  failure is contained.
69
69
  - **Fails open.** Every path is wrapped. A broken migration must never cost someone
70
70
  their session start.
71
+ - **Never let a dependency be fatal.** Load the runtime with `await import()` in a
72
+ try/catch. A static import makes an unresolvable dependency fatal for the whole hook,
73
+ including the repair — the delivery path must survive what it is delivering a fix for
74
+ (#18). Only the update check genuinely needs the runtime.
75
+ - **Stamp on success, never on failure.** Marking a version done after a failed attempt
76
+ turns a transient problem into a permanent one: the repair never runs again, even once
77
+ the cause is gone. Retrying costs one spawn per session while genuinely drifted.
71
78
  - **One stdout write.** The runtime owns the single `SessionStart` JSON response and
72
79
  exposes no append hook; a second response line is invalid. Merge into the existing
73
80
  intercept.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.4.1] - 2026-09-04
6
+
7
+ ### Fixed — the SessionStart hook could not survive a missing runtime (#18)
8
+
9
+ `hooks/session-start.js` imported `@theglitchking/claude-plugin-runtime` statically. That
10
+ package resolves out of the shared `~/.claude/plugins/npm-cache/`, which a marketplace
11
+ plugin cache does not carry itself, so when it could not resolve the **entire hook died** —
12
+ `ERR_MODULE_NOT_FOUND` before any of its work ran.
13
+
14
+ That took down the drift warning and `autoRepair` along with the update check: the two
15
+ things #10 built precisely so a fix could reach an install that is already broken. The
16
+ delivery path had a single point of failure the payload was meant to survive.
17
+
18
+ - **The runtime is loaded lazily now**, in a try/catch returning `null`. A resolution
19
+ failure costs only the update check; the completion nudge, drift warning and repair all
20
+ still run, and the hook emits its own valid `SessionStart` payload since nothing else
21
+ will.
22
+ - **A failed repair no longer stamps the version.** `autoRepair` previously stamped
23
+ `repairedSkillsForVersion` regardless of outcome, to avoid retrying every session — which
24
+ turned a *transient* failure into a permanent one. Reproduced: one session without a
25
+ resolvable runtime marked 3.4.0 done, and the repair was then skipped forever even once
26
+ the runtime returned. It stamps on success only; a failure costs one spawn per session
27
+ while genuinely drifted, and the drift warning fires alongside it naming the manual fix.
28
+
29
+ Both defects came from the #10 work and shipped in 3.4.0. Test suite grows to 125
30
+ assertions, including a fixture with no `node_modules` at all.
31
+
5
32
  ## [3.4.0] - 2026-09-04
6
33
 
7
34
  ### Changed — only completed plans are gitignored (#15)
@@ -3,6 +3,11 @@
3
3
  //
4
4
  // Three jobs:
5
5
  // 1. The update check, delegated entirely to @theglitchking/claude-plugin-runtime.
6
+ // That import is dynamic on purpose: the runtime resolves out of the shared
7
+ // ~/.claude/plugins/npm-cache/, which is not guaranteed to be populated. A
8
+ // static import made an unresolvable runtime fatal for the WHOLE hook, taking
9
+ // jobs 2-4 down with it — including the repair that is the only way a fix
10
+ // reaches an already-broken install. Jobs 2-4 need no runtime at all.
6
11
  // 2. A completion nudge: if any plan under .planning/ has every box checked
7
12
  // but is still sitting in the active tree, say so, so the agent archives it
8
13
  // instead of rediscovering a finished plan next session.
@@ -14,13 +19,23 @@
14
19
  // node_modules in the plugin cache and never runs npm postinstall -- so it is
15
20
  // the only delivery path a fix can reach existing broken installs through.
16
21
 
17
- import { runSessionStart } from "@theglitchking/claude-plugin-runtime";
18
22
  import { spawnSync } from "node:child_process";
19
23
  import { dirname, join, resolve } from "node:path";
20
24
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
25
  import { fileURLToPath } from "node:url";
22
26
  import { driftedSkills, readSkillVersion } from "../scripts/lib/skill-link.js";
23
27
 
28
+ // Resolve the runtime lazily. Returns null when it cannot be loaded, which costs
29
+ // only the update check — never the drift warning or the repair.
30
+ async function loadRunSessionStart() {
31
+ try {
32
+ const mod = await import("@theglitchking/claude-plugin-runtime");
33
+ return typeof mod?.runSessionStart === "function" ? mod.runSessionStart : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
24
39
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
25
40
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
41
 
@@ -96,14 +111,18 @@ function autoRepair() {
96
111
  timeout: 10000,
97
112
  });
98
113
 
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.
114
+ // Stamp only on success. Stamping a failure turns a transient problem — an
115
+ // unresolvable runtime, a locked file — into a permanent one: the version is
116
+ // marked done and the repair never runs again, even once the cause is gone.
117
+ // A failure costs one spawn per session while genuinely drifted, and the drift
118
+ // warning fires alongside it naming the manual fix, so nobody is stuck silently.
119
+ const remaining = driftedSkills(projectRoot, packageRoot, "skills");
120
+ if (remaining.length || r.status !== 0) return ""; // let driftNudge() report it
121
+
101
122
  try {
102
123
  writeFileSync(CONFIG, JSON.stringify({ ...cfg, repairedSkillsForVersion: version }, null, 2) + "\n", "utf8");
103
- } catch { /* config unwritable — the drift warning still fires every session */ }
124
+ } catch { /* config unwritable — worst case the repair is attempted again */ }
104
125
 
105
- const remaining = driftedSkills(projectRoot, packageRoot, "skills");
106
- if (remaining.length || r.status !== 0) return ""; // let driftNudge() report it
107
126
  return "[persistent-planning] repaired a stale skill directory (originals kept as .bak-*).";
108
127
  } catch {
109
128
  return "";
@@ -147,12 +166,27 @@ function driftNudge() {
147
166
  const repaired = autoRepair();
148
167
  const notice = [planNudge(), repaired, driftNudge()].filter(Boolean).join("\n\n");
149
168
 
150
- if (!notice) {
151
- await runSessionStart({
152
- packageName: "@theglitchking/persistent-planning",
153
- pluginName: "persistent-planning",
154
- configFile: "persistent-planning.json",
155
- });
169
+ const runSessionStart = await loadRunSessionStart();
170
+ const RUNTIME_OPTS = {
171
+ packageName: "@theglitchking/persistent-planning",
172
+ pluginName: "persistent-planning",
173
+ configFile: "persistent-planning.json",
174
+ };
175
+
176
+ function emitNoticeOnly() {
177
+ // No runtime, so nobody else will emit the response. Say our piece and stop.
178
+ if (!notice) return;
179
+ process.stdout.write(
180
+ JSON.stringify({
181
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: notice },
182
+ }) + "\n"
183
+ );
184
+ }
185
+
186
+ if (!runSessionStart) {
187
+ emitNoticeOnly();
188
+ } else if (!notice) {
189
+ await runSessionStart(RUNTIME_OPTS);
156
190
  } else {
157
191
  // ponytail: the runtime owns the one SessionStart JSON response and exposes no
158
192
  // hook for appending to it, and a second response line on stdout is invalid.
@@ -179,11 +213,7 @@ if (!notice) {
179
213
  return realWrite(chunk, ...rest);
180
214
  };
181
215
 
182
- await runSessionStart({
183
- packageName: "@theglitchking/persistent-planning",
184
- pluginName: "persistent-planning",
185
- configFile: "persistent-planning.json",
186
- });
216
+ await runSessionStart(RUNTIME_OPTS);
187
217
 
188
218
  // The runtime can return without emitting (e.g. it chdir-failed). Say our piece.
189
219
  if (!merged) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/persistent-planning",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
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": {