pan-wizard 3.22.0 → 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 (134) 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 +11 -6
  68. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  69. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  70. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  71. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  72. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  73. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  74. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  75. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  76. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  77. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  78. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  79. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  80. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  81. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  82. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  83. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  84. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  85. package/pan-wizard-core/learnings/index.json +7 -7
  86. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  87. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  88. package/pan-wizard-core/references/design-methodology.md +94 -0
  89. package/pan-wizard-core/references/git-integration.md +9 -9
  90. package/pan-wizard-core/references/guardrails.md +4 -1
  91. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  92. package/pan-wizard-core/references/model-profiles.md +44 -34
  93. package/pan-wizard-core/references/planning-config.md +5 -5
  94. package/pan-wizard-core/references/verification-patterns.md +67 -0
  95. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  96. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  97. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  98. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  99. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  100. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  101. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  102. package/pan-wizard-core/templates/design.md +146 -0
  103. package/pan-wizard-core/templates/discovery.md +3 -3
  104. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  105. package/pan-wizard-core/templates/playbook.md +1 -1
  106. package/pan-wizard-core/templates/uat.md +1 -1
  107. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  108. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  109. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  110. package/pan-wizard-core/workflows/health.md +24 -1
  111. package/pan-wizard-core/workflows/help.md +5 -4
  112. package/pan-wizard-core/workflows/learn.md +5 -5
  113. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  114. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  116. package/pan-wizard-core/workflows/new-project.md +47 -32
  117. package/pan-wizard-core/workflows/optimize.md +8 -8
  118. package/pan-wizard-core/workflows/pause.md +1 -1
  119. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  120. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  121. package/pan-wizard-core/workflows/profile.md +13 -10
  122. package/pan-wizard-core/workflows/research-phase.md +1 -1
  123. package/pan-wizard-core/workflows/resume-project.md +1 -1
  124. package/pan-wizard-core/workflows/settings.md +9 -9
  125. package/pan-wizard-core/workflows/transition.md +24 -8
  126. package/pan-wizard-core/workflows/update.md +23 -20
  127. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  128. package/pan-zcode/README.md +12 -2
  129. package/pan-zcode/bin/install-zcode.js +1 -1
  130. package/pan-zcode/mcp/native-tools.cjs +1 -1
  131. package/pan-zcode/mcp/server.cjs +76 -14
  132. package/scripts/build-hooks.js +2 -1
  133. package/scripts/generate-skills-docs.py +0 -4
  134. 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
  /**
@@ -263,14 +263,19 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
263
263
  // Consolidate any per-agent log over the entry cap (reuses compactMemory, which
264
264
  // no-ops under the cap). Dry-run counts entries without writing.
265
265
  try {
266
- for (const a of listMemoryAgents(cwd)) {
267
- const rawMem = readMemory(cwd, a);
268
- if (rawMem == null) continue;
269
- 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;
270
275
  if (count > DEFAULT_MAX_ENTRIES) {
271
276
  let removed = 0;
272
- if (apply) { const r = compactMemory(cwd, a, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
273
- 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 });
274
279
  }
275
280
  }
276
281
  } catch { /* agent sweep is best-effort */ }
@@ -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;
@@ -121,7 +121,7 @@ function initTraceSession(cwd, opts = {}) {
121
121
 
122
122
  return { session_id: sessionId, started_at: meta.started_at, directory: sessionDir, reused: false };
123
123
  } catch (e) {
124
- return { error: e.message };
124
+ return { error: e.message || 'trace_init_failed' };
125
125
  }
126
126
  }
127
127
 
@@ -278,7 +278,7 @@ function endTraceSession(cwd, sessionId) {
278
278
  ended_at: meta.ended_at,
279
279
  };
280
280
  } catch (e) {
281
- return { error: e.message };
281
+ return { error: e.message || 'trace_read_failed' };
282
282
  }
283
283
  }
284
284
 
@@ -303,7 +303,7 @@ function reconcileTraceSession(cwd, sessionId) {
303
303
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
304
304
  return { session_id: sid, reconciled: true, event_count: counts.event_count, malformed_count: counts.malformed_count };
305
305
  } catch (e) {
306
- return { error: e.message };
306
+ return { error: e.message || 'trace_write_failed' };
307
307
  }
308
308
  }
309
309
 
@@ -349,7 +349,7 @@ function readTraceSession(cwd, sessionId) {
349
349
 
350
350
  return { session_id: sessionId, metadata, events, event_count: events.length, malformed_count: malformed };
351
351
  } catch (e) {
352
- return { error: e.message };
352
+ return { error: e.message || 'trace_end_failed' };
353
353
  }
354
354
  }
355
355
 
@@ -380,7 +380,7 @@ function listTraceSessions(cwd) {
380
380
 
381
381
  return { sessions, count: sessions.length };
382
382
  } catch (e) {
383
- return { error: e.message };
383
+ return { error: e.message || 'trace_reconcile_failed' };
384
384
  }
385
385
  }
386
386
 
@@ -585,7 +585,7 @@ function listOptimizationReports(cwd) {
585
585
 
586
586
  return { reports, count: reports.length };
587
587
  } catch (e) {
588
- return { error: e.message };
588
+ return { error: e.message || 'report_write_failed' };
589
589
  }
590
590
  }
591
591
 
