pan-wizard 3.22.0 → 3.25.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 (135) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +4 -4
  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 +34 -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/lib/worktree.cjs +98 -0
  85. package/pan-wizard-core/bin/pan-tools.cjs +45 -20
  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/README.md +12 -2
  130. package/pan-zcode/bin/install-zcode.js +1 -1
  131. package/pan-zcode/mcp/native-tools.cjs +1 -1
  132. package/pan-zcode/mcp/server.cjs +76 -14
  133. package/scripts/build-hooks.js +2 -1
  134. package/scripts/generate-skills-docs.py +0 -4
  135. package/scripts/run-tests.cjs +11 -4
@@ -6,7 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
- const { safeReadFile, execGit, findPhaseInternal, output } = require('./core.cjs');
9
+ const { safeReadFile, execGit, findPhaseInternal, output, normalizePhaseName } = require('./core.cjs');
10
10
  const { readStateSafe } = require('./state.cjs');
11
11
  const {
12
12
  STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PATTERNS_FILE, PHASE_DIR_RE,
@@ -162,7 +162,13 @@ function cmdDepsValidate(cwd, raw) {
162
162
  const headerRe = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
163
163
  let match;
164
164
  while ((match = headerRe.exec(roadmapContent)) !== null) {
165
- roadmapPhases.set(match[1], match[2].trim());
165
+ // Key both maps by the NORMALIZED number. Roadmap headings are unpadded
166
+ // ("Phase 1") and directory names are zero-padded ("01-foundation"), so
167
+ // keying on the raw values made every phase mismatch in BOTH directions:
168
+ // `deps validate` reported "in roadmap but no directory on disk" for phases
169
+ // whose directory was right there, plus an "orphaned directory" warning for
170
+ // the same phase — inventing errors on a roadmap in PAN's own template shape.
171
+ roadmapPhases.set(normalizePhaseName(match[1]), match[2].trim());
166
172
  }
167
173
  } else {
168
174
  issues.push({ type: 'warning', message: 'roadmap.md not found' });
@@ -176,7 +182,7 @@ function cmdDepsValidate(cwd, raw) {
176
182
  if (entry.isDirectory()) {
177
183
  const dirMatch = entry.name.match(PHASE_DIR_RE);
178
184
  if (dirMatch) {
179
- diskPhases.set(dirMatch[1], entry.name);
185
+ diskPhases.set(normalizePhaseName(dirMatch[1]), entry.name);
180
186
  }
181
187
  }
182
188
  }
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execFileSync } = require('child_process');
8
- const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, error, escapeRegex } = require('./core.cjs');
8
+ const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInternal, getMilestoneInfo, toPosix, output, EXIT_OK, error, escapeRegex } = require('./core.cjs');
9
9
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
10
10
  const { writeStateMd, readStateSafe } = require('./state.cjs');
11
11
  const {
@@ -362,7 +362,17 @@ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
362
362
  if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
363
363
  const r = checkArtifacts(cwd, content);
364
364
  if (r.total === 0) {
365
- output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
365
+ // EXIT_OK: "nothing declared to check" is a RESULT, not a failure. Two pieces of
366
+ // shipped evidence: (1) the plan template ships `must_haves: { truths: [],
367
+ // artifacts: [], key_links: [] }` (pan-wizard-core/templates/phase-prompt.md,
368
+ // template.cjs generatePlanTemplate), so an empty block is the documented default
369
+ // state of a scaffolded plan; presence of the block is enforced by
370
+ // `verify plan-structure`, not here. (2) parseMustHavesBlock currently matches the
371
+ // block header at 4-space indent while every shipped template and agent emits it at
372
+ // 2 — so this branch is reached for *every* plan authored in PAN's own format, and
373
+ // exiting non-zero would fail every real `verify artifacts` call. Reclassifying
374
+ // this as a failure requires fixing that indentation mismatch first.
375
+ output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw, undefined, EXIT_OK);
366
376
  return;
367
377
  }
368
378
  output(r, raw, r.all_passed ? 'valid' : 'invalid');
@@ -425,7 +435,10 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
425
435
  if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
426
436
  const r = checkKeyLinks(cwd, content);
427
437
  if (r.total === 0) {
428
- output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
438
+ // EXIT_OK: same reasoning as cmdVerifyArtifacts above — an empty key_links block is
439
+ // the shipped template default, and the parseMustHavesBlock indent mismatch means
440
+ // this branch fires for every plan written in PAN's own format.
441
+ output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw, undefined, EXIT_OK);
429
442
  return;
430
443
  }
431
444
  output(r, raw, r.all_verified ? 'valid' : 'invalid');
