pan-wizard 3.21.1 → 3.24.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 (136) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. package/scripts/run-tests.cjs +11 -4
@@ -625,24 +625,39 @@ function renderCommandStack(d) {
625
625
  ? s.agents.map(ag => `<div class="ag"><span class="amono">↳ ${esc(ag.name)}</span>`
626
626
  + `<span class="amono ${ag.active ? 'okc' : 'dim'}">${ag.active ? `${ag.calls} calls · ${fmtTokens(ag.tokens)} tok` : 'idle'}</span></div>`).join('')
627
627
  : '<div class="ag"><span class="amono dim">git-tool driven · no agents</span></div>';
628
+ // `tier` and `access` are reproduced verbatim from squads.cjs, and both are
629
+ // advisory there: `tier` is a squad grouping attribute (the active
630
+ // `model_profile` is what resolves a model), and `access` is the intended
631
+ // contract the conductor's prompt is told to honour, not a sandbox. The
632
+ // tooltip says so, because a bare `read-only` badge in a dashboard reads as
633
+ // enforcement to anyone who has not read the module.
628
634
  return `
629
635
  <div class="squad" style="border-left:3px solid ${color}">
630
636
  <div class="squad-head">
631
637
  <span class="squad-name"><span class="dot" style="background:${color}"></span>${esc(s.label)}
632
- <span class="amono dim">· ${esc(s.tier)} · ${esc(s.access)}</span></span>
638
+ <span class="amono dim" title="Advisory labels from squads.cjs: tier is a squad grouping (the active model_profile resolves the model), and access is the contract the conductor is instructed to honour — the binding tool grant is each agent's own frontmatter.">· ${esc(s.tier)} · ${esc(s.access)}</span></span>
633
639
  ${pillEl}
634
640
  </div>
635
641
  <div class="squad-sum">${esc(s.summary)}</div>
636
642
  ${drill}
637
643
  </div>`;
638
644
  }).join('');
