pan-wizard 3.33.0 → 3.35.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 +29 -26
- package/agents/pan-conductor.md +18 -17
- package/agents/pan-counterfactual.md +2 -2
- package/agents/pan-debugger.md +30 -9
- package/agents/pan-design-checker.md +2 -1
- package/agents/pan-designer.md +2 -2
- package/agents/pan-distiller.md +3 -3
- package/agents/pan-document_code.md +14 -14
- package/agents/pan-executor.md +30 -9
- package/agents/pan-experiment-runner.md +9 -8
- package/agents/pan-hardener.md +1 -1
- package/agents/pan-integration-checker.md +2 -2
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +4 -4
- package/agents/pan-optimizer.md +39 -43
- package/agents/pan-phase-researcher.md +10 -10
- package/agents/pan-plan-checker.md +37 -34
- package/agents/pan-planner.md +57 -44
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-project-researcher.md +12 -12
- package/agents/pan-release.md +1 -1
- package/agents/pan-research-synthesizer.md +3 -3
- package/agents/pan-reviewer.md +26 -3
- package/agents/pan-roadmapper.md +15 -14
- package/agents/pan-verifier.md +33 -25
- package/bin/install-lib.cjs +176 -38
- package/bin/install.js +81 -63
- package/commands/pan/army.md +15 -11
- package/commands/pan/assumptions.md +2 -2
- package/commands/pan/audit-deployment.md +11 -12
- package/commands/pan/cost.md +8 -8
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/debug.md +10 -7
- package/commands/pan/design-phase.md +5 -5
- package/commands/pan/discuss-phase.md +2 -0
- package/commands/pan/exec-phase.md +21 -37
- package/commands/pan/experiment.md +9 -8
- package/commands/pan/focus-auto.md +22 -20
- package/commands/pan/focus-design.md +7 -6
- package/commands/pan/focus-doc-audit.md +4 -3
- package/commands/pan/focus-drift-walking.md +4 -3
- package/commands/pan/focus-exec.md +6 -6
- package/commands/pan/focus-plan.md +12 -11
- package/commands/pan/focus-scan.md +5 -3
- package/commands/pan/focus-sync.md +3 -2
- package/commands/pan/git.md +8 -8
- package/commands/pan/health.md +2 -2
- package/commands/pan/hud.md +4 -7
- package/commands/pan/hygiene.md +14 -7
- package/commands/pan/knowledge.md +9 -10
- package/commands/pan/learn.md +10 -7
- package/commands/pan/links.md +4 -1
- package/commands/pan/map-codebase.md +1 -1
- package/commands/pan/mcp-bridge.md +19 -21
- package/commands/pan/milestone-done.md +6 -3
- package/commands/pan/milestone-new.md +1 -1
- package/commands/pan/new-project.md +10 -10
- package/commands/pan/optimize.md +18 -14
- package/commands/pan/patches.md +2 -6
- package/commands/pan/pause.md +1 -1
- package/commands/pan/phase-tests.md +3 -2
- package/commands/pan/plan-phase.md +9 -19
- package/commands/pan/preview.md +2 -3
- package/commands/pan/profile.md +2 -2
- package/commands/pan/quick.md +1 -1
- package/commands/pan/remove-phase.md +1 -1
- package/commands/pan/report.md +3 -7
- package/commands/pan/research-phase.md +16 -15
- package/commands/pan/resume.md +9 -11
- package/commands/pan/retro.md +5 -3
- package/commands/pan/review-deep.md +7 -9
- package/commands/pan/settings.md +1 -1
- package/commands/pan/verify-phase.md +0 -11
- package/commands/pan/what-if.md +3 -4
- package/hooks/dist/pan-context-monitor.js +352 -44
- package/hooks/dist/pan-cost-logger.js +30 -0
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +73 -24
- package/hooks/dist/pan-stop-guard.js +7 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
- package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
- package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
- package/pan-wizard-core/bin/lib/commands.cjs +57 -6
- package/pan-wizard-core/bin/lib/config.cjs +7 -1
- package/pan-wizard-core/bin/lib/constants.cjs +18 -6
- package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
- package/pan-wizard-core/bin/lib/core.cjs +53 -19
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
- package/pan-wizard-core/bin/lib/cost.cjs +142 -13
- package/pan-wizard-core/bin/lib/distill.cjs +6 -3
- package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
- package/pan-wizard-core/bin/lib/findings.cjs +135 -21
- package/pan-wizard-core/bin/lib/focus.cjs +13 -4
- package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
- package/pan-wizard-core/bin/lib/hygiene.cjs +184 -17
- package/pan-wizard-core/bin/lib/init.cjs +13 -4
- package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
- package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
- package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
- package/pan-wizard-core/bin/lib/memory.cjs +508 -54
- package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +97 -17
- package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
- package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
- package/pan-wizard-core/bin/lib/phase.cjs +39 -13
- package/pan-wizard-core/bin/lib/preview.cjs +34 -31
- package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
- package/pan-wizard-core/bin/lib/runner.cjs +2 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
- package/pan-wizard-core/bin/lib/state.cjs +43 -28
- package/pan-wizard-core/bin/lib/template.cjs +11 -4
- package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
- package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
- package/pan-wizard-core/bin/lib/verify-scope.cjs +11 -7
- package/pan-wizard-core/bin/lib/verify.cjs +30 -28
- package/pan-wizard-core/bin/lib/whatif.cjs +53 -9
- package/pan-wizard-core/bin/pan-tools.cjs +114 -19
- package/pan-wizard-core/learnings/index.json +5 -5
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/opencode/pan-wizard.js +139 -0
- package/pan-wizard-core/references/checkpoints.md +3 -3
- package/pan-wizard-core/references/continuation-format.md +3 -3
- package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/git-integration.md +4 -6
- package/pan-wizard-core/references/git-planning-commit.md +2 -2
- package/pan-wizard-core/references/guardrails.md +2 -2
- package/pan-wizard-core/references/handoff-decisions.md +3 -3
- package/pan-wizard-core/references/model-profile-resolution.md +5 -5
- package/pan-wizard-core/references/model-profiles.md +15 -10
- package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
- package/pan-wizard-core/references/planning-config.md +7 -7
- package/pan-wizard-core/references/tdd.md +10 -1
- package/pan-wizard-core/references/verdict-contract.md +6 -1
- package/pan-wizard-core/references/verification-patterns.md +5 -5
- package/pan-wizard-core/templates/codebase/architecture.md +2 -2
- package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
- package/pan-wizard-core/templates/codebase/concerns.md +3 -3
- package/pan-wizard-core/templates/codebase/integrations.md +6 -6
- package/pan-wizard-core/templates/codebase/relationships.md +2 -2
- package/pan-wizard-core/templates/codebase/stack.md +2 -2
- package/pan-wizard-core/templates/codebase/structure.md +4 -4
- package/pan-wizard-core/templates/codebase/testing.md +4 -4
- package/pan-wizard-core/templates/config.json +2 -6
- package/pan-wizard-core/templates/continue-here.md +1 -1
- package/pan-wizard-core/templates/debug-subagent-prompt.md +5 -3
- package/pan-wizard-core/templates/debug.md +15 -1
- package/pan-wizard-core/templates/milestone-archive.md +6 -6
- package/pan-wizard-core/templates/milestone.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +31 -17
- package/pan-wizard-core/templates/planner-subagent-prompt.md +5 -5
- package/pan-wizard-core/templates/preview-report.md +1 -1
- package/pan-wizard-core/templates/research-project/architecture.md +3 -3
- package/pan-wizard-core/templates/research-project/features.md +1 -1
- package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
- package/pan-wizard-core/templates/research-project/stack.md +3 -3
- package/pan-wizard-core/templates/research.md +5 -5
- package/pan-wizard-core/templates/retrospective.md +1 -1
- package/pan-wizard-core/templates/roadmap.md +15 -15
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/templates/summary-complex.md +1 -0
- package/pan-wizard-core/templates/summary-minimal.md +1 -0
- package/pan-wizard-core/templates/summary-standard.md +2 -1
- package/pan-wizard-core/templates/summary.md +18 -2
- package/pan-wizard-core/templates/uat.md +3 -3
- package/pan-wizard-core/templates/user-setup.md +8 -8
- package/pan-wizard-core/templates/verification-report.md +53 -8
- package/pan-wizard-core/workflows/add-phase.md +1 -1
- package/pan-wizard-core/workflows/assumptions.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +10 -8
- package/pan-wizard-core/workflows/exec-phase.md +75 -95
- package/pan-wizard-core/workflows/execute-plan.md +8 -7
- package/pan-wizard-core/workflows/health.md +13 -12
- package/pan-wizard-core/workflows/help.md +8 -9
- package/pan-wizard-core/workflows/learn.md +13 -11
- package/pan-wizard-core/workflows/map-codebase.md +10 -10
- package/pan-wizard-core/workflows/milestone-audit.md +10 -10
- package/pan-wizard-core/workflows/milestone-gaps.md +18 -25
- package/pan-wizard-core/workflows/milestone-new.md +7 -6
- package/pan-wizard-core/workflows/new-project.md +17 -25
- package/pan-wizard-core/workflows/optimize.md +11 -9
- package/pan-wizard-core/workflows/pause.md +6 -1
- package/pan-wizard-core/workflows/phase-tests.md +4 -4
- package/pan-wizard-core/workflows/plan-phase.md +53 -42
- package/pan-wizard-core/workflows/profile.md +2 -2
- package/pan-wizard-core/workflows/progress.md +6 -8
- package/pan-wizard-core/workflows/quick.md +7 -5
- package/pan-wizard-core/workflows/remove-phase.md +1 -1
- package/pan-wizard-core/workflows/research-phase.md +9 -5
- package/pan-wizard-core/workflows/resume-project.md +4 -6
- package/pan-wizard-core/workflows/retro.md +3 -1
- package/pan-wizard-core/workflows/settings.md +16 -24
- package/pan-wizard-core/workflows/todo-check.md +4 -3
- package/pan-wizard-core/workflows/transition.md +16 -12
- package/pan-wizard-core/workflows/update.md +25 -17
- package/pan-wizard-core/workflows/verify-phase.md +22 -20
- package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
- package/pan-zcode/README.md +7 -7
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/scripts/build-hooks.js +1 -0
- package/scripts/build-plugin.js +3 -1
- package/scripts/generate-skills-docs.py +15 -6
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +1 -0
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
|
|
8
8
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible, planningRel } = require('./utils.cjs');
|
|
9
|
-
const { output, error, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
|
|
9
|
+
const { output, error, isGitRepo, isGitIgnored, execGit, escapeRegex, loadConfig } = require('./core.cjs');
|
|
10
10
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
11
11
|
const { writeStateMd } = require('./state.cjs');
|
|
12
12
|
|
|
@@ -258,8 +258,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
258
258
|
result.archive_warnings = archiveResult.warnings;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
// Auto-commit + tag unless --no-commit
|
|
262
|
-
|
|
261
|
+
// Auto-commit + tag unless --no-commit, not a git repo, or the project keeps planning
|
|
262
|
+
// files out of git (`commit_docs: false` or a gitignored planning tree), the rules
|
|
263
|
+
// `phase complete` and `pan-tools commit` follow (this commit used to ignore both).
|
|
264
|
+
const noCommit = options.noCommit
|
|
265
|
+
|| loadConfig(cwd).commit_docs === false
|
|
266
|
+
|| isGitIgnored(cwd, planningRel());
|
|
267
|
+
if (!noCommit && isGitRepo(cwd)) {
|
|
263
268
|
execGit(cwd, ['add', planningRel() + '/']);
|
|
264
269
|
const commitMsg = `docs: milestone ${version} complete`;
|
|
265
270
|
const commitResult = execGit(cwd, ['commit', '-m', commitMsg]);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { output, escapeRegex, execGit } = require('./core.cjs');
|
|
11
|
+
const { output, escapeRegex, execGit, toLf } = require('./core.cjs');
|
|
12
12
|
const { planningPath, planningRel } = require('./utils.cjs');
|
|
13
13
|
|
|
14
14
|
// ─── Storage layout ──────────────────────────────────────────────────────────
|
|
@@ -793,6 +793,27 @@ function revertedIndices(rows) {
|
|
|
793
793
|
return out;
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
+
/** The trace session a report belongs to, from its name (`<session>-opt-report.md`, `<session>-analysis.json`). */
|
|
797
|
+
function reportSessionId(reportPath) {
|
|
798
|
+
const m = path.basename(String(reportPath || '')).match(/^(.+?)-(?:opt-report\.md|analysis\.json)$/);
|
|
799
|
+
return m ? m[1] : null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* An older report's memory actions (`memory`, `memory_append`, `memory_entry`) still
|
|
804
|
+
* write into `.planning/memory/`, but PAN's workflows no longer load that folder into
|
|
805
|
+
* agents (ADR-0036, amended 2026-10-04). Say so in the result rather than let the
|
|
806
|
+
* apply look like it taught anyone anything.
|
|
807
|
+
*/
|
|
808
|
+
const MEMORY_NOT_LOADED_WARNING = "stored in .planning/memory/, which PAN's workflows do not load into agents (ADR-0036): "
|
|
809
|
+
+ "write a lesson where the agents read — the project's instructions, a test, or the code";
|
|
810
|
+
|
|
811
|
+
function notLoadedWarning(cwd, relPath) {
|
|
812
|
+
const abs = path.resolve(cwd, relPath || '');
|
|
813
|
+
if (path.dirname(abs) !== path.resolve(planningPath(cwd), 'memory')) return {};
|
|
814
|
+
return { warning: MEMORY_NOT_LOADED_WARNING };
|
|
815
|
+
}
|
|
816
|
+
|
|
796
817
|
function applyReportRecommendations(cwd, reportPath) {
|
|
797
818
|
let reportContent;
|
|
798
819
|
try {
|
|
@@ -842,15 +863,23 @@ function applyReportRecommendations(cwd, reportPath) {
|
|
|
842
863
|
const resolvedCwd = path.resolve(cwd);
|
|
843
864
|
actions.forEach((action, i) => {
|
|
844
865
|
try {
|
|
845
|
-
// Containment: action.path on memory writes is report/agent-authored
|
|
846
|
-
//
|
|
847
|
-
//
|
|
866
|
+
// Containment: action.path on memory writes is report/agent-authored. A `../`
|
|
867
|
+
// (or absolute) path escaped the project (M23, ADR audit 2026-08), and a path
|
|
868
|
+
// inside it reached any file: a report's `memory_append` to CLAUDE.md made
|
|
869
|
+
// agent-written text a standing instruction with no review (ADR-0040), and
|
|
870
|
+
// `/pan:learn --apply` runs this on the optimizer's own report. A memory action
|
|
871
|
+
// writes memory: `.planning/memory/` and nothing else.
|
|
848
872
|
if (action.type === 'memory' || action.type === 'memory_append') {
|
|
849
873
|
const abs = path.resolve(cwd, action.path || '');
|
|
874
|
+
const memoryDir = path.resolve(planningPath(cwd), 'memory');
|
|
850
875
|
if (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep)) {
|
|
851
876
|
skipped.push({ action, reason: 'path escapes project root — skipped' });
|
|
852
877
|
return;
|
|
853
878
|
}
|
|
879
|
+
if (!abs.startsWith(memoryDir + path.sep)) {
|
|
880
|
+
skipped.push({ action, reason: 'a memory action writes only into .planning/memory/ — skipped; make a change to the project\'s instructions yourself' });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
854
883
|
}
|
|
855
884
|
const earlier = live.get(actionSig(action));
|
|
856
885
|
if (earlier) {
|
|
@@ -867,11 +896,29 @@ function applyReportRecommendations(cwd, reportPath) {
|
|
|
867
896
|
fs.mkdirSync(path.dirname(memPath), { recursive: true });
|
|
868
897
|
fs.writeFileSync(memPath, action.content, 'utf-8');
|
|
869
898
|
records.push({ i, type: action.type, path: rel(memPath), kind: 'created', sha256_after: normalisedHash(fs.readFileSync(memPath, 'utf-8')), action_sig: actionSig(action) });
|
|
870
|
-
applied.push({ action, result: `Written to ${action.path}
|
|
899
|
+
applied.push({ action, result: `Written to ${action.path}`, ...notLoadedWarning(cwd, action.path) });
|
|
871
900
|
}
|
|
872
901
|
} else if (action.type === 'memory_append') {
|
|
873
902
|
appendRecorded(path.join(cwd, action.path), '\n' + action.content, action, i);
|
|
874
|
-
applied.push({ action, result: `Appended to ${action.path}
|
|
903
|
+
applied.push({ action, result: `Appended to ${action.path}`, ...notLoadedWarning(cwd, action.path) });
|
|
904
|
+
} else if (action.type === 'memory_entry') {
|
|
905
|
+
// The gated write path (O6): a lesson drawn from this report's trace session,
|
|
906
|
+
// recorded only if it carries citations that hold and passes `memory record`'s
|
|
907
|
+
// other rules. The optimizer no longer proposes these; an older report's are
|
|
908
|
+
// still recorded, as created/appended so revert works.
|
|
909
|
+
const session = reportSessionId(reportPath);
|
|
910
|
+
if (!session) { skipped.push({ action, reason: 'a memory entry needs the trace session the report came from, and this report\'s name does not carry one' }); return; }
|
|
911
|
+
const { recordLesson } = require('./memory.cjs');
|
|
912
|
+
const logPath = path.join(planningPath(cwd), 'memory', `${String(action.agent || '')}.md`);
|
|
913
|
+
let before = null;
|
|
914
|
+
try { before = fs.readFileSync(logPath, 'utf-8'); } catch { /* a new log */ }
|
|
915
|
+
const r = recordLesson(cwd, String(action.agent || ''), { lesson: action.lesson, trace: session, cites: action.cites });
|
|
916
|
+
if (r.error || !r.recorded) { skipped.push({ action, reason: r.error || `memory record refused it: ${r.reason}` }); return; }
|
|
917
|
+
const after = fs.readFileSync(logPath, 'utf-8');
|
|
918
|
+
records.push(before === null
|
|
919
|
+
? { i, type: action.type, path: rel(logPath), kind: 'created', sha256_after: normalisedHash(after), action_sig: actionSig(action) }
|
|
920
|
+
: { i, type: action.type, path: rel(logPath), kind: 'appended', text: toLf(after).slice(toLf(before).length), sha256_after: normalisedHash(after), action_sig: actionSig(action) });
|
|
921
|
+
applied.push({ action, result: `Recorded for ${r.agent} from ${r.evidence}`, warning: MEMORY_NOT_LOADED_WARNING });
|
|
875
922
|
} else if (action.type === 'note') {
|
|
876
923
|
// Write a human-readable suggestion note
|
|
877
924
|
const entry = `\n## ${new Date().toISOString()}: ${action.description || 'Suggestion'}\n\n${action.content || action.suggestion || ''}\n\n**Target:** ${action.target || 'unspecified'}\n`;
|
|
@@ -992,7 +1039,11 @@ function revertApply(cwd, which) {
|
|
|
992
1039
|
return { apply_id: apply.apply_id, reverted: out.sort((x, y) => x - y), refused, status: remaining === 0 ? 'reverted' : (out.length ? 'partial' : 'refused') };
|
|
993
1040
|
}
|
|
994
1041
|
|
|
995
|
-
//
|
|
1042
|
+
// Where a lesson reaches the next run's agents: they read the project's
|
|
1043
|
+
// instructions, and PAN's workflows no longer load `.planning/memory/` (ADR-0036).
|
|
1044
|
+
const LESSON_TARGET = "the project's instructions (CLAUDE.md or AGENTS.md, outside PAN's section)";
|
|
1045
|
+
|
|
1046
|
+
// Derive suggestion notes from a raw JSON analysis when no optimizer agent
|
|
996
1047
|
// report is available (fallback for /pan:optimize apply on a JSON file).
|
|
997
1048
|
function deriveActionsFromAnalysis(analysis) {
|
|
998
1049
|
const actions = [];
|
|
@@ -1002,9 +1053,9 @@ function deriveActionsFromAnalysis(analysis) {
|
|
|
1002
1053
|
memory_miss_patterns.slice(0, 3).forEach(p => {
|
|
1003
1054
|
actions.push({
|
|
1004
1055
|
type: 'note',
|
|
1005
|
-
description: `
|
|
1006
|
-
content: `
|
|
1007
|
-
target:
|
|
1056
|
+
description: `Missing knowledge: ${p.pattern}`,
|
|
1057
|
+
content: `An agent logged this as missing ${p.count} time(s) during the traced session. Write the answer where the agents read: ${LESSON_TARGET}, or a comment at the code it concerns.`,
|
|
1058
|
+
target: LESSON_TARGET,
|
|
1008
1059
|
});
|
|
1009
1060
|
});
|
|
1010
1061
|
}
|
|
@@ -1016,8 +1067,8 @@ function deriveActionsFromAnalysis(analysis) {
|
|
|
1016
1067
|
actions.push({
|
|
1017
1068
|
type: 'note',
|
|
1018
1069
|
description: `Recurring tool failure: ${p.agent} ${p.tool} (${p.error_class})`,
|
|
1019
|
-
content: `${p.agent}'s ${p.tool} call failed with "${p.message}" in ${p.spawns} spawns across ${p.sessions} session(s) (${p.occurrences} times in all).
|
|
1020
|
-
target:
|
|
1070
|
+
content: `${p.agent}'s ${p.tool} call failed with "${p.message}" in ${p.spawns} spawns across ${p.sessions} session(s) (${p.occurrences} times in all). Write the working form of the call, or the precondition it needs, where ${p.agent} reads it: ${LESSON_TARGET}.`,
|
|
1071
|
+
target: LESSON_TARGET,
|
|
1021
1072
|
});
|
|
1022
1073
|
});
|
|
1023
1074
|
Object.entries(analysis.verdict_stats || {}).filter(([, s]) => s.fail >= 2).forEach(([agent, s]) => {
|
|
@@ -1025,7 +1076,7 @@ function deriveActionsFromAnalysis(analysis) {
|
|
|
1025
1076
|
type: 'note',
|
|
1026
1077
|
description: `Repeated judge failures: ${agent}`,
|
|
1027
1078
|
content: `${agent} failed ${s.fail} time(s) (${s.retries} retr${s.retries === 1 ? 'y' : 'ies'}, ${s.resolved_by_retry} resolved by the retry). Read the recorded findings (\`pan-tools findings list --agent ${agent}\`) for the class that keeps recurring before the next phase.`,
|
|
1028
|
-
target: '.planning/
|
|
1079
|
+
target: '.planning/findings.jsonl',
|
|
1029
1080
|
});
|
|
1030
1081
|
});
|
|
1031
1082
|
|
|
@@ -1034,8 +1085,8 @@ function deriveActionsFromAnalysis(analysis) {
|
|
|
1034
1085
|
actions.push({
|
|
1035
1086
|
type: 'note',
|
|
1036
1087
|
description: `Knowledge gap: ${p.pattern}`,
|
|
1037
|
-
content: `The agent had to infer this ${p.count} time(s). Research and
|
|
1038
|
-
target:
|
|
1088
|
+
content: `The agent had to infer this ${p.count} time(s). Research the answer and write it where the agents read: ${LESSON_TARGET}, or a comment at the code it concerns.`,
|
|
1089
|
+
target: LESSON_TARGET,
|
|
1039
1090
|
});
|
|
1040
1091
|
});
|
|
1041
1092
|
}
|
|
@@ -1218,6 +1269,18 @@ function cmdOptimizeLearn(cwd, opts, raw) {
|
|
|
1218
1269
|
}, raw);
|
|
1219
1270
|
}
|
|
1220
1271
|
|
|
1272
|
+
/**
|
|
1273
|
+
* `--report` as given resolves against the project (`--cwd`), not the shell's
|
|
1274
|
+
* directory; a bare file name that is not there is looked up in the reports dir.
|
|
1275
|
+
*/
|
|
1276
|
+
function resolveReportPath(cwd, given) {
|
|
1277
|
+
const abs = path.resolve(cwd, given);
|
|
1278
|
+
if (fs.existsSync(abs)) return abs;
|
|
1279
|
+
if (path.basename(given) !== given) return abs; // a path with directories is taken as given
|
|
1280
|
+
const inReports = path.join(getReportsDir(cwd), given);
|
|
1281
|
+
return fs.existsSync(inReports) ? inReports : abs;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1221
1284
|
function cmdOptimizeApply(cwd, opts, raw) {
|
|
1222
1285
|
const reports = listOptimizationReports(cwd);
|
|
1223
1286
|
if (reports.error || reports.count === 0) {
|
|
@@ -1225,7 +1288,7 @@ function cmdOptimizeApply(cwd, opts, raw) {
|
|
|
1225
1288
|
return;
|
|
1226
1289
|
}
|
|
1227
1290
|
|
|
1228
|
-
const reportPath = opts.reportPath
|
|
1291
|
+
const reportPath = opts.reportPath ? resolveReportPath(cwd, opts.reportPath) : reports.reports[0].path;
|
|
1229
1292
|
const result = applyReportRecommendations(cwd, reportPath);
|
|
1230
1293
|
output({ report: path.basename(reportPath), ...result }, raw);
|
|
1231
1294
|
}
|
|
@@ -1286,6 +1349,14 @@ function validatePromoteInputs(pattern, opts) {
|
|
|
1286
1349
|
if (!pattern.id) return 'pattern.id is required';
|
|
1287
1350
|
if (!pattern.summary) return 'pattern.summary is required';
|
|
1288
1351
|
if (!pattern.rule) return 'pattern.rule is required';
|
|
1352
|
+
// A citation names the code the pattern rests on, relative to the tree that
|
|
1353
|
+
// holds the store; it must hold now, and learn lint L-007 keeps checking it.
|
|
1354
|
+
if (pattern.cites !== undefined && !Array.isArray(pattern.cites)) return 'pattern.cites must be a list';
|
|
1355
|
+
const { citationProblem } = require('./memory.cjs');
|
|
1356
|
+
for (const c of pattern.cites || []) {
|
|
1357
|
+
const problem = citationProblem(opts.sourceRoot, c);
|
|
1358
|
+
if (problem) return `citation ${c}: ${problem}`;
|
|
1359
|
+
}
|
|
1289
1360
|
return null;
|
|
1290
1361
|
}
|
|
1291
1362
|
|
|
@@ -1366,7 +1437,8 @@ function classifyPatternKind(pattern) {
|
|
|
1366
1437
|
* always written by us — no general YAML dependency needed).
|
|
1367
1438
|
*/
|
|
1368
1439
|
function readTopicFile(filePath) {
|
|
1369
|
-
|
|
1440
|
+
// LF: an install committed with core.autocrlf=true holds these files as CRLF.
|
|
1441
|
+
const content = toLf(fs.readFileSync(filePath, 'utf-8'));
|
|
1370
1442
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1371
1443
|
if (!fmMatch) {
|
|
1372
1444
|
return { frontmatter: { topic: '', patterns: [] }, body: content, _raw: content };
|
|
@@ -1429,6 +1501,13 @@ function serializeTopicFile(topic, patterns, body) {
|
|
|
1429
1501
|
fm += ` promoted_at: ${p.promoted_at || ts}\n`;
|
|
1430
1502
|
const srcExps = Array.isArray(p.source_experiments) ? p.source_experiments : [];
|
|
1431
1503
|
fm += ` source_experiments: [${srcExps.join(', ')}]\n`;
|
|
1504
|
+
// Optional fields survive a re-serialisation: promoting into an existing topic
|
|
1505
|
+
// rewrites every pattern's frontmatter, and dropping these lost the supersession
|
|
1506
|
+
// learn lint L-005 reads, and the citations L-007 checks.
|
|
1507
|
+
const cites = Array.isArray(p.cites) ? p.cites : [];
|
|
1508
|
+
if (cites.length) fm += ` cites: [${cites.join(', ')}]\n`;
|
|
1509
|
+
if (p.superseded_by) fm += ` superseded_by: ${p.superseded_by}\n`;
|
|
1510
|
+
if (p.superseded_id) fm += ` superseded_id: ${p.superseded_id}\n`;
|
|
1432
1511
|
}
|
|
1433
1512
|
return `---\n${fm}---\n${body}`;
|
|
1434
1513
|
}
|
|
@@ -1519,6 +1598,7 @@ function promotePattern(pattern, opts) {
|
|
|
1519
1598
|
summary: pattern.summary,
|
|
1520
1599
|
promoted_at: promotedAt,
|
|
1521
1600
|
source_experiments: pattern.source_experiments || [],
|
|
1601
|
+
...(pattern.cites && pattern.cites.length ? { cites: pattern.cites } : {}),
|
|
1522
1602
|
});
|
|
1523
1603
|
|
|
1524
1604
|
const serialized = serializeTopicFile(topic, frontmatter.patterns, body);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The phase gate (program V1): may this phase be marked complete?
|
|
4
|
+
*
|
|
5
|
+
* Every guarantee at the end of PAN's loop used to be a sentence in a workflow:
|
|
6
|
+
* "do not advance unless the verification passed", "reconcile before advancing".
|
|
7
|
+
* The model skipped one (exec-phase's `--no-transition` path never ran reconcile at
|
|
8
|
+
* all), and `phase complete` marked the phase done regardless. The gate puts those
|
|
9
|
+
* rules in code, in one function every caller uses: `phase complete`, the native
|
|
10
|
+
* exec-waves script, and the MCP merge request.
|
|
11
|
+
*
|
|
12
|
+
* Refusal reasons, one code per class:
|
|
13
|
+
* no_plans · missing_summary · no_verification · verification_unreadable ·
|
|
14
|
+
* verification_failed (V9 lowering included) · human_needed_unapproved ·
|
|
15
|
+
* verification_stale · reconcile_contradiction · test_failed · open_findings ·
|
|
16
|
+
* stubs_found · integrity_violation
|
|
17
|
+
*
|
|
18
|
+
* What the gate could not check is listed in `not_checked`, never refused on:
|
|
19
|
+
* a skipped test gate, an unknown freshness, no git, no plan commits, the verifier
|
|
20
|
+
* switched off. `workflow.phase_gate` is `warn` (default: report and proceed) or
|
|
21
|
+
* `enforce` (refuse unless `--force --reason`, which records a decision).
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { findPhaseInternal, safeReadFile, loadConfig, output, error } = require('./core.cjs');
|
|
26
|
+
const { isVerificationFile } = require('./constants.cjs');
|
|
27
|
+
const { planningPath } = require('./utils.cjs');
|
|
28
|
+
|
|
29
|
+
const MODES = Object.freeze(['warn', 'enforce']);
|
|
30
|
+
const SERIOUS = new Set(['critical', 'high']);
|
|
31
|
+
|
|
32
|
+
/** `workflow.phase_gate` from config.json: `enforce` or (anything else) `warn`. */
|
|
33
|
+
function gateMode(cwd) {
|
|
34
|
+
let cfg = null;
|
|
35
|
+
try { cfg = JSON.parse(fs.readFileSync(planningPath(cwd, 'config.json'), 'utf-8')); } catch { /* defaults */ }
|
|
36
|
+
const v = cfg && cfg.workflow && cfg.workflow.phase_gate;
|
|
37
|
+
return MODES.includes(v) ? v : 'warn';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string|number} phase
|
|
43
|
+
* @param {{mode?: 'warn'|'enforce'}} [opts]
|
|
44
|
+
* @returns {{phase, mode, ok, reasons: Array<{code, detail}>, facts: Object, not_checked: Array<{check, reason}>}|{error, reason}}
|
|
45
|
+
*/
|
|
46
|
+
function phaseGate(cwd, phase, opts = {}) {
|
|
47
|
+
const info = findPhaseInternal(cwd, phase);
|
|
48
|
+
if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
|
|
49
|
+
const V = require('./verdict.cjs');
|
|
50
|
+
const F = require('./findings.cjs');
|
|
51
|
+
const { reconcilePhase, verificationFreshness, scanStubs } = require('./verify.cjs');
|
|
52
|
+
const { committedFiles } = require('./verify-scope.cjs');
|
|
53
|
+
const { checkTestIntegrity } = require('./verify-integrity.cjs');
|
|
54
|
+
|
|
55
|
+
const mode = MODES.includes(opts.mode) ? opts.mode : gateMode(cwd);
|
|
56
|
+
const reasons = [];
|
|
57
|
+
const notChecked = [];
|
|
58
|
+
const facts = {};
|
|
59
|
+
const refuse = (code, detail, extra = {}) => reasons.push({ code, detail, ...extra });
|
|
60
|
+
const skip = (check, reason) => notChecked.push({ check, reason });
|
|
61
|
+
const phaseNumber = info.phase_number || String(phase);
|
|
62
|
+
const dir = path.join(cwd, info.directory);
|
|
63
|
+
|
|
64
|
+
// 1. Every plan has a summary.
|
|
65
|
+
const plans = info.plans || [];
|
|
66
|
+
const incomplete = info.incomplete_plans || [];
|
|
67
|
+
facts.plans = `${plans.length - incomplete.length}/${plans.length}`;
|
|
68
|
+
if (!plans.length) refuse('no_plans', 'the phase has no plans');
|
|
69
|
+
else if (incomplete.length) refuse('missing_summary', `${incomplete.length} plan(s) have no summary: ${incomplete.join(', ')}`, { plans: incomplete });
|
|
70
|
+
|
|
71
|
+
// 2. A passing verification (with code facts applied, V9).
|
|
72
|
+
let names = [];
|
|
73
|
+
try { names = fs.readdirSync(dir); } catch { /* unreadable reads as empty */ }
|
|
74
|
+
const verFile = names.filter(isVerificationFile).sort().pop();
|
|
75
|
+
const verifierOn = loadConfig(cwd).verifier !== false;
|
|
76
|
+
if (!verFile) {
|
|
77
|
+
if (verifierOn) refuse('no_verification', 'the phase has no verification; run /pan:verify-phase');
|
|
78
|
+
else skip('verification', 'workflow.verifier is off');
|
|
79
|
+
} else {
|
|
80
|
+
const parsed = V.parseVerdictText(safeReadFile(path.join(dir, verFile)) || '', { agent: 'pan-verifier', phase: phaseNumber });
|
|
81
|
+
if (!parsed.ok) {
|
|
82
|
+
refuse('verification_unreadable', `${verFile}: ${parsed.reason}`);
|
|
83
|
+
} else {
|
|
84
|
+
const v = parsed.verdict;
|
|
85
|
+
const fm = V.verdictFromVerificationFrontmatter(safeReadFile(path.join(dir, verFile)) || '') || {};
|
|
86
|
+
facts.verification = v.verdict;
|
|
87
|
+
facts.test_gate = fm.test_gate || null;
|
|
88
|
+
if (parsed.lowered_by && parsed.lowered_by.length) facts.lowered_by = parsed.lowered_by;
|
|
89
|
+
if (fm.test_gate === 'failed') refuse('test_failed', 'the verification records test_gate: failed');
|
|
90
|
+
else if (fm.test_gate === 'skipped') skip('tests', 'the test gate was skipped');
|
|
91
|
+
else if (!fm.test_gate) skip('tests', 'the verification does not record its test gate');
|
|
92
|
+
if (v.outcome === 'fail') {
|
|
93
|
+
refuse('verification_failed', `the verification is ${v.verdict}${v.agent_verdict ? ` (the verifier wrote ${v.agent_verdict})` : ''}`);
|
|
94
|
+
} else if (v.outcome === 'needs_human') {
|
|
95
|
+
const human = F.listFindings(cwd, { phase: phaseNumber, agent: 'pan-verifier', class: 'human' }).findings;
|
|
96
|
+
const unapproved = human.filter((f) => f.status !== 'decision');
|
|
97
|
+
if (unapproved.length || !human.length) {
|
|
98
|
+
refuse('human_needed_unapproved', `${unapproved.length || 'the'} human verification item(s) have no recorded approval; record it with \`findings dispose <id> --as decision --reason "<what was checked>"\``, { ids: unapproved.map((f) => f.id) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. The verification still describes the code.
|
|
105
|
+
if (verFile) {
|
|
106
|
+
const fresh = verificationFreshness(cwd, phaseNumber, info);
|
|
107
|
+
facts.freshness = fresh.state;
|
|
108
|
+
if (fresh.state === 'stale') refuse('verification_stale', `files changed since the verified commit: ${(fresh.changed_since || []).slice(0, 5).join(', ')}`, { changed_since: fresh.changed_since });
|
|
109
|
+
else if (fresh.state === 'unknown') skip('freshness', fresh.reason || 'unknown');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. The written verdict agrees with the mechanical checks.
|
|
113
|
+
if (verFile) {
|
|
114
|
+
const rec = reconcilePhase(cwd, phaseNumber);
|
|
115
|
+
facts.reconciled = rec.reconciled;
|
|
116
|
+
if (!rec.reconciled) refuse('reconcile_contradiction', rec.contradictions.join('; '), { contradictions: rec.contradictions });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. No critical or high finding left open without a disposition.
|
|
120
|
+
const open = F.listFindings(cwd, { phase: phaseNumber, status: 'open' }).findings
|
|
121
|
+
.filter((f) => SERIOUS.has(f.severity) && f.class !== 'human');
|
|
122
|
+
facts.open_serious_findings = open.length;
|
|
123
|
+
if (open.length) refuse('open_findings', `${open.length} critical/high finding(s) are open: dispose each (\`findings dispose <id> --as fixed|deferred|dismissed|decision --reason "…"\`) or fix it`, { ids: open.map((f) => f.id) });
|
|
124
|
+
|
|
125
|
+
// 6. No stub in the files the phase committed.
|
|
126
|
+
const committed = committedFiles(cwd, phaseNumber);
|
|
127
|
+
if (!committed.git) {
|
|
128
|
+
skip('stubs', 'git is unavailable');
|
|
129
|
+
} else {
|
|
130
|
+
const files = [...committed.files].filter((f) => fs.existsSync(path.join(cwd, f)));
|
|
131
|
+
const stubs = scanStubs(cwd, { files });
|
|
132
|
+
facts.stubs_blocking = stubs.blocking;
|
|
133
|
+
if (stubs.blocking) refuse('stubs_found', `${stubs.blocking} stub marker(s) in committed files: ${stubs.findings.filter((s) => s.severity === 'high').slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 7. The phase did not weaken its own tests.
|
|
137
|
+
const integrity = checkTestIntegrity(cwd, phaseNumber);
|
|
138
|
+
if (integrity.error || !integrity.checked) {
|
|
139
|
+
skip('test_integrity', integrity.reason || integrity.error);
|
|
140
|
+
} else {
|
|
141
|
+
facts.test_integrity = integrity.ok ? 'clean' : 'violated';
|
|
142
|
+
if (!integrity.ok) refuse('integrity_violation', integrity.violations.slice(0, 3).map((x) => `${x.code} ${x.file}`).join('; '), { violations: integrity.violations });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { phase: phaseNumber, mode, ok: reasons.length === 0, reasons, facts, not_checked: notChecked };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `--force --reason`: complete past the gate, on the record. Every open finding the
|
|
150
|
+
* gate counted gets a `decision` disposition with the reason, and the trace logs it.
|
|
151
|
+
*/
|
|
152
|
+
function recordGateOverride(cwd, gate, reason) {
|
|
153
|
+
const F = require('./findings.cjs');
|
|
154
|
+
const ids = [];
|
|
155
|
+
for (const r of gate.reasons) for (const id of r.ids || []) if (!ids.includes(id)) ids.push(id);
|
|
156
|
+
const disposed = ids.length ? F.disposeFindings(cwd, { ids, as: 'decision', reason: `phase completed past the gate: ${reason}` }) : { disposed: [] };
|
|
157
|
+
try {
|
|
158
|
+
require('./optimize.cjs').logTraceEvent(cwd, {
|
|
159
|
+
type: 'decision', category: 'phase_gate_forced', agent: 'orchestrator', phase: gate.phase,
|
|
160
|
+
description: `phase ${gate.phase} completed past the gate (${gate.reasons.map((r) => r.code).join(', ')}): ${reason}`,
|
|
161
|
+
impact: 'major', context: { reasons: gate.reasons.map((r) => r.code), reason },
|
|
162
|
+
});
|
|
163
|
+
} catch { /* telemetry is best effort */ }
|
|
164
|
+
return { reason, refused_by: gate.reasons.map((r) => r.code), disposed: disposed.disposed || [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cmdPhaseGate(cwd, phase, raw, opts = {}) {
|
|
168
|
+
if (!phase) error('Usage: phase gate <N> [--mode warn|enforce]');
|
|
169
|
+
const r = phaseGate(cwd, phase, opts);
|
|
170
|
+
if (r.error) { output(r, raw); return; }
|
|
171
|
+
output(r, raw, r.ok ? 'pass' : 'refuse', r.ok ? 0 : 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { phaseGate, recordGateOverride, gateMode, cmdPhaseGate, MODES };
|
|
@@ -173,6 +173,74 @@ function renumberIntegerPhases(phasesDir, removedInt) {
|
|
|
173
173
|
return { renamedDirs, renamedFiles };
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Remove one phase's section from roadmap text. The section runs from its `Phase N:` heading
|
|
178
|
+
* to the first line outside a code fence that is another phase heading, a heading at the
|
|
179
|
+
* section's own level or above (`## Progress`, a milestone group), a `</details>` that closes
|
|
180
|
+
* an enclosing block, or a collapsed milestone's `<details>` (its `<summary>` names a version
|
|
181
|
+
* or a phase range). Any other `<details>` block the section opens and closes itself is part
|
|
182
|
+
* of it. Running to the end of the file deleted `## Progress` with the last phase and the next
|
|
183
|
+
* milestone group's heading with a group's last phase; a line-blind bound stopped at a
|
|
184
|
+
* `# comment` or `## Usage` inside a fenced block.
|
|
185
|
+
* @param {string} content - roadmap.md text (LF or CRLF; line endings are kept)
|
|
186
|
+
* @param {string} phaseNum - the phase number as written in its heading
|
|
187
|
+
* @returns {string} the text without that section (unchanged when no heading matches)
|
|
188
|
+
*/
|
|
189
|
+
function removePhaseSection(content, phaseNum) {
|
|
190
|
+
const lines = content.split('\n');
|
|
191
|
+
const fenced = new Array(lines.length).fill(false);
|
|
192
|
+
// A fence line with an info string opens a block, even inside another (a nested example);
|
|
193
|
+
// a bare one closes the innermost block it can close. A block never closed stays unfenced.
|
|
194
|
+
for (let i = 0, stack = []; i < lines.length; i++) {
|
|
195
|
+
const f = lines[i].match(/^\s{0,3}(`{3,}|~{3,})(.*)/);
|
|
196
|
+
if (!f) continue;
|
|
197
|
+
const top = stack[stack.length - 1];
|
|
198
|
+
if (top && !f[2].trim() && f[1][0] === top.fence[0] && f[1].length >= top.fence.length) {
|
|
199
|
+
stack.pop();
|
|
200
|
+
fenced.fill(true, top.open, i + 1);
|
|
201
|
+
} else if (!top || f[2].trim()) stack.push({ fence: f[1], open: i });
|
|
202
|
+
}
|
|
203
|
+
const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(phaseNum)}\\s*:`, 'i');
|
|
204
|
+
const start = lines.findIndex((l, i) => !fenced[i] && headRe.test(l));
|
|
205
|
+
if (start === -1) return content;
|
|
206
|
+
const levelEndRe = new RegExp(`^#{1,${lines[start].match(headRe)[1].length}}\\s`);
|
|
207
|
+
const endsSection = (i) => !fenced[i] && (/^#{2,4}\s*Phase\s+\d/i.test(lines[i]) || levelEndRe.test(lines[i]));
|
|
208
|
+
const opens = (i) => !fenced[i] && /^\s*<details\b/i.test(lines[i]);
|
|
209
|
+
const closes = (i) => !fenced[i] && /^\s*<\/details\b/i.test(lines[i]);
|
|
210
|
+
const oneLine = (i) => /<\/details\s*>/i.test(lines[i]);
|
|
211
|
+
// A collapsed milestone's <summary> names its version or phase range ("v1.0 MVP (Phases 1-4)").
|
|
212
|
+
const milestone = (i) => /<summary>[^<]*\b(?:v\d+\.\d+|phases\s+\d)/i.test(`${lines[i]} ${lines[i + 1] || ''}`);
|
|
213
|
+
// A <details> closed before anything that ends the section is the section's own block.
|
|
214
|
+
const ownBlock = (at) => {
|
|
215
|
+
for (let i = at + 1, d = 0; i < lines.length; i++) {
|
|
216
|
+
if (endsSection(i)) return false;
|
|
217
|
+
if (opens(i) && !oneLine(i)) d++;
|
|
218
|
+
else if (closes(i)) { if (d === 0) return true; d--; }
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
let end = start + 1;
|
|
223
|
+
for (let depth = 0; end < lines.length; end++) {
|
|
224
|
+
if (endsSection(end)) break;
|
|
225
|
+
if (opens(end)) {
|
|
226
|
+
if (depth === 0 && milestone(end)) break;
|
|
227
|
+
if (oneLine(end)) continue;
|
|
228
|
+
if (depth > 0 || ownBlock(end)) { depth++; continue; }
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
if (closes(end)) { if (depth > 0) { depth--; continue; } break; }
|
|
232
|
+
}
|
|
233
|
+
const toEof = end === lines.length;
|
|
234
|
+
lines.splice(start, end - start);
|
|
235
|
+
let out = lines.join('\n');
|
|
236
|
+
// A section that ran to the end of the file took the final line ending with it.
|
|
237
|
+
if (toEof && content.endsWith('\n')) {
|
|
238
|
+
out = out.replace(/\r$/, '');
|
|
239
|
+
if (!out.endsWith('\n')) out += content.endsWith('\r\n') ? '\r\n' : '\n';
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
176
244
|
/**
|
|
177
245
|
* Rewrite roadmap.md after a phase is removed: delete the target section,
|
|
178
246
|
* remove checkbox/table references, and renumber subsequent phase references.
|
|
@@ -189,14 +257,8 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
189
257
|
roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
190
258
|
} catch { return; }
|
|
191
259
|
|
|
192
|
-
|
|
193
|
-
// Matches from the phase heading to the next phase heading (or end of file).
|
|
260
|
+
roadmapContent = removePhaseSection(roadmapContent, phaseNum);
|
|
194
261
|
const targetEscaped = escapeRegex(phaseNum);
|
|
195
|
-
const sectionPattern = new RegExp(
|
|
196
|
-
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
|
|
197
|
-
'i'
|
|
198
|
-
);
|
|
199
|
-
roadmapContent = roadmapContent.replace(sectionPattern, '');
|
|
200
262
|
|
|
201
263
|
// Remove checkbox list items referencing this phase
|
|
202
264
|
const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
|
|
@@ -207,8 +269,9 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
207
269
|
roadmapContent = roadmapContent.replace(tableRowPattern, '');
|
|
208
270
|
|
|
209
271
|
// For integer phase removal, renumber all references to subsequent phases.
|
|
210
|
-
// Walk from
|
|
211
|
-
//
|
|
272
|
+
// Walk UP from removedInt+1, decrementing each by 1. A downward walk re-hits its
|
|
273
|
+
// own output (10->9, then that 9 and the real 9 ->8), so every later phase
|
|
274
|
+
// collapsed onto the removed number.
|
|
212
275
|
if (!isDecimal) {
|
|
213
276
|
const removedInt = parseInt(normalized, 10);
|
|
214
277
|
|
|
@@ -219,35 +282,35 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
219
282
|
// and an unbounded lower end is how `phase remove -1234567890` turned into
|
|
220
283
|
// ~1.2 billion regex passes. The loop can now never exceed maxPhase steps.
|
|
221
284
|
const stopAt = Math.max(removedInt, 0);
|
|
222
|
-
for (let oldNum =
|
|
285
|
+
for (let oldNum = stopAt + 1; oldNum <= maxPhase; oldNum++) {
|
|
223
286
|
const newNum = oldNum - 1;
|
|
224
287
|
const oldStr = String(oldNum);
|
|
225
288
|
const newStr = String(newNum);
|
|
226
289
|
const oldPad = oldStr.padStart(2, '0');
|
|
227
290
|
const newPad = newStr.padStart(2, '0');
|
|
228
291
|
|
|
229
|
-
// Phase headings: ## Phase 18: or ### Phase 18: -> ## Phase 17:
|
|
292
|
+
// Phase headings: ## Phase 18: or ### Phase 18.1: -> ## Phase 17: / ### Phase 17.1:
|
|
230
293
|
roadmapContent = roadmapContent.replace(
|
|
231
|
-
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(
|
|
294
|
+
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
|
|
232
295
|
`$1${newStr}$2`
|
|
233
296
|
);
|
|
234
297
|
|
|
235
|
-
// Inline phase references: "Phase 18:" or "Phase 18
|
|
298
|
+
// Inline phase references: "Phase 18:", "Phase 18 " or "Phase 18.1:" -> "Phase 17..."
|
|
236
299
|
roadmapContent = roadmapContent.replace(
|
|
237
|
-
new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
|
|
300
|
+
new RegExp(`(Phase\\s+)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
|
|
238
301
|
`$1${newStr}$2`
|
|
239
302
|
);
|
|
240
303
|
|
|
241
|
-
// Plan references in padded form: 18-01 -> 17-01
|
|
304
|
+
// Plan references in padded form: 18-01 / 18.1-01 -> 17-01 / 17.1-01, never inside a date (2026-10-01)
|
|
242
305
|
roadmapContent = roadmapContent.replace(
|
|
243
|
-
new RegExp(
|
|
244
|
-
`${newPad}-$
|
|
306
|
+
new RegExp(`(?<![\\d.-])${oldPad}((?:\\.\\d+)?)-(\\d{2})(?!\\d)`, 'g'),
|
|
307
|
+
`${newPad}$1-$2`
|
|
245
308
|
);
|
|
246
309
|
|
|
247
|
-
// Progress table row numbers: | 18. -> | 17.
|
|
310
|
+
// Progress table row numbers: | 18. or | 18.1. -> | 17. / | 17.1.
|
|
248
311
|
roadmapContent = roadmapContent.replace(
|
|
249
|
-
new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
|
|
250
|
-
`$1${newStr}. `
|
|
312
|
+
new RegExp(`(\\|\\s*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
|
|
313
|
+
`$1${newStr}$2. `
|
|
251
314
|
);
|
|
252
315
|
|
|
253
316
|
// Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
|
|
@@ -279,7 +342,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
279
342
|
// PHASE_NUM_RE does not match — so a bad argument used to reach the renumber
|
|
280
343
|
// logic and do real damage, in both cases reporting success and exiting 0:
|
|
281
344
|
// `phase remove 0` renumbered EVERY roadmap heading down to "Phase 0"
|
|
282
|
-
// (the
|
|
345
|
+
// (the renumber loop then ran down from 99 and re-hit
|
|
283
346
|
// its own output, collapsing 3->2->1->0) and renamed
|
|
284
347
|
// every phase directory one lower.
|
|
285
348
|
// `phase remove -1e9` spun that same loop ~1.2 billion times, each pass
|
|
@@ -425,6 +488,7 @@ module.exports = {
|
|
|
425
488
|
collectDirsToRenumber,
|
|
426
489
|
renamePhaseDir,
|
|
427
490
|
renumberIntegerPhases,
|
|
491
|
+
removePhaseSection,
|
|
428
492
|
updateRoadmapAfterRemoval,
|
|
429
493
|
cmdPhaseRemove,
|
|
430
494
|
updateStateAfterPhaseRemoval,
|
|
@@ -96,14 +96,20 @@ function resolvePhase(cwd, phaseNumber) {
|
|
|
96
96
|
function extractPhaseGoal(cwd, phaseNumber) {
|
|
97
97
|
const content = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
|
|
98
98
|
if (!content) return { objective: null, success_criteria: [] };
|
|
99
|
-
|
|
99
|
+
// Padded or not: the number comes from the zero-padded phase directory ("02"),
|
|
100
|
+
// while PAN writes `### Phase 2:` headings, so the report found no goal.
|
|
101
|
+
const unpadded = String(phaseNumber).trim().replace(/^0+(?=\d)/, '');
|
|
102
|
+
const re = new RegExp('#{2,4}\\s*Phase\\s+0*' + escapeRegex(unpadded) + ':\\s*([^\\n]+)', 'i');
|
|
100
103
|
const m = content.match(re);
|
|
101
104
|
if (!m) return { objective: null, success_criteria: [] };
|
|
102
105
|
const start = m.index;
|
|
103
106
|
const rest = content.slice(start + 1);
|
|
104
107
|
const nextH = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
|
|
105
108
|
const end = nextH ? start + 1 + nextH.index : content.length;
|
|
106
|
-
|
|
109
|
+
let section = content.slice(start, end);
|
|
110
|
+
// A phase moved out by `roadmap compact` keeps a stub; its criteria are in the history.
|
|
111
|
+
const compacted = require('./roadmap-compact.cjs').compactedPhaseSection(cwd, phaseNumber, section);
|
|
112
|
+
if (compacted && compacted.section) section = compacted.section;
|
|
107
113
|
const gm = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
|
|
108
114
|
const objective = gm ? gm[1].trim() : null;
|
|
109
115
|
const cm = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
|