@@ -490,8 +503,11 @@ function reconcilePhase(cwd, phaseNum) {
490
503
  function cmdVerifyReconcile(cwd, phaseNum, raw) {
491
504
  if (!phaseNum) { error('Usage: verify reconcile <phase>'); }
492
505
  const r = reconcilePhase(cwd, phaseNum);
493
- output(r, raw, r.reconciled ? 'valid' : 'invalid');
494
- process.exit(r.reconciled ? 0 : 1);
506
+ // Exit non-zero on contradiction so exec-phase's auto-advance gate actually
507
+ // stops on a rubber-stamped verification. output() previously hard-coded
508
+ // exit 0, making the old trailing process.exit dead code (H3, ADR audit
509
+ // 2026-08); the exitCode arg restores the gate.
510
+ output(r, raw, r.reconciled ? 'valid' : 'invalid', r.reconciled ? 0 : 1);
495
511
  }
496
512
 
497
513
  // ─── Stub / fake-return scanner (ADR-0036 review — closes the hardcoded
@@ -516,7 +532,9 @@ const STUB_CODE_EXT = /\.(js|cjs|mjs|jsx|ts|tsx|py|go|rb|java|php|rs|c|cc|cpp|h|
516
532
  * @returns {{scanned, findings: Array, blocking: number, total: number}}
517
533
  */
518
534
  function scanStubs(cwd, opts = {}) {
519
- let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd);
535
+ // includeUntracked: a stub gate that cannot see new files is not a gate. See the
536
+ // note in verify-drift.cjs getChangedFiles for why drift does not want the same.
537
+ let files = Array.isArray(opts.files) ? opts.files : getChangedFiles(cwd, null, { includeUntracked: true });
520
538
  files = (files || []).filter(f => STUB_CODE_EXT.test(f));
521
539
  const findings = [];
522
540
  for (const rel of files) {
@@ -538,8 +556,9 @@ function scanStubs(cwd, opts = {}) {
538
556
 
539
557
  function cmdVerifyStubs(cwd, opts = {}, raw) {
540
558
  const r = scanStubs(cwd, opts);
541
- output(r, raw, r.blocking === 0 ? 'valid' : 'invalid');
542
- if (opts.gate) process.exit(r.blocking > 0 ? 1 : 0);
559
+ // --gate must exit non-zero on blocking findings; output() used to hard-exit 0
560
+ // before the gate check, so the gate was dead (M30, ADR audit 2026-08).
561
+ output(r, raw, r.blocking === 0 ? 'valid' : 'invalid', opts.gate ? (r.blocking > 0 ? 1 : 0) : 0);
543
562
  }
544
563
 
545
564
  /**
@@ -757,8 +776,17 @@ function checkStateFile(cwd, addIssue, repairs) {
757
776
  if (stateContent === null) {
758
777
  // skip further state checks
759
778
  } else {
760
- // Extract phase references (e.g. "Phase 3" or "phase 01") from state.md
761
- const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(match => match[1]);
779
+ // Extract phase references (e.g. "Phase 3" or "phase 01") from state.md,
780
+ // deduped by normalized phase number. A real state.md mentions the same phase
781
+ // several times -- a total, a progress line, a decisions note -- and one missing
782
+ // directory must produce one warning, not one per mention. Keying on the
783
+ // normalized form collapses "Phase 3" and "Phase 03"; the first spelling seen
784
+ // is kept so the message quotes what the file actually says.
785
+ const phaseRefs = new Map();
786
+ for (const match of stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)) {
787
+ const key = normalizePhaseName(match[1]);
788
+ if (!phaseRefs.has(key)) phaseRefs.set(key, match[1]);
789
+ }
762
790
  // Get disk phases for cross-reference
763
791
  const diskPhases = new Set();
764
792
  try {
@@ -773,7 +801,7 @@ function checkStateFile(cwd, addIssue, repairs) {
773
801
  // phases/ directory may not exist yet
774
802
  }
775
803
  // Check for invalid references -- only warn if there are phases on disk
776
- for (const ref of phaseRefs) {
804
+ for (const ref of phaseRefs.values()) {
777
805
  const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
778
806
  if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
779
807
  if (diskPhases.size > 0) {
@@ -866,10 +894,28 @@ function crossCheckRoadmapDisk(cwd, phasesDirPath, addIssue) {
866
894
  }
867
895
  } catch { /* phases/ may not exist yet */ }
868
896
 
897
+ // A roadmap phase ahead of the current position has simply not been planned yet.
898
+ // PAN's own workflow creates the condition: /pan:new-project writes a roadmap
899
+ // declaring every phase up front, and /pan:plan-phase creates directories one at a
900
+ // time -- so warning on those would make `degraded` the permanent state of every
901
+ // multi-phase project and drain the verdict of signal. Only a phase at or behind
902
+ // the current position is genuinely suspect. With no readable current phase we
903
+ // cannot tell ahead from behind, so fall back to warning on everything.
904
+ let currentPhase = null;
905
+ try {
906
+ const stateContent = fs.readFileSync(path.join(planningPath(cwd), STATE_FILE), 'utf-8');
907
+ const m = stateContent.match(/\*\*Current Phase:\*\*\s*(\d+[A-Z]?(?:\.\d+)*)/i);
908
+ if (m) currentPhase = m[1];
909
+ } catch { /* no state.md -- checkStateFile reports that; stay conservative here */ }
910
+
869
911
  for (const p of roadmapPhases) {
870
912
  const padded = String(parseInt(p, 10)).padStart(2, '0');
871
- if (!diskPhases.has(p) && !diskPhases.has(padded))
913
+ if (diskPhases.has(p) || diskPhases.has(padded)) continue;
914
+ if (currentPhase !== null && comparePhaseNum(p, currentPhase) > 0) {
915
+ addIssue('info', 'I002', `Phase ${p} in ${ROADMAP_FILE} is not planned yet (current phase: ${currentPhase})`, `Run /pan:plan-phase ${p} when you reach it`);
916
+ } else {
872
917
  addIssue('warning', 'W006', `Phase ${p} in ${ROADMAP_FILE} but no directory on disk`, 'Create phase directory or remove from roadmap');
918
+ }
873
919
  }
874
920
  for (const p of diskPhases) {
875
921
  const unpadded = String(parseInt(p, 10));
@@ -919,17 +965,13 @@ function repairIssues(cwd, repairs) {
919
965
  switch (repair) {
920
966
  case 'createConfig':
921
967
  case 'resetConfig': {
922
- // Write a fresh config.json with sensible defaults
923
- const defaults = {
924
- model_profile: 'balanced',
925
- commit_docs: true,
926
- search_gitignored: false,
927
- branching_strategy: 'none',
928
- research: true,
929
- plan_checker: true,
930
- verifier: true,
931
- parallelization: true,
932
- };
968
+ // Write the canonical NESTED config via buildConfigDefaults. The old
969
+ // flat literal (research/plan_checker/verifier at top level) did NOT
970
+ // match the schema the gate reads (config.workflow.verifier), so
971
+ // --repair silently disabled the verification gate (M31, ADR audit
972
+ // 2026-08; note the flat form also drifted plan_checker vs plan_check).
973
+ const { buildConfigDefaults } = require('./config.cjs');
974
+ const defaults = buildConfigDefaults(false, {});
933
975
  fs.writeFileSync(configFullPath, JSON.stringify(defaults, null, 2), 'utf-8');
934
976
  repairActions.push({ action: repair, success: true, path: CONFIG_FILE });
935
977
  break;
@@ -970,7 +1012,7 @@ function repairIssues(cwd, repairs) {
970
1012
  }
971
1013
  } catch (err) {
972
1014
  // Repair action failed -- record the error so callers can report it
973
- repairActions.push({ action: repair, success: false, error: err.message });
1015
+ repairActions.push({ action: repair, success: false, error: err.message || 'repair_failed' });
974
1016
  }
975
1017
  }
976
1018
 
@@ -1111,7 +1153,7 @@ function syncRequirementCheckboxes(cwd) {
1111
1153
  }
1112
1154
 
1113
1155
  if (fixed > 0) {
1114
- try { fs.writeFileSync(reqPath, reqContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message }; }
1156
+ try { fs.writeFileSync(reqPath, reqContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message || 'requirements_write_failed' }; }
1115
1157
  }
1116
1158
  return { fixed };
1117
1159
  }
@@ -1161,7 +1203,7 @@ function syncRoadmapPlanCheckboxes(cwd) {
1161
1203
  }
1162
1204
 
1163
1205
  if (fixed > 0) {
1164
- try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message }; }
1206
+ try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch (e) { return { fixed: 0, error: e.message || 'roadmap_write_failed' }; }
1165
1207
  }
1166
1208
  return { fixed };
1167
1209
  }
@@ -1308,19 +1350,6 @@ function cmdValidateHealth(cwd, options, raw) {
1308
1350
  }
1309
1351
  }