645
+ // The coordinator strap is deliberately narrow, because both halves of what it
646
+ // used to say were wrong. It does NOT name a model family: the agent file
647
+ // (`agents/pan-conductor.md`) carries no `model:` field, so it resolves to
648
+ // `inherit` and runs on whatever model launched the session — PAN selects
649
+ // nothing. And it says delegation-*first*, not delegation-only: that agent's
650
+ // `tools:` grant includes `Write` and `Bash`, and nothing strips them (squads.cjs
651
+ // is a registry + resolver). Routing rather than coding is how the conductor is
652
+ // instructed to behave, not something the runtime prevents. Do not re-add a
653
+ // family name or an enforcement word here without changing the agent file first.
639
654
  return `
640
655
  <section class="panel">
641
656
  <div class="ph">command stack — live</div>
642
657
  <div class="coord">
643
658
  <span class="dot" style="background:var(--coral)"></span>
644
659
  <span class="cname">Mission Control</span>
645
- <span class="amono dim">· ${esc(a.coordinator)} · opus · reasoning · delegation-only</span>
660
+ <span class="amono dim">· ${esc(a.coordinator)} · session model · delegation-first</span>
646
661
  <span class="mc-state">${anyActive ? 'delegating' : 'idle'}</span>
647
662
  </div>
648
663
  <div class="squads">${squadCards}</div>
@@ -3,11 +3,10 @@
3
3
  */
4
4
 
5
5
  const fs = require('fs');
6
- const os = require('os');
7
6
  const path = require('path');
8
7
  const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit } = require('./core.cjs');
9
8
  const { PLANNING_DIR, PHASES_DIR, CODEBASE_DIR, QUICK_DIR, MILESTONES_DIR, STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PROJECT_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isResearchFile, isContextFile, isVerificationFile, PLAN_SUFFIX, SUMMARY_SUFFIX, CONTEXT_SUFFIX, RESEARCH_SUFFIX, VERIFICATION_SUFFIX, UAT_SUFFIX, MAX_SLUG_LENGTH } = require('./constants.cjs');
10
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey } = require('./utils.cjs');
9
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey, parsePhaseDir } = require('./utils.cjs');
11
10
  const { classifyPlanTier } = require('./phase.cjs');
12
11
  const { extractFrontmatter } = require('./frontmatter.cjs');
13
12
  const { detectLanguages } = require('./codebase.cjs');
@@ -768,9 +767,7 @@ function scanAllPhases(cwd) {
768
767
  const dirNames = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort();
769
768
 
770
769
  for (const dirName of dirNames) {
771
- const dirMatch = dirName.match(/^(\d+(?:\.\d+)*)-?(.*)/);
772
- const phaseNumber = dirMatch ? dirMatch[1] : dirName;
773
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
770
+ const { number: phaseNumber, name: phaseName } = parsePhaseDir(dirName);
774
771
 
775
772
  const phaseFullPath = path.join(phasesDirPath, dirName);
776
773
  let phaseFiles;
@@ -151,6 +151,22 @@ function conversationsDir(cwd) {
151
151
  return path.join(planningPath(cwd), CONVERSATIONS_DIR);
152
152
  }
153
153
 
154
+ // Phase ids are shaped like "1", "2.1", "06A" — never a path fragment. Validate
155
+ // before using phaseNum as a directory segment so a malformed/traversal value
156
+ // (e.g. "../../../outside") cannot write session.json outside .planning/conversations.
157
+ // Mirrors memory.cjs's validateAgentName hardening convention (2026-07).
158
+ const PHASE_NUM_RE = /^[0-9]+(\.[0-9]+)*[A-Z]?$/i;
159
+
160
+ function validatePhaseNum(phaseNum) {
161
+ if (phaseNum === undefined || phaseNum === null || String(phaseNum).trim() === '') {
162
+ return 'phaseNum required';
163
+ }
164
+ if (!PHASE_NUM_RE.test(String(phaseNum))) {
165
+ return `Invalid phase: ${phaseNum}. Must match ${PHASE_NUM_RE}`;
166
+ }
167
+ return null;
168
+ }
169
+
154
170
  function sessionFile(cwd, phaseNum) {
155
171
  return path.join(conversationsDir(cwd), String(phaseNum), 'session.json');
156
172
  }
@@ -159,6 +175,8 @@ function sessionFile(cwd, phaseNum) {
159
175
  * Read or initialize a discussion session for a phase.
160
176
  */
161
177
  function loadSession(cwd, phaseNum) {
178
+ const err = validatePhaseNum(phaseNum);
179
+ if (err) return { error: err };
162
180
  const file = sessionFile(cwd, phaseNum);
163
181
  try {
164
182
  return JSON.parse(fs.readFileSync(file, 'utf-8'));
@@ -176,7 +194,8 @@ function loadSession(cwd, phaseNum) {
176
194
  * @returns {{appended: true, turn_count: number, file: string}|{error: string}}
177
195
  */
178
196
  function appendTurn(cwd, phaseNum, turn) {
179
- if (!phaseNum) return { error: 'phaseNum required' };
197
+ const phaseErr = validatePhaseNum(phaseNum);
198
+ if (phaseErr) return { error: phaseErr };
180
199
  if (!turn || !turn.role || !turn.content) return { error: 'turn requires role + content' };
181
200
  if (turn.role !== 'user' && turn.role !== 'agent') {
182
201
  return { error: 'turn.role must be "user" or "agent"' };
@@ -10,6 +10,7 @@
10
10
  * - L-004: Universal-scope rule prose mentions PAN-internal terms
11
11
  * (candidate for internal/ scope rather than universal/)
12
12
  * - L-005: Revision marker (rN) appended in body but no supersession field
13
+ * - L-006: Universal-scope pattern cites an internal pattern id (dangles after install)
13
14
  *
14
15
  * These are not patterns themselves — they're integrity checks for the
15
16
  * pattern store. Wired to `pan-tools learn lint`.
@@ -20,6 +21,25 @@ const path = require('path');
20
21
 
21
22
  const VALID_SCOPES = ['universal', 'internal'];
22
23
 
24
+ /**
25
+ * Default root for the learnings store: three levels up from lib/ — the install
26
+ * root (`<runtime-dir>/`) or the source repo root. Both layouts keep
27
+ * pan-wizard-core/ at that level, so this resolves correctly in either.
28
+ *
29
+ * Why this exists rather than defaulting to cwd: the dispatcher used
30
+ * `--source-root || cwd`, which is only right when cwd IS the PAN source repo. In
31
+ * a real install cwd is the user's project, where pan-wizard-core/learnings/ does
32
+ * not exist — so `learn topics-for` returned zero topics from a store holding
33
+ * dozens, `learn lint` reported PASS on a store it never opened, and
34
+ * `build-index` crashed. Twenty-five shipped instruction sites across the
35
+ * exec/plan/verify workflows tell agents to run these commands from the project
36
+ * root, and none passes --source-root, so every one of them silently got nothing.
37
+ * Mirrors skill-align.cjs resolveSkillRoot() and experiment.cjs PAN_SOURCE_ROOT.
38
+ */
39
+ function resolveLearningsRoot() {
40
+ return path.resolve(__dirname, '..', '..', '..');
41
+ }
42
+
23
43
  function getLearningsDir(sourceRoot, scope) {
24
44
  return path.join(sourceRoot, 'pan-wizard-core', 'learnings', scope);
25
45
  }
@@ -248,6 +268,35 @@ function lintPatterns(patterns) {
248
268
  }
249
269
  }
250
270
 
271
+ // L-006: a universal-scope pattern citing an INTERNAL pattern id.
272
+ //
273
+ // This is the only rule that must run in the source repo to catch a defect that
274
+ // only manifests after install. `internal/` is stripped when PAN is installed, so
275
+ // a universal topic citing an internal id resolves fine here — where both scopes
276
+ // are present — and becomes a dangling L-002 reference on every user's machine.
277
+ // That is exactly what shipped: universal/concurrency.md cited P-1402 from
278
+ // internal/pan-dev-bugs.md, so `learn lint` FAILED in every install while passing
279
+ // in the repo, making the integrity gate un-greenable for users and invisible to us.
280
+ // Checking scope-crossing directly, rather than waiting for the reference to
281
+ // dangle, is what makes it catchable before release.
282
+ const internalIds = new Set(patterns.filter(p => p.scope === 'internal').map(p => p.id));
283
+ if (internalIds.size > 0) {
284
+ for (const p of patterns) {
285
+ if (p.scope !== 'universal') continue;
286
+ const refs = [...new Set(p.body.match(PATTERN_REF_RE) || [])].filter(r => r !== p.id);
287
+ for (const ref of refs.filter(r => internalIds.has(r))) {
288
+ violations.push({
289
+ code: 'L-006',
290
+ severity: 'error',
291
+ pattern_id: p.id,
292
+ file: p.file,
293
+ message: `Universal pattern "${p.id}" cites internal pattern "${ref}" — internal/ is stripped at install time, so this becomes a dangling reference in every install`,
294
+ internal_ref: ref,
295
+ });
296
+ }
297
+ }
298
+ }
299
+
251
300
  return {
252
301
  violations,
253
302
  pattern_count: patterns.length,
@@ -284,6 +333,7 @@ function cmdLearnLint(sourceRoot, opts = {}) {
284
333
 
285
334
  module.exports = {
286
335
  cmdLearnLint,
336
+ resolveLearningsRoot,
287
337
  collectAllPatterns,
288
338
  lintPatterns,
289
339
  extractPatternBody,
@@ -509,8 +509,13 @@ function validateAll(cwd, opts = {}) {
509
509
 
510
510
  function cmdLinksValidate(cwd, opts = {}) {
511
511
  const result = validateAll(cwd, opts);
512
- // Bypass core.output() because it unconditionally exits 0; we need exit 1
513
- // when status is "fail" so CI / hooks can detect violations.
512
+ // This used to hand-roll process.stdout.write + process.exit to get exit 1 on a
513
+ // failing status, because output() exited 0 unconditionally. It no longer does
514
+ // (see its contract comment in core.cjs), so the gate goes through output() like
515
+ // every other command — which also picks up the >50KB @file: overflow protocol
516
+ // this bypass was silently missing. The payload has no `error` key, so the code
517
+ // is passed explicitly: "fail" is a verdict, not a malfunction.
518
+ let human;
514
519
  if (opts.raw) {
515
520
  const lines = [
516
521
  `Links: ${result.summary.status.toUpperCase()}`,
@@ -529,11 +534,9 @@ function cmdLinksValidate(cwd, opts = {}) {
529
534
  const where = f.source_line ? `${f.source}:${f.source_line}` : f.source;
530
535
  lines.push(`[${f.severity.toUpperCase()}] ${f.code} ${where}: ${f.detail}`);
531
536
  }
532
- process.stdout.write(lines.join('\n'));
533
- } else {
534
- process.stdout.write(JSON.stringify(result, null, 2));
537
+ human = lines.join('\n');
535
538
  }
536
- process.exit(result.summary.status === 'fail' ? 1 : 0);
539
+ output(result, opts.raw, human, result.summary.status === 'fail' ? 1 : 0);
537
540
  }
538
541
 
539
542
  module.exports = {
@@ -48,8 +48,18 @@ function acquireLock(filePath, opts = {}) {
48
48
  try {
49
49
  const age = Date.now() - fs.statSync(lockPath).mtimeMs;
50
50
  if (age > staleMs) {
51
- // Holder likely crashed — steal and retry immediately.
52
- try { fs.unlinkSync(lockPath); } catch { /* racing steal — loop retries */ }
51
+ // Holder likely crashed — steal via an atomic rename to a unique name.
52
+ // rename() moves the specific inode: exactly ONE concurrent stealer can
53
+ // rename a given lock file; every other contender's rename fails with
54
+ // ENOENT (the source is gone) instead of blindly unlinking a lock that a
55
+ // winner may already have re-created. This closes the double-holder race
56
+ // where two processes both stat the same stale lock and both unlink it.
57
+ const stealPath = `${lockPath}.steal.${process.pid}.${Date.now()}`;
58
+ try {
59
+ fs.renameSync(lockPath, stealPath);
60
+ // We won the steal — discard the stolen lock and retry wx-create.
61
+ try { fs.unlinkSync(stealPath); } catch { /* best-effort */ }
62
+ } catch { /* another contender stole/cleared it first — loop retries */ }
53
63
  continue;
54
64
  }
55
65
  } catch { /* lock vanished between EEXIST and stat — loop retries */ }
@@ -59,9 +69,18 @@ function acquireLock(filePath, opts = {}) {
59
69
  return { acquired: false, lockPath };
60
70
  }
61
71
 
62
- /** Release a lock acquired by acquireLock. Best-effort. */
72
+ /**
73
+ * Release a lock acquired by acquireLock. Best-effort, but ownership-verified:
74
+ * only unlink when the lock file still contains THIS process's pid, so a lock
75
+ * that was stolen (stale) and re-created by a successor is never deleted out
76
+ * from under that successor.
77
+ */
63
78
  function releaseLock(lockPath) {
64
- try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
79
+ try {
80
+ const owner = fs.readFileSync(lockPath, 'utf-8').trim();
81
+ if (owner !== String(process.pid)) return; // not ours anymore — leave it
82
+ fs.unlinkSync(lockPath);
83
+ } catch { /* already gone / unreadable — nothing to release */ }
65
84
  }
66
85
 
67
86
  /**
@@ -24,12 +24,49 @@ const { readMemory, parseEntries, listMemoryAgents, compactMemory, DEFAULT_MAX_E
24
24
 
25
25
  const DEFAULT_KEEP = 12; // recent bullets kept inline per section
26
26
  const STATE_ARCHIVE_FILE = 'state-archive.md';
27
+ const QUARANTINE_FILE = 'quarantine.md';
27
28
 
28
29
  // Sections whose bullet lists grow unbounded and are safe to reconcile.
29
30
  const APPEND_HEAVY = /\b(decisions|blockers|concerns|pending todos|todos|session continuity|accumulated context|recent activity)\b/i;
30
31
  // A bullet that is just a placeholder — dropped once real entries exist.
31
32
  const PLACEHOLDER = /^-\s*(none(\s+yet)?|n\/a|tbd|todo|—|-)\.?\s*$/i;
32
33
 
34
+ // Memory-injection defense (threat: a subagent writes an instruction/directive
35
+ // into the always-loaded memory that a LATER agent or run reads and OBEYS — a
36
+ // cross-generation prompt injection, exactly the "agent writes self-serving
37
+ // directives into persistent memory for successors" vector in the OpenAI
38
+ // rogue-agent incident, Reuters 2026-07). See ADR-0040. state.md is agent-writable
39
+ // (decisions/blockers/notes), so during reconcile any bullet that reads like a
40
+ // directive AIMED at the agent/system is QUARANTINED out of standing memory (moved
41
+ // to .planning/memory/quarantine.md, reversible) rather than carried forward —
42
+ // nothing agent-authored becomes standing instruction without human review (the
43
+ // merge gate). High-precision, injection-flavored patterns only, to minimize false
44
+ // positives; a legitimate note caught here is recoverable from quarantine.md.
45
+ const DIRECTIVE_PATTERNS = [
46
+ /\bignore\s+(all\s+|any\s+|these\s+)?(previous|prior|earlier|above)\b/i,
47
+ /\bdisregard\s+(the\s+|all\s+|any\s+|your\s+)?(previous|prior|above|earlier|instructions|rules|guidelines|guardrails)\b/i,
48
+ /\byou\s+are\s+now\b/i,
49
+ /\bnew\s+instructions?\s*:/i,
50
+ /\b(the\s+)?system\s+prompt\b/i,
51
+ /\b(as|acting\s+as|being)\s+(an?\s+)?(admin|administrator|root|superuser|developer\s+with)\b/i,
52
+ /\bpre-?authoriz(e|ed|ation)\b/i,
53
+ /\b(do\s+not|don'?t|never)\s+(tell|inform|notify|ask|alert)\s+the\s+(user|human|operator)\b/i,
54
+ /\bwithout\s+(asking|telling|notifying|informing|alerting)\s+the\s+(user|human|operator)\b/i,
55
+ /\b(bypass|skip|disable|override|remove|turn\s+off)\s+(the\s+)?(merge[-\s]?gate|human[-\s]?(approval|gate|review)|approval[-\s]?gate|safety[-\s]?(harness|check|guard|gate)|verification[-\s]?gate|review[-\s]?gate)\b/i,
56
+ /\b(always|automatically)\s+(approve|auto-?approve|accept|merge|confirm|say\s+yes)\b/i,
57
+ /\bauto-?approve\s+(all|any|every|everything)\b/i,
58
+ /\boverride\s+(the\s+)?(human|approval|merge[-\s]?gate|safety)\b/i,
59
+ ];
60
+
61
+ /**
62
+ * True when a memory entry reads like a directive aimed at the agent/system
63
+ * (an injection), as opposed to a descriptive project note. Pure + zero-dep.
64
+ */
65
+ function isSuspiciousDirective(text) {
66
+ if (typeof text !== 'string' || !text) return false;
67
+ return DIRECTIVE_PATTERNS.some((re) => re.test(text));
68
+ }
69
+
33
70
  const isHeading = (l) => /^#{1,6}\s+\S/.test(l);
34
71
  const headingText = (l) => (l.match(/^#{1,6}\s+(.*)$/) || [, ''])[1];
35
72
  const isBullet = (l) => /^-\s+\S/.test(l);
@@ -64,7 +101,7 @@ function joinSections(sections) {
64
101
  * its indented continuation lines, so a bullet is never orphaned from its detail.
65
102
  * Overflow entries are pushed to `archived`. Returns { lines, changed }.
66
103
  */
67
- function reconcileBullets(lines, keepN, archived) {
104
+ function reconcileBullets(lines, keepN, archived, quarantined) {
68
105
  const firstB = lines.findIndex(isBullet);
69
106
  if (firstB === -1) return { lines, changed: false };
70
107
 
@@ -87,9 +124,22 @@ function reconcileBullets(lines, keepN, archived) {
87
124
  }
88
125
  const trailer = rest.slice(i);
89
126
 
127
+ // 0. QUARANTINE injected directives FIRST — a bullet that reads like an
128
+ // instruction aimed at the agent never survives into standing memory; it is
129
+ // moved to the quarantine file for human review (memory-injection defense).
130
+ const safe = [];
131
+ let quarantinedHere = 0;
132
+ for (const e of entries) {
133
+ if (quarantined && isSuspiciousDirective(e.key)) {
134
+ quarantined.push(e.lines.join('\n'));
135
+ quarantinedHere++;
136
+ } else {
137
+ safe.push(e);
138
+ }
139
+ }
90
140
  // 1. dedupe (keep first occurrence)
91
141
  const seen = new Set();
92
- const deduped = entries.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
142
+ const deduped = safe.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
93
143
  // 2. strip placeholders once real entries exist
94
144
  const real = deduped.filter((e) => !PLACEHOLDER.test(e.key));
95
145
  const kept0 = real.length ? real : deduped;
@@ -102,7 +152,7 @@ function reconcileBullets(lines, keepN, archived) {
102
152
  }
103
153
  for (const d of dropped) archived.push(d.lines.join('\n'));
104
154
 
105
- const changed = deduped.length !== entries.length || kept0.length !== deduped.length || dropped.length > 0;
155
+ const changed = quarantinedHere > 0 || deduped.length !== safe.length || kept0.length !== deduped.length || dropped.length > 0;
106
156
  const newLines = [...pre, ...kept.flatMap((e) => e.lines), ...trailer];
107
157
  // Preserve the section's trailing blank line (the blank that separates it from
108
158
  // the next heading) so reconciling never collapses two sections together.
@@ -119,23 +169,22 @@ function optimizeStateContent(content, opts = {}) {
119
169
  const keepN = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep : DEFAULT_KEEP;
120
170
  const sections = parseSections(content);
121
171
  const archived = [];
172
+ const quarantined = [];
122
173
  const sectionsTouched = [];
123
174
  let changed = false;
124
175
 
125
176
  for (const s of sections) {
126
177
  if (s.heading === null) continue;
127
178
  if (!APPEND_HEAVY.test(headingText(s.heading))) continue;
128
- const before = archived.length;
129
- const r = reconcileBullets(s.lines, keepN, archived);
179
+ const r = reconcileBullets(s.lines, keepN, archived, quarantined);
130
180
  if (r.changed) {
131
181
  s.lines = r.lines;
132
182
  changed = true;
133
183
  sectionsTouched.push(headingText(s.heading).trim());
134
184
  }
135
- void before;
136
185
  }
137
186
 
138
- return { content: changed ? joinSections(sections) : content, changed, archived, sectionsTouched };
187
+ return { content: changed ? joinSections(sections) : content, changed, archived, quarantined, sectionsTouched };
139
188
  }
140
189
 
141
190
  // ─── Command ────────────────────────────────────────────────────────────────
@@ -154,6 +203,30 @@ function appendArchive(cwd, entries, now) {
154
203
  fs.appendFileSync(p, block, 'utf-8');
155
204
  }
156
205
 
206
+ function quarantinePath(cwd) {
207
+ return path.join(planningPath(cwd), MEMORY_DIR, QUARANTINE_FILE);
208
+ }
209
+
210
+ /**
211
+ * Append quarantined directive-like entries to a dated, human-review file. These
212
+ * were pulled OUT of standing memory because they read like injected instructions
213
+ * (memory-injection defense). Reversible — a human can review and, if legitimate,
214
+ * restore an entry by hand. The file leads with a warning so it is never loaded
215
+ * as trusted instruction memory.
216
+ */
217
+ function appendQuarantine(cwd, entries, now) {
218
+ if (!entries.length) return;
219
+ const p = quarantinePath(cwd);
220
+ const fresh = !fs.existsSync(p);
221
+ fs.mkdirSync(path.dirname(p), { recursive: true });
222
+ const stamp = now || '(undated)';
223
+ const header = fresh
224
+ ? '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n'
225
+ : '';
226
+ const block = `${header}\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`;
227
+ fs.appendFileSync(p, block, 'utf-8');
228
+ }
229
+
157
230
  /**
158
231
  * `memory optimize [--apply] [--keep N]` — reconcile state.md + consolidate
159
232
  * over-budget agent logs. Dry-run by default: reports what WOULD change.
@@ -172,12 +245,15 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
172
245
  changed: opt.changed,
173
246
  sections_touched: opt.sectionsTouched,
174
247
  archived_entries: opt.archived.length,
248
+ quarantined_entries: opt.quarantined.length,
175
249
  before_bytes: Buffer.byteLength(before),
176
250
  after_bytes: Buffer.byteLength(opt.content),
177
251
  };
178
252
  result.archived = opt.archived.length;
253
+ result.quarantined = opt.quarantined.length;
179
254
  if (apply && opt.changed) {
180
255
  appendArchive(cwd, opt.archived, opts.now);
256
+ appendQuarantine(cwd, opt.quarantined, opts.now);
181
257
  writeStateMd(statePath, opt.content, cwd);
182
258
  }
183
259
  } else {
@@ -187,20 +263,27 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
187
263
  // Consolidate any per-agent log over the entry cap (reuses compactMemory, which
188
264
  // no-ops under the cap). Dry-run counts entries without writing.
189
265
  try {
190
- for (const a of listMemoryAgents(cwd)) {
191
- const rawMem = readMemory(cwd, a);
192
- if (rawMem == null) continue;
193
- const count = parseEntries(rawMem).length;
266
+ // M21: listMemoryAgents(cwd) returns { agents: [{ agent, entries }] }, and
267
+ // readMemory(cwd, name) returns { agent, entries, raw }. The old loop iterated
268
+ // the return OBJECT directly (a TypeError, silently swallowed by this catch)
269
+ // and passed the whole object to readMemory/parseEntries — so the agent-log
270
+ // consolidation never ran. Iterate .agents, read each by name, count entries.
271
+ for (const a of listMemoryAgents(cwd).agents) {
272
+ const mem = readMemory(cwd, a.agent);
273
+ if (mem == null) continue;
274
+ const count = parseEntries(mem.raw).length;
194
275
  if (count > DEFAULT_MAX_ENTRIES) {
195
276
  let removed = 0;
196
- if (apply) { const r = compactMemory(cwd, a, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
197
- result.agents.push({ agent: a, entries: count, over_cap: true, compacted: apply, removed });
277
+ if (apply) { const r = compactMemory(cwd, a.agent, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
278
+ result.agents.push({ agent: a.agent, entries: count, over_cap: true, compacted: apply, removed });
198
279
  }
199
280
  }
200
281
  } catch { /* agent sweep is best-effort */ }
201
282
 
283
+ const q = result.quarantined || 0;
284
+ const quarantineNote = q ? `; ${q} directive-like entr${q === 1 ? 'y' : 'ies'} QUARANTINED` : '';
202
285
  const summary = result.state.changed
203
- ? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
286
+ ? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${quarantineNote}${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
204
287
  : `state.md already lean${result.agents.length ? `; ${result.agents.length} agent log(s) over budget` : ''} — nothing to do`;
205
288
  output(result, raw, summary);
206
289
  }
@@ -239,8 +322,9 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
239
322
  const opt = optimizeStateContent(before, { keep: opts.keep });
240
323
  if (!opt.changed) return { optimized: false, reason: 'clean' };
241
324
  appendArchive(cwd, opt.archived, opts.now);
325
+ appendQuarantine(cwd, opt.quarantined, opts.now);
242
326
  writeStateMd(statePath, opt.content, cwd);
243
- return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length };
327
+ return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length, quarantined: opt.quarantined.length };
244
328
  } catch {
245
329
  return { optimized: false, reason: 'error' };
246
330
  }
@@ -248,6 +332,6 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
248
332
 
249
333
  module.exports = {
250
334
  optimizeStateContent, reconcileBullets, parseSections, joinSections, cmdMemoryOptimize,
251
- maybeAutoOptimizeMemory, autoOptimizeEnabled,
252
- APPEND_HEAVY, PLACEHOLDER, DEFAULT_KEEP, STATE_ARCHIVE_FILE,
335
+ maybeAutoOptimizeMemory, autoOptimizeEnabled, isSuspiciousDirective,
336
+ APPEND_HEAVY, PLACEHOLDER, DIRECTIVE_PATTERNS, DEFAULT_KEEP, STATE_ARCHIVE_FILE, QUARANTINE_FILE,
253
337
  };
@@ -32,6 +32,24 @@ const {
32
32
  upsertAgentsMdSection,
33
33
  ensureClaudeMdImport,
34
34
  } = require('./agents-md.cjs');
35
+ const { isSuspiciousDirective } = require('./memory-optimize.cjs');
36
+
37
+ /**
38
+ * Scan a procedural-memory file (AGENTS.md / CLAUDE.md) for lines that read like
39
+ * directives aimed at the agent — a memory-injection risk in the ALWAYS-loaded
40
+ * instruction files (ADR-0040). rebuild owns only the marker-fenced PAN section
41
+ * (regenerated from a fixed template, so it can't be poisoned); user content is
42
+ * preserved by contract, so here we WARN rather than auto-edit — surfacing
43
+ * suspect lines for human review instead of silently rewriting the user's file.
44
+ */
45
+ function scanForDirectives(file, content, warnings) {
46
+ if (typeof content !== 'string') return;
47
+ content.split('\n').forEach((line, i) => {
48
+ if (isSuspiciousDirective(line)) {
49
+ warnings.push({ file, line: i + 1, text: line.trim().slice(0, 200) });
50
+ }
51
+ });
52
+ }
35
53
 
36
54
  // Source repo root — mirrors experiment.cjs / install.js. __dirname is
37
55
  // .../pan-wizard-core/bin/lib, so three levels up is the repo (or install) root.
@@ -106,6 +124,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
106
124
 
107
125
  const runtimes = detectRuntimes(cwd);
108
126
  const targets = [];
127
+ const warnings = [];
109
128
 
110
129
  // 1. AGENTS.md — universal PAN section (all runtimes read it natively).
111
130
  {
@@ -113,6 +132,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
113
132
  const existing = safeReadFile(p);
114
133
  const desired = upsertAgentsMdSection(existing, buildAgentsMdSection());
115
134
  targets.push({ file: 'AGENTS.md', ...rebuildFile(p, existing, desired, apply) });
135
+ scanForDirectives('AGENTS.md', desired, warnings);
116
136
  }
117
137
 
118
138
  // 2. CLAUDE.md — Claude bridge, only when the Claude runtime is installed.
@@ -121,6 +141,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
121
141
  const existing = safeReadFile(p);
122
142
  const desired = ensureClaudeMdImport(existing);
123
143
  targets.push({ file: 'CLAUDE.md', ...rebuildFile(p, existing, desired, apply) });
144
+ scanForDirectives('CLAUDE.md', desired, warnings);
124
145
  }
125
146
 
126
147
  // 3. state.md — re-derive YAML frontmatter from the body (progress/status).
@@ -139,10 +160,12 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
139
160
  runtimes,
140
161
  rebuilt: targets,
141
162
  changed_count: changed.length,
163
+ directive_warnings: warnings,
142
164
  };
143
- const summary = changed.length === 0
165
+ const warnNote = warnings.length ? `; ⚠ ${warnings.length} directive-like line(s) in procedural memory — review (not auto-edited)` : '';
166
+ const summary = (changed.length === 0
144
167
  ? `tools memory already current (${targets.map((t) => t.file).join(', ')}) — nothing to do`
145
- : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`;
168
+ : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`) + warnNote;
146
169
  output(result, raw, summary);
147
170
  }
148
171
 
@@ -151,6 +174,7 @@ module.exports = {
151
174
  detectRuntimes,
152
175
  isInsideSourceRepo,
153
176
  rebuildFile,
177
+ scanForDirectives,
154
178
  PAN_SOURCE_ROOT,
155
179
  RUNTIME_DIRS,
156
180
  };
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { PLANNING_DIR, PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
8
8
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
9
- const { output, error, isGitRepo, execGit } = require('./core.cjs');
9
+ const { output, error, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
10
10
  const { extractFrontmatter } = require('./frontmatter.cjs');
11
11
  const { writeStateMd } = require('./state.cjs');
12
12
 
@@ -39,7 +39,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
39
39
  try {
40
40
  reqContent = fs.readFileSync(reqPath, 'utf-8');
41
41
  } catch {
42
- output({ updated: false, reason: 'requirements.md not found', ids: reqIds }, raw, 'no requirements file');
42
+ // error key => exit 1: the caller asked to mark requirements complete and no
43
+ // requirement was marked - the milestone record does not reflect the work.
44
+ output({ updated: false, reason: 'requirements.md not found', error: 'requirements_not_found', ids: reqIds }, raw, 'no requirements file');
43
45
  return;
44
46
  }
45
47
  const updated = [];
@@ -47,20 +49,24 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
47
49
 
48
50
  for (const reqId of reqIds) {
49
51
  let found = false;
52
+ // Escape the requirement ID before interpolating into RegExp — an ID with
53
+ // regex metacharacters otherwise crashes or mis-matches (M22, ADR audit
54
+ // 2026-08). Matches the escapeRegex convention in phase-remove.cjs/knowledge.cjs.
55
+ const reqEsc = escapeRegex(reqId);
50
56
 
51
57
  // Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID**
52
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi');
58
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEsc}\\*\\*)`, 'gi');
53
59
  if (checkboxPattern.test(reqContent)) {
54
60
  reqContent = reqContent.replace(checkboxPattern, '$1x$2');
55
61
  found = true;
56
62
  }
57
63
 
58
64
  // Update traceability table: | REQ-ID | Phase N | Pending | -> | REQ-ID | Phase N | Complete |
59
- const tablePattern = new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
65
+ const tablePattern = new RegExp(`(\\|\\s*${reqEsc}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
60
66
  if (tablePattern.test(reqContent)) {
61
67
  // Re-create regex since test() advances lastIndex for global regex
62
68
  reqContent = reqContent.replace(
63
- new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
69
+ new RegExp(`(\\|\\s*${reqEsc}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
64
70
  '$1 Complete $2'
65
71
  );
66
72
  found = true;