@@ -626,8 +626,19 @@ function applyReportRecommendations(cwd, reportPath) {
626
626
  const applied = [];
627
627
  const skipped = [];
628
628
 
629
+ const resolvedCwd = path.resolve(cwd);
629
630
  for (const action of actions) {
630
631
  try {
632
+ // Containment: action.path on memory writes is report/agent-authored and
633
+ // must stay inside the project. A `../` (or absolute) path otherwise
634
+ // escapes and writes anywhere the process can (M23, ADR audit 2026-08).
635
+ if (action.type === 'memory' || action.type === 'memory_append') {
636
+ const abs = path.resolve(cwd, action.path || '');
637
+ if (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep)) {
638
+ skipped.push({ action, reason: 'path escapes project root — skipped' });
639
+ continue;
640
+ }
641
+ }
631
642
  if (action.type === 'memory') {
632
643
  // Write new memory entry (skip if file exists to avoid overwriting manual edits)
633
644
  const memPath = path.join(cwd, action.path);
@@ -751,7 +762,7 @@ function getOptimizeStats(cwd) {
751
762
  current_session: getCurrentSessionId(cwd),
752
763
  };
753
764
  } catch (e) {
754
- return { error: e.message };
765
+ return { error: e.message || 'apply_failed' };
755
766
  }
756
767
  }
757
768
 
@@ -20,7 +20,7 @@ function removePhaseFromDisk(phaseDir) {
20
20
  try {
21
21
  fs.rmSync(phaseDir, { recursive: true, force: true });
22
22
  } catch (e) {
23
- return { removed: false, error: e.message };
23
+ return { removed: false, error: e.message || 'remove_failed' };
24
24
  }
25
25
  return { removed: true };
26
26
  }
@@ -214,7 +214,12 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
214
214
 
215
215
  // Reasonable upper bound for phase numbers
216
216
  const maxPhase = 99;
217
- for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
217
+ // Floor the loop independently of removedInt. cmdPhaseRemove rejects a phase
218
+ // below 1 before we get here, but this function is also reachable directly,
219
+ // and an unbounded lower end is how `phase remove -1234567890` turned into
220
+ // ~1.2 billion regex passes. The loop can now never exceed maxPhase steps.
221
+ const stopAt = Math.max(removedInt, 0);
222
+ for (let oldNum = maxPhase; oldNum > stopAt; oldNum--) {
218
223
  const newNum = oldNum - 1;
219
224
  const oldStr = String(oldNum);
220
225
  const newStr = String(newNum);
@@ -269,6 +274,23 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
269
274
  error('phase number required for phase remove');
270
275
  }
271
276
 
277
+ // Validate the identifier BEFORE touching the roadmap or any directory.
278
+ // normalizePhaseName does not validate — it returns its input unchanged when
279
+ // PHASE_NUM_RE does not match — so a bad argument used to reach the renumber
280
+ // logic and do real damage, in both cases reporting success and exiting 0:
281
+ // `phase remove 0` renumbered EVERY roadmap heading down to "Phase 0"
282
+ // (the descending renumber loop runs 99..1 and re-hits
283
+ // its own output, collapsing 3->2->1->0) and renamed
284
+ // every phase directory one lower.
285
+ // `phase remove -1e9` spun that same loop ~1.2 billion times, each pass
286
+ // running several regex replaces over the roadmap —
287
+ // hours of CPU, no output, no way to tell it was stuck.
288
+ // Phases are numbered from 1, so anything below that is a typo, never a target.
289
+ const phaseIdent = String(targetPhase).trim();
290
+ if (!/^\d+[A-Za-z]?(?:\.\d+)*$/.test(phaseIdent) || parseInt(phaseIdent, 10) < 1) {
291
+ error(`invalid phase number: ${targetPhase} (expected a phase numbered from 1, e.g. 3, 3A or 3.1)`);
292
+ }
293
+
272
294
  const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
273
295
  const phasesDir = phasesPath(cwd);
274
296
  const force = options.force || false;
@@ -291,6 +313,23 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
291
313
  // Phases directory does not exist; targetDir remains null
292
314
  }
293
315
 
316
+ // Refuse a phase that exists nowhere. A phase CAN legitimately have a roadmap
317
+ // entry and no directory yet (planned, not scaffolded), so only refuse when it
318
+ // is in neither place. Previously this reported {removed: <n>} with exit 0 for
319
+ // a phase that was never there, and claimed roadmap.md and state.md had been
320
+ // rewritten — an orchestrator reads that as "it is gone" and moves on.
321
+ if (!targetDir) {
322
+ let roadmapText = '';
323
+ try {
324
+ roadmapText = fs.readFileSync(roadmapPath, 'utf8');
325
+ } catch { /* unreadable roadmap is reported below as not-found */ }
326
+ const displayNum = escapeRegex(String(parseInt(phaseIdent, 10)) + (phaseIdent.includes('.') ? '.' + phaseIdent.split('.').slice(1).join('.') : ''));
327
+ const inRoadmap = new RegExp(`^#{2,4}\\s*Phase\\s+0*${displayNum}(\\D|$)`, 'im').test(roadmapText);
328
+ if (!inRoadmap) {
329
+ error(`Phase ${targetPhase} not found — no phase directory and no roadmap entry`);
330
+ }
331
+ }
332
+
294
333
  // Check for executed work (summary.md files)
295
334
  if (targetDir && !force) {
296
335
  const targetPath = path.join(phasesDir, targetDir);