1310
1352
 
1311
- // Determine overall status from error/warning counts
1312
- let status;
1313
- if (errors.length > 0) {
1314
- status = HEALTH_STATUS.BROKEN;
1315
- } else if (warnings.length > 0) {
1316
- status = HEALTH_STATUS.DEGRADED;
1317
- } else {
1318
- status = HEALTH_STATUS.HEALTHY;
1319
- }
1320
-
1321
- const repairableCount = errors.filter(e => e.repairable).length +
1322
- warnings.filter(w => w.repairable).length;
1323
-
1324
1353
  // Check 11 (optional): drift analysis
1325
1354
  let driftResult;
1326
1355
  if (options.drift) {
@@ -1352,6 +1381,22 @@ function cmdValidateHealth(cwd, options, raw) {
1352
1381
  }
1353
1382
  }
1354
1383
 
1384
+ // Determine overall status from error/warning counts. This must run after every
1385
+ // check that can call addIssue -- computing it earlier left DRIFT_HIGH and
1386
+ // LINKS_ERR sitting in `warnings` while the verdict still read `healthy`, which
1387
+ // contradicts the documented behaviour of --links ("errors degrade health").
1388
+ let status;
1389
+ if (errors.length > 0) {
1390
+ status = HEALTH_STATUS.BROKEN;
1391
+ } else if (warnings.length > 0) {
1392
+ status = HEALTH_STATUS.DEGRADED;
1393
+ } else {
1394
+ status = HEALTH_STATUS.HEALTHY;
1395
+ }
1396
+
1397
+ const repairableCount = errors.filter(e => e.repairable).length +
1398
+ warnings.filter(w => w.repairable).length;
1399
+
1355
1400
  const result = {
1356
1401
  status,
1357
1402
  errors,
@@ -79,7 +79,7 @@ function buildCounterfactualContext(cwd, phaseNum, scenario) {
79
79
 
80
80
  return {
81
81
  phase: String(phaseNum),
82
- phase_name: phaseInfo.name || null,
82
+ phase_name: phaseInfo.phase_name || null,
83
83
  directory: toPosix(phaseInfo.directory),
84
84
  scenario,
85
85
  slug: scenarioSlug(scenario),
@@ -256,6 +256,13 @@ function cmdWhatifPrepare(cwd, phaseNum, scenario, raw) {
256
256
  const ctx = buildCounterfactualContext(cwd, phaseNum, scenario);
257
257
  if (ctx.error) { output(ctx, raw); return; }
258
258
  const wt = createWorktree(cwd, phaseNum, scenario);
259
+ // `worktree_error` (not `error`) because the payload also carries the context that
260
+ // WAS resolved, and `error` would read as "the context failed". It is still an
261
+ // error-family key, so output() derives exit 1 from it exactly as it does for the
262
+ // `ctx.error` guard one line above — which is the point: the two adjacent failures
263
+ // of the same command must not report different exit codes just because one of
264
+ // them renamed the key. Callers cannot proceed either way: what-if stage 2 spawns
265
+ // an agent INTO worktree_path, and there is no worktree.
259
266
  if (wt.error) { output({ ...ctx, worktree_error: wt.error }, raw); return; }
260
267
  output({ ...ctx, worktree: wt }, raw);
261
268
  }
@@ -89,6 +89,91 @@ function listArmyWorktrees(cwd) {
89
89
  return out;
90
90
  }
91
91
 
92
+ /**
93
+ * Sweep every army worktree and orphaned army/ branch (P-1815, PanLoop
94
+ * finding 13). The what-if subsystem always had this (`whatif cleanup`); the
95
+ * army path created worktrees that nothing removed — sibling `pan-army-*`
96
+ * directories and `army/*` branches accumulated after every campaign.
97
+ *
98
+ * Safety posture (the L3 lesson — never trade clutter for silent data loss):
99
+ * - A DIRTY worktree is kept unless `force` — git refuses, we surface why.
100
+ * - A branch is deleted only when its tip is reachable from HEAD (truly
101
+ * integrated). Squash-merged and aborted branches are NOT reachable and may
102
+ * hold the only copy of real work, so by default they are KEPT and listed
103
+ * with the exact command to delete them; `force` sweeps them too.
104
+ * (The per-task Phase 5 teardown — `worktree remove --branch` right after
105
+ * the merge lands — deletes unconditionally; at that moment the deletion is
106
+ * the documented intent. The sweeper is the abort/orphan tool, so it errs
107
+ * the other way.)
108
+ * @param {string} cwd - main project root
109
+ * @param {Object} [opts] - { force: boolean }
110
+ * @returns {{removed_worktrees, deleted_branches, kept, pruned, clean}|{error}}
111
+ */
112
+ function cleanupArmyWorktrees(cwd, opts) {
113
+ if (!isGitRepo(cwd)) return { error: 'Not a git repo' };
114
+ const force = opts?.force === true;
115
+ const removedWorktrees = [];
116
+ const deletedBranches = [];
117
+ const kept = [];
118
+
119
+ const branchIsIntegrated = (branch) =>
120
+ execGit(cwd, ['merge-base', '--is-ancestor', branch, 'HEAD']).exitCode === 0;
121
+
122
+ const deleteBranch = (branch, hadWorktree) => {
123
+ if (force || branchIsIntegrated(branch)) {
124
+ const del = execGit(cwd, ['branch', '-D', branch]);
125
+ if (del.exitCode === 0) deletedBranches.push(branch);
126
+ else kept.push({ branch, reason: `branch -D failed: ${del.stderr.trim()}` });
127
+ } else {
128
+ kept.push({
129
+ branch,
130
+ reason: `carries commits not reachable from HEAD (squash-merged or aborted work${hadWorktree ? '' : '; no worktree attached'}) — rerun with --force, or: git branch -D ${branch}`,
131
+ });
132
+ }
133
+ };
134
+
135
+ // 1. Registered army worktrees. Track every branch step 1 has already
136
+ // decided on — deleted OR deliberately kept — so the orphan scan below
137
+ // does not re-process (and double-report) it.
138
+ const handledBranches = new Set();
139
+ for (const t of listArmyWorktrees(cwd)) {
140
+ const rmArgs = ['worktree', 'remove'];
141
+ if (force) rmArgs.push('--force');
142
+ rmArgs.push(t.worktree);
143
+ const rm = execGit(cwd, rmArgs);
144
+ if (rm.exitCode !== 0) {
145
+ kept.push({ worktree: t.worktree, branch: t.branch, reason: `worktree remove refused: ${rm.stderr.trim()} — pass --force to discard uncommitted changes` });
146
+ if (t.branch) handledBranches.add(t.branch);
147
+ continue;
148
+ }
149
+ removedWorktrees.push(t.worktree);
150
+ if (t.branch) {
151
+ handledBranches.add(t.branch);
152
+ deleteBranch(t.branch, true);
153
+ }
154
+ }
155
+
156
+ // 2. Drop stale registrations (a manually deleted directory leaves one).
157
+ const pruned = execGit(cwd, ['worktree', 'prune']).exitCode === 0;
158
+
159
+ // 3. Orphaned army/ branches — a worktree removed without its branch.
160
+ const stillAttached = new Set(listArmyWorktrees(cwd).map(t => t.branch));
161
+ const ls = execGit(cwd, ['branch', '--list', `${ARMY_BRANCH_PREFIX}*`, '--format=%(refname:short)']);
162
+ if (ls.exitCode === 0) {
163
+ for (const branch of ls.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
164
+ if (!stillAttached.has(branch) && !handledBranches.has(branch)) deleteBranch(branch, false);
165
+ }
166
+ }
167
+
168
+ return {
169
+ removed_worktrees: removedWorktrees,
170
+ deleted_branches: deletedBranches,
171
+ kept,
172
+ pruned,
173
+ clean: kept.length === 0,
174
+ };
175
+ }
176
+
92
177
  // ─── CLI ─────────────────────────────────────────────────────────────────────
93
178
 
94
179
  function cmdWorktreeList(cwd, raw) {
@@ -112,12 +197,25 @@ function cmdWorktreeRemove(cwd, worktreePath, branch, raw, opts) {
112
197
  output(r, raw, r.warnings.length ? r.warnings.join('\n') : 'removed');
113
198
  }
114
199
 
200
+ function cmdWorktreeCleanup(cwd, raw, opts) {
201
+ const r = cleanupArmyWorktrees(cwd, opts);
202
+ if (r.error) return error(r.error);
203
+ const lines = [
204
+ ...r.removed_worktrees.map(w => `removed worktree ${w}`),
205
+ ...r.deleted_branches.map(b => `deleted branch ${b}`),
206
+ ...r.kept.map(k => `KEPT ${k.worktree || k.branch}: ${k.reason}`),
207
+ ];
208
+ output(r, raw, lines.length ? lines.join('\n') : 'nothing to clean');
209
+ }
210
+
115
211
  module.exports = {
116
212
  ARMY_BRANCH_PREFIX,
117
213
  createTaskWorktree,
118
214
  removeTaskWorktree,
119
215
  listArmyWorktrees,
216
+ cleanupArmyWorktrees,
120
217
  cmdWorktreeList,
121
218
  cmdWorktreeCreate,
122
219
  cmdWorktreeRemove,
220
+ cmdWorktreeCleanup,
123
221
  };
@@ -27,13 +27,13 @@
27
27
  * config-get <key> Get a config.json value
28
28
  * history-digest Aggregate all summary.md data
29
29
  * summary-extract <path> [--fields] Extract structured data from summary.md
30
- * state-snapshot Structured parse of state.md
30
+ * state-snapshot Structured parse of state.md (alias: state snapshot)
31
31
  * phase-plan-index <phase> Index plans with waves and status
32
32
  * websearch <query> Search web via Brave API (if configured)
33
33
  * [--limit N] [--freshness day|week|month]
34
34
  *
35
35
  * Phase Listing:
36
- * phases list [--type plan|summary] List phases with optional type filter
36
+ * phases list [--type plans|summaries] List phases with optional type filter
37
37
  * [--phase N] [--include-archived]
38
38
  *
39
39
  * Phase Operations:
@@ -282,8 +282,20 @@ async function main() {
282
282
 
283
283
  const command = args[0];
284
284
 
285
+ const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>]\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
286
+
285
287
  if (!command) {
286
- error('Usage: pan-tools <command> [args] [--raw] [--cwd <path>]\nCommands: state, state-snapshot, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, summary-extract, rollback-snapshot, websearch, focus, preflight, dashboard, learnings, deps, standards');
288
+ error(USAGE);
289
+ }
290
+
291
+ // P-1814 (PanLoop finding 11): `--help` was the single most frequent failed
292
+ // probe in field transcripts — 26 sightings across 10 command docs. It is
293
+ // the most predictable thing any agent or human tries, so it works: print
294
+ // the usage and exit 0. A missing command stays exit 1 (the error contract);
295
+ // an explicit request for help is a success.
296
+ if (command === '--help' || command === '-h' || command === 'help') {
297
+ console.log(USAGE);
298
+ process.exit(0);
287
299
  }
288
300
 
289
301
  switch (command) {
@@ -337,10 +349,16 @@ async function main() {
337
349
  stopped_at: getArgValue(args, '--stopped-at'),
338
350
  resume_file: getArgValue(args, '--resume-file', 'None'),
339
351
  }, raw);
352
+ } else if (subcommand === 'snapshot') {
353
+ // Alias for the top-level `state-snapshot` (P-1813). The hyphenated
354
+ // command reads exactly like a `state` subcommand, and a field agent
355
+ // guessed the spaced form by analogy (PanLoop finding 10). The guess
356
+ // is semantically right, so it lands on the real handler.
357
+ state.cmdStateSnapshot(cwd, raw);
340
358
  } else if (subcommand === 'load' || !subcommand) {
341
359
  state.cmdStateLoad(cwd, raw);
342
360
  } else {
343
- error(`Unknown state subcommand: ${subcommand}. Available: json, update, get, patch, advance-plan, record-metric, update-progress, add-decision, add-blocker, resolve-blocker, record-session, load`);
361
+ error(`Unknown state subcommand: ${subcommand}. Available: json, update, get, patch, advance-plan, record-metric, update-progress, add-decision, add-blocker, resolve-blocker, record-session, load, snapshot`);
344
362
  }
345
363
  break;
346
364
  }
@@ -530,7 +548,7 @@ async function main() {
530
548
  } else if (subcommand === 'stubs') {
531
549
  verify.cmdVerifyStubs(cwd, { gate: args.includes('--gate') }, raw);
532
550
  } else {
533
- error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links');
551
+ error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, reconcile, stubs');
534
552
  }
535
553
  break;
536
554
  }
@@ -786,8 +804,12 @@ async function main() {
786
804
 
787
805
  case 'batch-commit': {
788
806
  const itemsJson = args[1];
789
- let items = [];
790
- try { items = JSON.parse(itemsJson); } catch { /* empty */ }
807
+ // Report a malformed payload rather than silently reporting 'no_items':
808
+ // a caller whose JSON was garbled (e.g. shell-stripped quotes) must not be
809
+ // told there was nothing to commit. Absent arg → genuine empty batch.
810
+ const items = itemsJson === undefined || itemsJson === ''
811
+ ? []
812
+ : parseJsonOrError(itemsJson, 'batch-commit');
791
813
  commands.cmdBatchCommit(cwd, items, raw);
792
814
  break;
793
815
  }
@@ -1169,8 +1191,10 @@ async function main() {
1169
1191
  worktree.cmdWorktreeCreate(cwd, args[2], raw, { base: getArgValue(args, '--base') });
1170
1192
  } else if (subcommand === 'remove') {
1171
1193
  worktree.cmdWorktreeRemove(cwd, args[2], getArgValue(args, '--branch'), raw, { force: args.includes('--force') });
1194
+ } else if (subcommand === 'cleanup') {
1195
+ worktree.cmdWorktreeCleanup(cwd, raw, { force: args.includes('--force') });
1172
1196
  } else {
1173
- error('Unknown worktree subcommand. Available: list, create, remove');
1197
+ error('Unknown worktree subcommand. Available: list, create, remove, cleanup');
1174
1198
  }
1175
1199
  break;
1176
1200
  }
@@ -1295,6 +1319,7 @@ async function main() {
1295
1319
  const subcommand = args[1];
1296
1320
  if (subcommand === 'trace') {
1297
1321
  const traceSub = args[2];
1322
+ const tokensWasted = getArgValue(args, '--tokens-wasted');
1298
1323
  optimize.cmdOptimizeTrace(cwd, traceSub, {
1299
1324
  sessionId: getArgValue(args, '--session'),
1300
1325
  all: args.includes('--all'),
@@ -1306,9 +1331,8 @@ async function main() {
1306
1331
  type: getArgValue(args, '--type'),
1307
1332
  category: getArgValue(args, '--category'),
1308
1333
  impact: getArgValue(args, '--impact'),
1309
- description: getArgValue(args, '--description'),
1310
1334
  correction: getArgValue(args, '--correction'),
1311
- tokens_wasted: getArgValue(args, '--tokens-wasted') ? Number(getArgValue(args, '--tokens-wasted')) : null,
1335
+ tokens_wasted: tokensWasted ? Number(tokensWasted) : null,
1312
1336
  context: (() => { const v = getArgValue(args, '--context'); if (!v) return null; try { return JSON.parse(v); } catch { return null; } })(),
1313
1337
  }, raw);
1314
1338
  } else if (subcommand === 'learn') {
@@ -1382,7 +1406,7 @@ async function main() {
1382
1406
  const sourceExperiments = sourceExpsCsv
1383
1407
  ? sourceExpsCsv.split(',').map(s => s.trim()).filter(Boolean)
1384
1408
  : [];
1385
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1409
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1386
1410
 
1387
1411
  const result = optimize.promotePattern(
1388
1412
  { id: patternId, summary, evidence, rule, applies_in: appliesIn, source_experiments: sourceExperiments },
@@ -1396,7 +1420,7 @@ async function main() {
1396
1420
  const patternId = getArgValue(args, '--pattern');
1397
1421
  const scope = getArgValue(args, '--scope');
1398
1422
  const topic = getArgValue(args, '--topic');
1399
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1423
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1400
1424
  if (!patternId || !scope || !topic) {
1401
1425
  error('learn unpromote requires --pattern <id> --scope <s> --topic <t>');
1402
1426
  }
@@ -1406,14 +1430,14 @@ async function main() {
1406
1430
  }
1407
1431
 
1408
1432
  if (subcommand === 'list-promoted') {
1409
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1433
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1410
1434
  const result = optimize.listPromotedPatterns({ sourceRoot });
1411
1435
  output(result, raw);
1412
1436
  break;
1413
1437
  }
1414
1438
 
1415
1439
  if (subcommand === 'build-index') {
1416
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1440
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1417
1441
  const result = learnIndex.cmdBuildIndex(sourceRoot);
1418
1442
  if (raw) {
1419
1443
  output(result, true,
@@ -1428,7 +1452,7 @@ async function main() {
1428
1452
  }
1429
1453
 
1430
1454
  if (subcommand === 'topics-for') {
1431
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1455
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1432
1456
  const agent = getArgValue(args, '--agent');
1433
1457
  if (!agent) { error('learn topics-for requires --agent <name>'); }
1434
1458
  const minRelevance = getArgValue(args, '--min-relevance', 'medium');
@@ -1451,7 +1475,7 @@ async function main() {
1451
1475
  }
1452
1476
 
1453
1477
  if (subcommand === 'lint') {
1454
- const sourceRoot = getArgValue(args, '--source-root') || cwd;
1478
+ const sourceRoot = getArgValue(args, '--source-root') || learnLint.resolveLearningsRoot();
1455
1479
  const scope = getArgValue(args, '--scope');
1456
1480
  const strict = args.includes('--strict');
1457
1481
  const result = learnLint.cmdLearnLint(sourceRoot, { scope, strict });
@@ -1466,11 +1490,12 @@ async function main() {
1466
1490
  for (const v of result.violations) {
1467
1491
  lines.push(`[${v.severity.toUpperCase()}] ${v.code} ${v.pattern_id}: ${v.message}`);
1468
1492
  }
1469
- output(result, true, lines.join('\n'));
1493
+ output(result, true, lines.join('\n'), result.summary.status === 'fail' ? 1 : 0);
1470
1494
  } else {
1471
- output(result, false);
1495
+ output(result, false, undefined, result.summary.status === 'fail' ? 1 : 0);
1472
1496
  }
1473
- if (result.summary.status === 'fail') process.exit(1);
1497
+ // exit code carried through output() above (it used to hard-exit 0
1498
+ // before this check, so `learn lint` never gated — M32, ADR audit 2026-08).
1474
1499
  break;
1475
1500
  }
1476
1501
 
@@ -1504,7 +1529,7 @@ async function main() {
1504
1529
  }
1505
1530
 
1506
1531
  default:
1507
- error(`Unknown command: ${command}. Run pan-tools without arguments to see available commands.`);
1532
+ error(`Unknown command: ${command}. Run pan-tools --help to see available commands.`);
1508
1533
  }
1509
1534
  }
1510
1535
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "generated_at": "2026-07-09T15:40:55.203Z",
3
+ "generated_at": "2026-08-07T13:36:47.532Z",
4
4
  "topics": [
5
5
  {
6
6
  "name": "experiment-runner",
@@ -152,8 +152,8 @@
152
152
  "P-350",
153
153
  "P-360"
154
154
  ],
155
- "size_bytes": 10667,
156
- "size_tokens_est": 2667,
155
+ "size_bytes": 11837,
156
+ "size_tokens_est": 2960,
157
157
  "agent_relevance": {
158
158
  "planner": "low",
159
159
  "executor": "medium",
@@ -218,8 +218,8 @@
218
218
  "P-1204",
219
219
  "P-NPRS-003"
220
220
  ],
221
- "size_bytes": 3400,
222
- "size_tokens_est": 850,
221
+ "size_bytes": 3402,
222
+ "size_tokens_est": 851,
223
223
  "agent_relevance": {
224
224
  "planner": "medium",
225
225
  "executor": "high",
@@ -804,7 +804,7 @@
804
804
  "totals": {
805
805
  "topics": 47,
806
806
  "patterns": 93,
807
- "size_bytes": 148954,
808
- "size_tokens_est": 37255
807
+ "size_bytes": 150126,
808
+ "size_tokens_est": 37549
809
809
  }
810
810
  }
@@ -37,12 +37,14 @@ patterns:
37
37
 
38
38
  ## P-330 — Scale agents into a coordinated army with squads, worktree isolation, and a human-gated ship
39
39
 
40
- **Evidence:** The bot-army model (ADR-0032/0033) showed the durable shape for running a whole-project goal across many agents: a delegation-only coordinator (never codes) fans work to role-scoped *squads* (architecture/build/quality/release), the build squad parallelizes by giving each agent its own branch + git worktree (so concurrent builders never touch the same file), quality is adversarial and read-only, and the path to a protected branch is a human-approved gate, not a bot merge.
40
+ **Evidence:** The bot-army model (ADR-0032/0033) showed the durable shape for running a whole-project goal across many agents: a coordinator that routes rather than implements fans work to role-scoped *squads* (architecture/build/quality/release), the build squad parallelizes by giving each agent its own branch + git worktree (which is why concurrent builders cannot land in the same working file), quality is adversarial and reads more than it writes, and the path to a protected branch is a human-approved gate, not a bot merge.
41
+
42
+ **Where the enforcement actually lives, and where it does not.** Worktree isolation is real isolation — separate directories, separate branches, enforced by git. The *role* labels are not: a coordinator that can spawn agents generally also holds file and shell tools, and a squad's `read-only` label is a contract its prompt is told to honour rather than a sandbox the runtime imposes. Both were written as absolutes here ("delegation-only", "never codes") and both were wrong; the guidance below survived the correction unchanged, because instructing the split is still right — claiming it is enforced is what was not.
41
43
 
42
44
  **Rule:** When scaling beyond a single agent:
43
45
 
44
- 1. **Coordinator delegates, never codes** — its tools are delegation-only; it plans, decomposes, and routes to squads, then aggregates tight summaries.
45
- 2. **Group agents into role-scoped squads with least-privilege tools** — design read-only, build read/write, quality read-only/adversarial, release always-ask. Resolve the roster from data, not hardcoded prompt lists.
46
+ 1. **Instruct the coordinator to delegate rather than implement** — it plans, decomposes, and routes to squads, then aggregates tight summaries. Treat that as prompt discipline and verify the grant instead of assuming it: read the coordinator's own `tools:` frontmatter, and if it holds write or shell tools, the split is instructed, not enforced. Say which of the two your prompt is relying on wherever you describe the design to a user.
47
+ 2. **Group agents into role-scoped squads with a declared least-privilege contract** — design read-only, build read/write, quality read-only/adversarial, release always-ask. Resolve the roster from data, not hardcoded prompt lists. A label is a contract until a per-agent grant matches it; a registry that maps agents to role labels changes no execution path on its own, so publish those labels as advisory rather than as guarantees.
46
48
  3. **Parallelize by isolation, not by hope** — one branch + worktree per concurrent builder; never two agents in one tree. Serialize builds only where the build tree corrupts under concurrency (a per-project opt-in).
47
49
  4. **The mutating boundary is human-gated** — merging to a protected branch is `always-ask`; recovery is revert / previous tag, never force-push or history rewrite.
48
50
  5. **The harness scales with the army, not after it** — depth caps, spawn/budget ceilings, and an abort kill-switch checked before every spawn are mandatory; a longer loop must not relax a single cap. Power and safety are the same investment.