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.
- package/README.md +38 -24
- package/agents/pan-conductor.md +3 -3
- package/agents/pan-design-checker.md +83 -0
- package/agents/pan-designer.md +67 -0
- package/agents/pan-document_code.md +3 -2
- package/agents/pan-executor.md +2 -2
- package/agents/pan-plan-checker.md +19 -6
- package/agents/pan-planner.md +4 -1
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-roadmapper.md +3 -1
- package/agents/pan-verifier.md +10 -10
- package/bin/install-lib.cjs +128 -17
- package/bin/install.js +480 -74
- package/commands/pan/army.md +20 -14
- package/commands/pan/audit-deployment.md +17 -14
- package/commands/pan/cost.md +2 -2
- package/commands/pan/debug.md +1 -1
- package/commands/pan/design-phase.md +77 -0
- package/commands/pan/exec-phase.md +2 -2
- package/commands/pan/experiment.md +18 -14
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/focus-design.md +13 -1
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/learn.md +1 -1
- package/commands/pan/links.md +3 -1
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-new.md +3 -1
- package/commands/pan/optimize.md +2 -2
- package/commands/pan/patches.md +1 -1
- package/commands/pan/plan-phase.md +3 -1
- package/commands/pan/preview.md +3 -3
- package/commands/pan/profile.md +3 -3
- package/commands/pan/research-phase.md +1 -1
- package/commands/pan/retro.md +4 -1
- package/commands/pan/review-deep.md +1 -1
- package/commands/pan/settings.md +2 -2
- package/commands/pan/what-if.md +1 -1
- package/hooks/dist/pan-check-update.js +149 -38
- package/hooks/dist/pan-context-monitor.js +155 -81
- package/hooks/dist/pan-cost-logger.js +288 -13
- package/hooks/dist/pan-statusline.js +24 -8
- package/hooks/dist/pan-stop-guard.js +160 -0
- package/hooks/dist/pan-trace-logger.js +261 -19
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
- package/pan-wizard-core/bin/lib/bus.cjs +19 -4
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
- package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
- package/pan-wizard-core/bin/lib/commands.cjs +88 -6
- package/pan-wizard-core/bin/lib/config.cjs +27 -2
- package/pan-wizard-core/bin/lib/constants.cjs +1 -0
- package/pan-wizard-core/bin/lib/core.cjs +113 -8
- package/pan-wizard-core/bin/lib/cost.cjs +61 -24
- package/pan-wizard-core/bin/lib/distill.cjs +22 -4
- package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
- package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
- package/pan-wizard-core/bin/lib/focus.cjs +42 -7
- package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
- package/pan-wizard-core/bin/lib/git.cjs +51 -16
- package/pan-wizard-core/bin/lib/hud.cjs +17 -2
- package/pan-wizard-core/bin/lib/init.cjs +2 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
- package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
- package/pan-wizard-core/bin/lib/links.cjs +9 -6
- package/pan-wizard-core/bin/lib/lock.cjs +23 -4
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
- package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
- package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
- package/pan-wizard-core/bin/lib/phase.cjs +82 -11
- package/pan-wizard-core/bin/lib/preview.cjs +23 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
- package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
- package/pan-wizard-core/bin/lib/runner.cjs +17 -7
- package/pan-wizard-core/bin/lib/squads.cjs +43 -11
- package/pan-wizard-core/bin/lib/state.cjs +32 -11
- package/pan-wizard-core/bin/lib/template.cjs +8 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
- package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
- package/pan-wizard-core/bin/lib/verify.cjs +84 -39
- package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
- package/pan-wizard-core/bin/pan-tools.cjs +42 -19
- package/pan-wizard-core/learnings/index.json +7 -7
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
- package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
- package/pan-wizard-core/references/design-methodology.md +94 -0
- package/pan-wizard-core/references/git-integration.md +9 -9
- package/pan-wizard-core/references/guardrails.md +4 -1
- package/pan-wizard-core/references/model-profile-resolution.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +44 -34
- package/pan-wizard-core/references/planning-config.md +5 -5
- package/pan-wizard-core/references/verification-patterns.md +67 -0
- package/pan-wizard-core/templates/codebase/architecture.md +1 -1
- package/pan-wizard-core/templates/codebase/concerns.md +1 -1
- package/pan-wizard-core/templates/codebase/conventions.md +1 -1
- package/pan-wizard-core/templates/codebase/integrations.md +1 -1
- package/pan-wizard-core/templates/codebase/stack.md +1 -1
- package/pan-wizard-core/templates/codebase/structure.md +1 -1
- package/pan-wizard-core/templates/codebase/testing.md +1 -1
- package/pan-wizard-core/templates/design.md +146 -0
- package/pan-wizard-core/templates/discovery.md +3 -3
- package/pan-wizard-core/templates/milestone-archive.md +2 -2
- package/pan-wizard-core/templates/playbook.md +1 -1
- package/pan-wizard-core/templates/uat.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
- package/pan-wizard-core/workflows/exec-phase.md +28 -8
- package/pan-wizard-core/workflows/execute-plan.md +2 -3
- package/pan-wizard-core/workflows/health.md +24 -1
- package/pan-wizard-core/workflows/help.md +5 -4
- package/pan-wizard-core/workflows/learn.md +5 -5
- package/pan-wizard-core/workflows/map-codebase.md +1 -1
- package/pan-wizard-core/workflows/milestone-audit.md +1 -1
- package/pan-wizard-core/workflows/milestone-new.md +4 -4
- package/pan-wizard-core/workflows/new-project.md +47 -32
- package/pan-wizard-core/workflows/optimize.md +8 -8
- package/pan-wizard-core/workflows/pause.md +1 -1
- package/pan-wizard-core/workflows/phase-tests.md +2 -2
- package/pan-wizard-core/workflows/plan-phase.md +4 -0
- package/pan-wizard-core/workflows/profile.md +13 -10
- package/pan-wizard-core/workflows/research-phase.md +1 -1
- package/pan-wizard-core/workflows/resume-project.md +1 -1
- package/pan-wizard-core/workflows/settings.md +9 -9
- package/pan-wizard-core/workflows/transition.md +24 -8
- package/pan-wizard-core/workflows/update.md +23 -20
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/pan-zcode/README.md +12 -2
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/pan-zcode/mcp/native-tools.cjs +1 -1
- package/pan-zcode/mcp/server.cjs +76 -14
- package/scripts/build-hooks.js +2 -1
- package/scripts/generate-skills-docs.py +0 -4
- 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)} ·
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
513
|
-
//
|
|
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
|
-
|
|
533
|
-
} else {
|
|
534
|
-
process.stdout.write(JSON.stringify(result, null, 2));
|
|
537
|
+
human = lines.join('\n');
|
|
535
538
|
}
|
|
536
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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*\\*\\*${
|
|
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*${
|
|
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*${
|
|
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
|
-
|
|
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);
|