pan-wizard 3.21.1 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +101 -17
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
- 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/KNOWN-BETA-RISKS.md +8 -0
- package/pan-zcode/README.md +14 -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
|
/**
|
|
@@ -24,12 +24,49 @@ const { readMemory, parseEntries, listMemoryAgents, compactMemory, DEFAULT_MAX_E
|
|
|
24
24
|
|
|
25
25
|
const DEFAULT_KEEP = 12; // recent bullets kept inline per section
|
|
26
26
|
const STATE_ARCHIVE_FILE = 'state-archive.md';
|
|
27
|
+
const QUARANTINE_FILE = 'quarantine.md';
|
|
27
28
|
|
|
28
29
|
// Sections whose bullet lists grow unbounded and are safe to reconcile.
|
|
29
30
|
const APPEND_HEAVY = /\b(decisions|blockers|concerns|pending todos|todos|session continuity|accumulated context|recent activity)\b/i;
|
|
30
31
|
// A bullet that is just a placeholder — dropped once real entries exist.
|
|
31
32
|
const PLACEHOLDER = /^-\s*(none(\s+yet)?|n\/a|tbd|todo|—|-)\.?\s*$/i;
|
|
32
33
|
|
|
34
|
+
// Memory-injection defense (threat: a subagent writes an instruction/directive
|
|
35
|
+
// into the always-loaded memory that a LATER agent or run reads and OBEYS — a
|
|
36
|
+
// cross-generation prompt injection, exactly the "agent writes self-serving
|
|
37
|
+
// directives into persistent memory for successors" vector in the OpenAI
|
|
38
|
+
// rogue-agent incident, Reuters 2026-07). See ADR-0040. state.md is agent-writable
|
|
39
|
+
// (decisions/blockers/notes), so during reconcile any bullet that reads like a
|
|
40
|
+
// directive AIMED at the agent/system is QUARANTINED out of standing memory (moved
|
|
41
|
+
// to .planning/memory/quarantine.md, reversible) rather than carried forward —
|
|
42
|
+
// nothing agent-authored becomes standing instruction without human review (the
|
|
43
|
+
// merge gate). High-precision, injection-flavored patterns only, to minimize false
|
|
44
|
+
// positives; a legitimate note caught here is recoverable from quarantine.md.
|
|
45
|
+
const DIRECTIVE_PATTERNS = [
|
|
46
|
+
/\bignore\s+(all\s+|any\s+|these\s+)?(previous|prior|earlier|above)\b/i,
|
|
47
|
+
/\bdisregard\s+(the\s+|all\s+|any\s+|your\s+)?(previous|prior|above|earlier|instructions|rules|guidelines|guardrails)\b/i,
|
|
48
|
+
/\byou\s+are\s+now\b/i,
|
|
49
|
+
/\bnew\s+instructions?\s*:/i,
|
|
50
|
+
/\b(the\s+)?system\s+prompt\b/i,
|
|
51
|
+
/\b(as|acting\s+as|being)\s+(an?\s+)?(admin|administrator|root|superuser|developer\s+with)\b/i,
|
|
52
|
+
/\bpre-?authoriz(e|ed|ation)\b/i,
|
|
53
|
+
/\b(do\s+not|don'?t|never)\s+(tell|inform|notify|ask|alert)\s+the\s+(user|human|operator)\b/i,
|
|
54
|
+
/\bwithout\s+(asking|telling|notifying|informing|alerting)\s+the\s+(user|human|operator)\b/i,
|
|
55
|
+
/\b(bypass|skip|disable|override|remove|turn\s+off)\s+(the\s+)?(merge[-\s]?gate|human[-\s]?(approval|gate|review)|approval[-\s]?gate|safety[-\s]?(harness|check|guard|gate)|verification[-\s]?gate|review[-\s]?gate)\b/i,
|
|
56
|
+
/\b(always|automatically)\s+(approve|auto-?approve|accept|merge|confirm|say\s+yes)\b/i,
|
|
57
|
+
/\bauto-?approve\s+(all|any|every|everything)\b/i,
|
|
58
|
+
/\boverride\s+(the\s+)?(human|approval|merge[-\s]?gate|safety)\b/i,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when a memory entry reads like a directive aimed at the agent/system
|
|
63
|
+
* (an injection), as opposed to a descriptive project note. Pure + zero-dep.
|
|
64
|
+
*/
|
|
65
|
+
function isSuspiciousDirective(text) {
|
|
66
|
+
if (typeof text !== 'string' || !text) return false;
|
|
67
|
+
return DIRECTIVE_PATTERNS.some((re) => re.test(text));
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
const isHeading = (l) => /^#{1,6}\s+\S/.test(l);
|
|
34
71
|
const headingText = (l) => (l.match(/^#{1,6}\s+(.*)$/) || [, ''])[1];
|
|
35
72
|
const isBullet = (l) => /^-\s+\S/.test(l);
|
|
@@ -64,7 +101,7 @@ function joinSections(sections) {
|
|
|
64
101
|
* its indented continuation lines, so a bullet is never orphaned from its detail.
|
|
65
102
|
* Overflow entries are pushed to `archived`. Returns { lines, changed }.
|
|
66
103
|
*/
|
|
67
|
-
function reconcileBullets(lines, keepN, archived) {
|
|
104
|
+
function reconcileBullets(lines, keepN, archived, quarantined) {
|
|
68
105
|
const firstB = lines.findIndex(isBullet);
|
|
69
106
|
if (firstB === -1) return { lines, changed: false };
|
|
70
107
|
|
|
@@ -87,9 +124,22 @@ function reconcileBullets(lines, keepN, archived) {
|
|
|
87
124
|
}
|
|
88
125
|
const trailer = rest.slice(i);
|
|
89
126
|
|
|
127
|
+
// 0. QUARANTINE injected directives FIRST — a bullet that reads like an
|
|
128
|
+
// instruction aimed at the agent never survives into standing memory; it is
|
|
129
|
+
// moved to the quarantine file for human review (memory-injection defense).
|
|
130
|
+
const safe = [];
|
|
131
|
+
let quarantinedHere = 0;
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
if (quarantined && isSuspiciousDirective(e.key)) {
|
|
134
|
+
quarantined.push(e.lines.join('\n'));
|
|
135
|
+
quarantinedHere++;
|
|
136
|
+
} else {
|
|
137
|
+
safe.push(e);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
90
140
|
// 1. dedupe (keep first occurrence)
|
|
91
141
|
const seen = new Set();
|
|
92
|
-
const deduped =
|
|
142
|
+
const deduped = safe.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
|
|
93
143
|
// 2. strip placeholders once real entries exist
|
|
94
144
|
const real = deduped.filter((e) => !PLACEHOLDER.test(e.key));
|
|
95
145
|
const kept0 = real.length ? real : deduped;
|
|
@@ -102,7 +152,7 @@ function reconcileBullets(lines, keepN, archived) {
|
|
|
102
152
|
}
|
|
103
153
|
for (const d of dropped) archived.push(d.lines.join('\n'));
|
|
104
154
|
|
|
105
|
-
const changed = deduped.length !==
|
|
155
|
+
const changed = quarantinedHere > 0 || deduped.length !== safe.length || kept0.length !== deduped.length || dropped.length > 0;
|
|
106
156
|
const newLines = [...pre, ...kept.flatMap((e) => e.lines), ...trailer];
|
|
107
157
|
// Preserve the section's trailing blank line (the blank that separates it from
|
|
108
158
|
// the next heading) so reconciling never collapses two sections together.
|
|
@@ -119,23 +169,22 @@ function optimizeStateContent(content, opts = {}) {
|
|
|
119
169
|
const keepN = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep : DEFAULT_KEEP;
|
|
120
170
|
const sections = parseSections(content);
|
|
121
171
|
const archived = [];
|
|
172
|
+
const quarantined = [];
|
|
122
173
|
const sectionsTouched = [];
|
|
123
174
|
let changed = false;
|
|
124
175
|
|
|
125
176
|
for (const s of sections) {
|
|
126
177
|
if (s.heading === null) continue;
|
|
127
178
|
if (!APPEND_HEAVY.test(headingText(s.heading))) continue;
|
|
128
|
-
const
|
|
129
|
-
const r = reconcileBullets(s.lines, keepN, archived);
|
|
179
|
+
const r = reconcileBullets(s.lines, keepN, archived, quarantined);
|
|
130
180
|
if (r.changed) {
|
|
131
181
|
s.lines = r.lines;
|
|
132
182
|
changed = true;
|
|
133
183
|
sectionsTouched.push(headingText(s.heading).trim());
|
|
134
184
|
}
|
|
135
|
-
void before;
|
|
136
185
|
}
|
|
137
186
|
|
|
138
|
-
return { content: changed ? joinSections(sections) : content, changed, archived, sectionsTouched };
|
|
187
|
+
return { content: changed ? joinSections(sections) : content, changed, archived, quarantined, sectionsTouched };
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
// ─── Command ────────────────────────────────────────────────────────────────
|
|
@@ -154,6 +203,30 @@ function appendArchive(cwd, entries, now) {
|
|
|
154
203
|
fs.appendFileSync(p, block, 'utf-8');
|
|
155
204
|
}
|
|
156
205
|
|
|
206
|
+
function quarantinePath(cwd) {
|
|
207
|
+
return path.join(planningPath(cwd), MEMORY_DIR, QUARANTINE_FILE);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Append quarantined directive-like entries to a dated, human-review file. These
|
|
212
|
+
* were pulled OUT of standing memory because they read like injected instructions
|
|
213
|
+
* (memory-injection defense). Reversible — a human can review and, if legitimate,
|
|
214
|
+
* restore an entry by hand. The file leads with a warning so it is never loaded
|
|
215
|
+
* as trusted instruction memory.
|
|
216
|
+
*/
|
|
217
|
+
function appendQuarantine(cwd, entries, now) {
|
|
218
|
+
if (!entries.length) return;
|
|
219
|
+
const p = quarantinePath(cwd);
|
|
220
|
+
const fresh = !fs.existsSync(p);
|
|
221
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
222
|
+
const stamp = now || '(undated)';
|
|
223
|
+
const header = fresh
|
|
224
|
+
? '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n'
|
|
225
|
+
: '';
|
|
226
|
+
const block = `${header}\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`;
|
|
227
|
+
fs.appendFileSync(p, block, 'utf-8');
|
|
228
|
+
}
|
|
229
|
+
|
|
157
230
|
/**
|
|
158
231
|
* `memory optimize [--apply] [--keep N]` — reconcile state.md + consolidate
|
|
159
232
|
* over-budget agent logs. Dry-run by default: reports what WOULD change.
|
|
@@ -172,12 +245,15 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
|
|
|
172
245
|
changed: opt.changed,
|
|
173
246
|
sections_touched: opt.sectionsTouched,
|
|
174
247
|
archived_entries: opt.archived.length,
|
|
248
|
+
quarantined_entries: opt.quarantined.length,
|
|
175
249
|
before_bytes: Buffer.byteLength(before),
|
|
176
250
|
after_bytes: Buffer.byteLength(opt.content),
|
|
177
251
|
};
|
|
178
252
|
result.archived = opt.archived.length;
|
|
253
|
+
result.quarantined = opt.quarantined.length;
|
|
179
254
|
if (apply && opt.changed) {
|
|
180
255
|
appendArchive(cwd, opt.archived, opts.now);
|
|
256
|
+
appendQuarantine(cwd, opt.quarantined, opts.now);
|
|
181
257
|
writeStateMd(statePath, opt.content, cwd);
|
|
182
258
|
}
|
|
183
259
|
} else {
|
|
@@ -187,20 +263,27 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
|
|
|
187
263
|
// Consolidate any per-agent log over the entry cap (reuses compactMemory, which
|
|
188
264
|
// no-ops under the cap). Dry-run counts entries without writing.
|
|
189
265
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
266
|
+
// M21: listMemoryAgents(cwd) returns { agents: [{ agent, entries }] }, and
|
|
267
|
+
// readMemory(cwd, name) returns { agent, entries, raw }. The old loop iterated
|
|
268
|
+
// the return OBJECT directly (a TypeError, silently swallowed by this catch)
|
|
269
|
+
// and passed the whole object to readMemory/parseEntries — so the agent-log
|
|
270
|
+
// consolidation never ran. Iterate .agents, read each by name, count entries.
|
|
271
|
+
for (const a of listMemoryAgents(cwd).agents) {
|
|
272
|
+
const mem = readMemory(cwd, a.agent);
|
|
273
|
+
if (mem == null) continue;
|
|
274
|
+
const count = parseEntries(mem.raw).length;
|
|
194
275
|
if (count > DEFAULT_MAX_ENTRIES) {
|
|
195
276
|
let removed = 0;
|
|
196
|
-
if (apply) { const r = compactMemory(cwd, a, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
|
|
197
|
-
result.agents.push({ agent: a, entries: count, over_cap: true, compacted: apply, removed });
|
|
277
|
+
if (apply) { const r = compactMemory(cwd, a.agent, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
|
|
278
|
+
result.agents.push({ agent: a.agent, entries: count, over_cap: true, compacted: apply, removed });
|
|
198
279
|
}
|
|
199
280
|
}
|
|
200
281
|
} catch { /* agent sweep is best-effort */ }
|
|
201
282
|
|
|
283
|
+
const q = result.quarantined || 0;
|
|
284
|
+
const quarantineNote = q ? `; ${q} directive-like entr${q === 1 ? 'y' : 'ies'} QUARANTINED` : '';
|
|
202
285
|
const summary = result.state.changed
|
|
203
|
-
? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
|
|
286
|
+
? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${quarantineNote}${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
|
|
204
287
|
: `state.md already lean${result.agents.length ? `; ${result.agents.length} agent log(s) over budget` : ''} — nothing to do`;
|
|
205
288
|
output(result, raw, summary);
|
|
206
289
|
}
|
|
@@ -239,8 +322,9 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
|
|
|
239
322
|
const opt = optimizeStateContent(before, { keep: opts.keep });
|
|
240
323
|
if (!opt.changed) return { optimized: false, reason: 'clean' };
|
|
241
324
|
appendArchive(cwd, opt.archived, opts.now);
|
|
325
|
+
appendQuarantine(cwd, opt.quarantined, opts.now);
|
|
242
326
|
writeStateMd(statePath, opt.content, cwd);
|
|
243
|
-
return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length };
|
|
327
|
+
return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length, quarantined: opt.quarantined.length };
|
|
244
328
|
} catch {
|
|
245
329
|
return { optimized: false, reason: 'error' };
|
|
246
330
|
}
|
|
@@ -248,6 +332,6 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
|
|
|
248
332
|
|
|
249
333
|
module.exports = {
|
|
250
334
|
optimizeStateContent, reconcileBullets, parseSections, joinSections, cmdMemoryOptimize,
|
|
251
|
-
maybeAutoOptimizeMemory, autoOptimizeEnabled,
|
|
252
|
-
APPEND_HEAVY, PLACEHOLDER, DEFAULT_KEEP, STATE_ARCHIVE_FILE,
|
|
335
|
+
maybeAutoOptimizeMemory, autoOptimizeEnabled, isSuspiciousDirective,
|
|
336
|
+
APPEND_HEAVY, PLACEHOLDER, DIRECTIVE_PATTERNS, DEFAULT_KEEP, STATE_ARCHIVE_FILE, QUARANTINE_FILE,
|
|
253
337
|
};
|
|
@@ -32,6 +32,24 @@ const {
|
|
|
32
32
|
upsertAgentsMdSection,
|
|
33
33
|
ensureClaudeMdImport,
|
|
34
34
|
} = require('./agents-md.cjs');
|
|
35
|
+
const { isSuspiciousDirective } = require('./memory-optimize.cjs');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scan a procedural-memory file (AGENTS.md / CLAUDE.md) for lines that read like
|
|
39
|
+
* directives aimed at the agent — a memory-injection risk in the ALWAYS-loaded
|
|
40
|
+
* instruction files (ADR-0040). rebuild owns only the marker-fenced PAN section
|
|
41
|
+
* (regenerated from a fixed template, so it can't be poisoned); user content is
|
|
42
|
+
* preserved by contract, so here we WARN rather than auto-edit — surfacing
|
|
43
|
+
* suspect lines for human review instead of silently rewriting the user's file.
|
|
44
|
+
*/
|
|
45
|
+
function scanForDirectives(file, content, warnings) {
|
|
46
|
+
if (typeof content !== 'string') return;
|
|
47
|
+
content.split('\n').forEach((line, i) => {
|
|
48
|
+
if (isSuspiciousDirective(line)) {
|
|
49
|
+
warnings.push({ file, line: i + 1, text: line.trim().slice(0, 200) });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
35
53
|
|
|
36
54
|
// Source repo root — mirrors experiment.cjs / install.js. __dirname is
|
|
37
55
|
// .../pan-wizard-core/bin/lib, so three levels up is the repo (or install) root.
|
|
@@ -106,6 +124,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
|
|
|
106
124
|
|
|
107
125
|
const runtimes = detectRuntimes(cwd);
|
|
108
126
|
const targets = [];
|
|
127
|
+
const warnings = [];
|
|
109
128
|
|
|
110
129
|
// 1. AGENTS.md — universal PAN section (all runtimes read it natively).
|
|
111
130
|
{
|
|
@@ -113,6 +132,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
|
|
|
113
132
|
const existing = safeReadFile(p);
|
|
114
133
|
const desired = upsertAgentsMdSection(existing, buildAgentsMdSection());
|
|
115
134
|
targets.push({ file: 'AGENTS.md', ...rebuildFile(p, existing, desired, apply) });
|
|
135
|
+
scanForDirectives('AGENTS.md', desired, warnings);
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
// 2. CLAUDE.md — Claude bridge, only when the Claude runtime is installed.
|
|
@@ -121,6 +141,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
|
|
|
121
141
|
const existing = safeReadFile(p);
|
|
122
142
|
const desired = ensureClaudeMdImport(existing);
|
|
123
143
|
targets.push({ file: 'CLAUDE.md', ...rebuildFile(p, existing, desired, apply) });
|
|
144
|
+
scanForDirectives('CLAUDE.md', desired, warnings);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
// 3. state.md — re-derive YAML frontmatter from the body (progress/status).
|
|
@@ -139,10 +160,12 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
|
|
|
139
160
|
runtimes,
|
|
140
161
|
rebuilt: targets,
|
|
141
162
|
changed_count: changed.length,
|
|
163
|
+
directive_warnings: warnings,
|
|
142
164
|
};
|
|
143
|
-
const
|
|
165
|
+
const warnNote = warnings.length ? `; ⚠ ${warnings.length} directive-like line(s) in procedural memory — review (not auto-edited)` : '';
|
|
166
|
+
const summary = (changed.length === 0
|
|
144
167
|
? `tools memory already current (${targets.map((t) => t.file).join(', ')}) — nothing to do`
|
|
145
|
-
: `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}
|
|
168
|
+
: `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`) + warnNote;
|
|
146
169
|
output(result, raw, summary);
|
|
147
170
|
}
|
|
148
171
|
|
|
@@ -151,6 +174,7 @@ module.exports = {
|
|
|
151
174
|
detectRuntimes,
|
|
152
175
|
isInsideSourceRepo,
|
|
153
176
|
rebuildFile,
|
|
177
|
+
scanForDirectives,
|
|
154
178
|
PAN_SOURCE_ROOT,
|
|
155
179
|
RUNTIME_DIRS,
|
|
156
180
|
};
|
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { PLANNING_DIR, PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
|
|
8
8
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
|
|
9
|
-
const { output, error, isGitRepo, execGit } = require('./core.cjs');
|
|
9
|
+
const { output, error, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
|
|
10
10
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
11
11
|
const { writeStateMd } = require('./state.cjs');
|
|
12
12
|
|
|
@@ -39,7 +39,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
|
|
39
39
|
try {
|
|
40
40
|
reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
41
41
|
} catch {
|
|
42
|
-
|
|
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;
|