@sienklogic/plan-build-run 2.33.1 → 2.37.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/CHANGELOG.md +678 -0
- package/dashboard/public/css/command-center.css +152 -65
- package/dashboard/public/css/explorer.css +70 -41
- package/dashboard/public/css/layout.css +163 -2
- package/dashboard/public/css/settings.css +108 -110
- package/dashboard/public/css/timeline.css +2 -1
- package/dashboard/public/css/tokens.css +13 -0
- package/dashboard/public/js/sidebar-toggle.js +21 -7
- package/dashboard/src/components/Layout.tsx +51 -7
- package/dashboard/src/components/explorer/tabs/MilestonesTab.tsx +18 -2
- package/dashboard/src/components/explorer/tabs/PhasesTab.tsx +11 -1
- package/dashboard/src/components/explorer/tabs/TodosTab.tsx +25 -6
- package/dashboard/src/components/partials/AttentionPanel.tsx +7 -1
- package/dashboard/src/components/partials/CurrentPhaseCard.tsx +26 -24
- package/dashboard/src/components/partials/QuickActions.tsx +21 -11
- package/dashboard/src/components/partials/StatCardGrid.tsx +67 -0
- package/dashboard/src/components/partials/StatusHeader.tsx +2 -1
- package/dashboard/src/components/settings/LogEntryList.tsx +43 -5
- package/dashboard/src/routes/command-center.routes.tsx +8 -7
- package/dashboard/src/routes/index.routes.tsx +32 -29
- package/package.json +2 -2
- package/plugins/copilot-pbr/agents/audit.agent.md +128 -16
- package/plugins/copilot-pbr/agents/codebase-mapper.agent.md +48 -1
- package/plugins/copilot-pbr/agents/debugger.agent.md +47 -1
- package/plugins/copilot-pbr/agents/executor.agent.md +152 -8
- package/plugins/copilot-pbr/agents/general.agent.md +46 -1
- package/plugins/copilot-pbr/agents/integration-checker.agent.md +52 -2
- package/plugins/copilot-pbr/agents/plan-checker.agent.md +50 -2
- package/plugins/copilot-pbr/agents/planner.agent.md +54 -1
- package/plugins/copilot-pbr/agents/researcher.agent.md +47 -2
- package/plugins/copilot-pbr/agents/synthesizer.agent.md +49 -1
- package/plugins/copilot-pbr/agents/verifier.agent.md +86 -2
- package/plugins/copilot-pbr/hooks/hooks.json +11 -0
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/references/agent-contracts.md +27 -0
- package/plugins/copilot-pbr/references/checkpoints.md +32 -1
- package/plugins/copilot-pbr/references/context-quality-tiers.md +45 -0
- package/plugins/copilot-pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/copilot-pbr/references/questioning.md +21 -1
- package/plugins/copilot-pbr/references/verification-patterns.md +52 -1
- package/plugins/copilot-pbr/skills/audit/SKILL.md +19 -3
- package/plugins/copilot-pbr/skills/begin/SKILL.md +57 -4
- package/plugins/copilot-pbr/skills/build/SKILL.md +39 -2
- package/plugins/copilot-pbr/skills/debug/SKILL.md +12 -1
- package/plugins/copilot-pbr/skills/explore/SKILL.md +13 -2
- package/plugins/copilot-pbr/skills/import/SKILL.md +26 -1
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/copilot-pbr/skills/plan/SKILL.md +50 -0
- package/plugins/copilot-pbr/skills/quick/SKILL.md +21 -0
- package/plugins/copilot-pbr/skills/review/SKILL.md +45 -0
- package/plugins/copilot-pbr/skills/scan/SKILL.md +20 -0
- package/plugins/copilot-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/copilot-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/audit.md +51 -5
- package/plugins/cursor-pbr/agents/codebase-mapper.md +48 -1
- package/plugins/cursor-pbr/agents/debugger.md +47 -1
- package/plugins/cursor-pbr/agents/executor.md +152 -8
- package/plugins/cursor-pbr/agents/general.md +46 -1
- package/plugins/cursor-pbr/agents/integration-checker.md +51 -1
- package/plugins/cursor-pbr/agents/plan-checker.md +49 -1
- package/plugins/cursor-pbr/agents/planner.md +54 -1
- package/plugins/cursor-pbr/agents/researcher.md +46 -1
- package/plugins/cursor-pbr/agents/synthesizer.md +49 -1
- package/plugins/cursor-pbr/agents/verifier.md +85 -1
- package/plugins/cursor-pbr/hooks/hooks.json +9 -0
- package/plugins/cursor-pbr/references/agent-contracts.md +27 -0
- package/plugins/cursor-pbr/references/checkpoints.md +32 -1
- package/plugins/cursor-pbr/references/context-quality-tiers.md +45 -0
- package/plugins/cursor-pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/cursor-pbr/references/questioning.md +21 -1
- package/plugins/cursor-pbr/references/verification-patterns.md +52 -1
- package/plugins/cursor-pbr/skills/audit/SKILL.md +19 -3
- package/plugins/cursor-pbr/skills/begin/SKILL.md +57 -4
- package/plugins/cursor-pbr/skills/build/SKILL.md +37 -2
- package/plugins/cursor-pbr/skills/debug/SKILL.md +12 -1
- package/plugins/cursor-pbr/skills/explore/SKILL.md +13 -2
- package/plugins/cursor-pbr/skills/import/SKILL.md +26 -1
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/cursor-pbr/skills/plan/SKILL.md +50 -0
- package/plugins/cursor-pbr/skills/quick/SKILL.md +21 -0
- package/plugins/cursor-pbr/skills/review/SKILL.md +45 -0
- package/plugins/cursor-pbr/skills/scan/SKILL.md +20 -0
- package/plugins/cursor-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/cursor-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/audit.md +44 -0
- package/plugins/pbr/agents/codebase-mapper.md +47 -0
- package/plugins/pbr/agents/debugger.md +46 -0
- package/plugins/pbr/agents/executor.md +150 -6
- package/plugins/pbr/agents/general.md +45 -0
- package/plugins/pbr/agents/integration-checker.md +50 -0
- package/plugins/pbr/agents/plan-checker.md +48 -0
- package/plugins/pbr/agents/planner.md +51 -0
- package/plugins/pbr/agents/researcher.md +45 -0
- package/plugins/pbr/agents/synthesizer.md +48 -0
- package/plugins/pbr/agents/verifier.md +84 -0
- package/plugins/pbr/hooks/hooks.json +9 -0
- package/plugins/pbr/references/agent-contracts.md +27 -0
- package/plugins/pbr/references/checkpoints.md +32 -0
- package/plugins/pbr/references/context-quality-tiers.md +45 -0
- package/plugins/pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/pbr/references/questioning.md +21 -0
- package/plugins/pbr/references/verification-patterns.md +52 -0
- package/plugins/pbr/scripts/check-plan-format.js +15 -3
- package/plugins/pbr/scripts/check-state-sync.js +26 -7
- package/plugins/pbr/scripts/check-subagent-output.js +30 -2
- package/plugins/pbr/scripts/config-schema.json +11 -1
- package/plugins/pbr/scripts/context-bridge.js +259 -0
- package/plugins/pbr/scripts/context-budget-check.js +2 -2
- package/plugins/pbr/scripts/lib/config.js +178 -0
- package/plugins/pbr/scripts/lib/core.js +578 -0
- package/plugins/pbr/scripts/lib/history.js +73 -0
- package/plugins/pbr/scripts/lib/init.js +166 -0
- package/plugins/pbr/scripts/lib/phase.js +364 -0
- package/plugins/pbr/scripts/lib/roadmap.js +175 -0
- package/plugins/pbr/scripts/lib/state.js +397 -0
- package/plugins/pbr/scripts/pbr-tools.js +346 -1235
- package/plugins/pbr/scripts/post-write-dispatch.js +5 -4
- package/plugins/pbr/scripts/post-write-quality.js +3 -3
- package/plugins/pbr/scripts/pre-write-dispatch.js +1 -1
- package/plugins/pbr/scripts/progress-tracker.js +1 -1
- package/plugins/pbr/scripts/suggest-compact.js +1 -1
- package/plugins/pbr/scripts/track-context-budget.js +53 -2
- package/plugins/pbr/scripts/validate-task.js +20 -28
- package/plugins/pbr/skills/audit/SKILL.md +19 -3
- package/plugins/pbr/skills/begin/SKILL.md +48 -2
- package/plugins/pbr/skills/build/SKILL.md +39 -2
- package/plugins/pbr/skills/debug/SKILL.md +12 -1
- package/plugins/pbr/skills/debug/templates/continuation-prompt.md.tmpl +12 -1
- package/plugins/pbr/skills/debug/templates/initial-investigation-prompt.md.tmpl +12 -5
- package/plugins/pbr/skills/explore/SKILL.md +13 -2
- package/plugins/pbr/skills/import/SKILL.md +26 -1
- package/plugins/pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/pbr/skills/plan/SKILL.md +52 -2
- package/plugins/pbr/skills/quick/SKILL.md +21 -0
- package/plugins/pbr/skills/review/SKILL.md +46 -0
- package/plugins/pbr/skills/scan/SKILL.md +20 -0
- package/plugins/pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* pbr-tools.js — Structured JSON state operations for Plan-Build-Run skills.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Thin dispatcher that imports from lib/ modules. All core logic lives in:
|
|
7
|
+
* lib/core.js — Foundation utilities (parsers, file ops, constants)
|
|
8
|
+
* lib/config.js — Config loading, validation, depth profiles
|
|
9
|
+
* lib/state.js — STATE.md operations (load, update, patch, advance)
|
|
10
|
+
* lib/roadmap.js — ROADMAP.md operations (parse, update status/plans)
|
|
11
|
+
* lib/phase.js — Phase operations (add, remove, list, info, plan-index)
|
|
12
|
+
* lib/init.js — Compound init commands (execute-phase, plan-phase, etc.)
|
|
13
|
+
* lib/history.js — HISTORY.md operations (append, load)
|
|
14
|
+
*
|
|
15
|
+
* Skills call this via:
|
|
8
16
|
* node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js <command> [args]
|
|
9
17
|
*
|
|
10
18
|
* Commands:
|
|
@@ -21,10 +29,88 @@
|
|
|
21
29
|
* history append <type> <title> [body] — Append record to HISTORY.md
|
|
22
30
|
* history load — Load all HISTORY.md records as JSON
|
|
23
31
|
* llm metrics [--session <ISO>] — Lifetime or session-scoped LLM usage metrics
|
|
32
|
+
* validate-project — Comprehensive .planning/ integrity check
|
|
33
|
+
* phase add <slug> [--after N] — Add a new phase directory (with renumbering)
|
|
34
|
+
* phase remove <N> — Remove an empty phase directory (with renumbering)
|
|
35
|
+
* phase list — List all phase directories with status
|
|
36
|
+
*
|
|
37
|
+
* Environment: PBR_PROJECT_ROOT — Override project root directory (used when hooks fire from subagent cwd)
|
|
24
38
|
*/
|
|
25
39
|
|
|
26
40
|
const fs = require('fs');
|
|
27
41
|
const path = require('path');
|
|
42
|
+
|
|
43
|
+
// --- Import lib modules ---
|
|
44
|
+
const {
|
|
45
|
+
KNOWN_AGENTS,
|
|
46
|
+
VALID_STATUS_TRANSITIONS,
|
|
47
|
+
validateStatusTransition,
|
|
48
|
+
output,
|
|
49
|
+
error,
|
|
50
|
+
parseYamlFrontmatter,
|
|
51
|
+
parseMustHaves,
|
|
52
|
+
findFiles,
|
|
53
|
+
tailLines,
|
|
54
|
+
countMustHaves,
|
|
55
|
+
determinePhaseStatus,
|
|
56
|
+
atomicWrite,
|
|
57
|
+
lockedFileUpdate,
|
|
58
|
+
writeActiveSkill
|
|
59
|
+
} = require('./lib/core');
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
configLoad: _configLoad,
|
|
63
|
+
configClearCache: _configClearCache,
|
|
64
|
+
configValidate: _configValidate,
|
|
65
|
+
resolveDepthProfile,
|
|
66
|
+
DEPTH_PROFILE_DEFAULTS
|
|
67
|
+
} = require('./lib/config');
|
|
68
|
+
|
|
69
|
+
const {
|
|
70
|
+
parseStateMd,
|
|
71
|
+
updateLegacyStateField,
|
|
72
|
+
updateFrontmatterField,
|
|
73
|
+
stateLoad: _stateLoad,
|
|
74
|
+
stateCheckProgress: _stateCheckProgress,
|
|
75
|
+
stateUpdate: _stateUpdate,
|
|
76
|
+
statePatch: _statePatch,
|
|
77
|
+
stateAdvancePlan: _stateAdvancePlan,
|
|
78
|
+
stateRecordMetric: _stateRecordMetric
|
|
79
|
+
} = require('./lib/state');
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
parseRoadmapMd,
|
|
83
|
+
findRoadmapRow,
|
|
84
|
+
updateTableRow,
|
|
85
|
+
roadmapUpdateStatus: _roadmapUpdateStatus,
|
|
86
|
+
roadmapUpdatePlans: _roadmapUpdatePlans
|
|
87
|
+
} = require('./lib/roadmap');
|
|
88
|
+
|
|
89
|
+
const {
|
|
90
|
+
frontmatter: _frontmatter,
|
|
91
|
+
planIndex: _planIndex,
|
|
92
|
+
mustHavesCollect: _mustHavesCollect,
|
|
93
|
+
phaseInfo: _phaseInfo,
|
|
94
|
+
phaseAdd: _phaseAdd,
|
|
95
|
+
phaseRemove: _phaseRemove,
|
|
96
|
+
phaseList: _phaseList
|
|
97
|
+
} = require('./lib/phase');
|
|
98
|
+
|
|
99
|
+
const {
|
|
100
|
+
initExecutePhase: _initExecutePhase,
|
|
101
|
+
initPlanPhase: _initPlanPhase,
|
|
102
|
+
initQuick: _initQuick,
|
|
103
|
+
initVerifyWork: _initVerifyWork,
|
|
104
|
+
initResume: _initResume,
|
|
105
|
+
initProgress: _initProgress
|
|
106
|
+
} = require('./lib/init');
|
|
107
|
+
|
|
108
|
+
const {
|
|
109
|
+
historyAppend: _historyAppend,
|
|
110
|
+
historyLoad: _historyLoad
|
|
111
|
+
} = require('./lib/history');
|
|
112
|
+
|
|
113
|
+
// --- Local LLM imports (not extracted — separate module tree) ---
|
|
28
114
|
const { resolveConfig, checkHealth } = require('./local-llm/health');
|
|
29
115
|
const { classifyArtifact } = require('./local-llm/operations/classify-artifact');
|
|
30
116
|
const { scoreSource } = require('./local-llm/operations/score-source');
|
|
@@ -33,188 +119,242 @@ const { summarizeContext } = require('./local-llm/operations/summarize-context')
|
|
|
33
119
|
const { readSessionMetrics, summarizeMetrics, computeLifetimeMetrics } = require('./local-llm/metrics');
|
|
34
120
|
const { computeThresholdAdjustments } = require('./local-llm/threshold-tuner');
|
|
35
121
|
|
|
36
|
-
|
|
37
|
-
const planningDir = path.join(cwd, '.planning');
|
|
122
|
+
// --- Module-level state (for backwards compatibility) ---
|
|
38
123
|
|
|
39
|
-
|
|
124
|
+
let cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
|
|
125
|
+
let planningDir = path.join(cwd, '.planning');
|
|
40
126
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* invalid transitions produce a stderr warning but are not blocked, to avoid
|
|
45
|
-
* breaking existing workflows.
|
|
46
|
-
*
|
|
47
|
-
* State machine:
|
|
48
|
-
* pending -> planned, skipped
|
|
49
|
-
* planned -> building
|
|
50
|
-
* building -> built, partial, needs_fixes
|
|
51
|
-
* built -> verified, needs_fixes
|
|
52
|
-
* partial -> building, needs_fixes
|
|
53
|
-
* verified -> building (re-execution)
|
|
54
|
-
* needs_fixes -> planned, building
|
|
55
|
-
* skipped -> pending (unskip)
|
|
56
|
-
*/
|
|
57
|
-
const VALID_STATUS_TRANSITIONS = {
|
|
58
|
-
pending: ['planned', 'skipped'],
|
|
59
|
-
planned: ['building'],
|
|
60
|
-
building: ['built', 'partial', 'needs_fixes'],
|
|
61
|
-
built: ['verified', 'needs_fixes'],
|
|
62
|
-
partial: ['building', 'needs_fixes'],
|
|
63
|
-
verified: ['building'],
|
|
64
|
-
needs_fixes: ['planned', 'building'],
|
|
65
|
-
skipped: ['pending']
|
|
66
|
-
};
|
|
127
|
+
// --- Wrapper functions that pass planningDir to lib modules ---
|
|
128
|
+
// These preserve the original function signatures (no planningDir param)
|
|
129
|
+
// so existing callers (hook scripts, tests) continue to work.
|
|
67
130
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
* @param {string} oldStatus - Current phase status
|
|
73
|
-
* @param {string} newStatus - Desired phase status
|
|
74
|
-
* @returns {{ valid: boolean, warning?: string }}
|
|
75
|
-
*/
|
|
76
|
-
function validateStatusTransition(oldStatus, newStatus) {
|
|
77
|
-
const from = (oldStatus || '').trim().toLowerCase();
|
|
78
|
-
const to = (newStatus || '').trim().toLowerCase();
|
|
131
|
+
function configLoad(dir) {
|
|
132
|
+
return _configLoad(dir || planningDir);
|
|
133
|
+
}
|
|
79
134
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
function configClearCache() {
|
|
136
|
+
_configClearCache();
|
|
137
|
+
cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
|
|
138
|
+
planningDir = path.join(cwd, '.planning');
|
|
139
|
+
}
|
|
84
140
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
141
|
+
function configValidate(preloadedConfig) {
|
|
142
|
+
return _configValidate(preloadedConfig, planningDir);
|
|
143
|
+
}
|
|
89
144
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
145
|
+
function stateLoad() {
|
|
146
|
+
return _stateLoad(planningDir);
|
|
147
|
+
}
|
|
94
148
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
warning: `Suspicious status transition: "${from}" -> "${to}". Expected one of: [${allowed.join(', ')}]. Proceeding anyway (advisory).`
|
|
98
|
-
};
|
|
149
|
+
function stateCheckProgress() {
|
|
150
|
+
return _stateCheckProgress(planningDir);
|
|
99
151
|
}
|
|
100
152
|
|
|
101
|
-
|
|
153
|
+
function stateUpdate(field, value) {
|
|
154
|
+
return _stateUpdate(field, value, planningDir);
|
|
155
|
+
}
|
|
102
156
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
157
|
+
function statePatch(jsonStr) {
|
|
158
|
+
return _statePatch(jsonStr, planningDir);
|
|
159
|
+
}
|
|
106
160
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
* Returns the parsed config object, or null if not found / parse error.
|
|
110
|
-
* Cache invalidates when file mtime changes or path differs.
|
|
111
|
-
*
|
|
112
|
-
* @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
|
|
113
|
-
* @returns {object|null} Parsed config or null
|
|
114
|
-
*/
|
|
115
|
-
function configLoad(dir) {
|
|
116
|
-
const configPath = path.join(dir || planningDir, 'config.json');
|
|
117
|
-
try {
|
|
118
|
-
if (!fs.existsSync(configPath)) return null;
|
|
119
|
-
const stat = fs.statSync(configPath);
|
|
120
|
-
const mtime = stat.mtimeMs;
|
|
121
|
-
if (_configCache && mtime === _configMtime && configPath === _configPath) {
|
|
122
|
-
return _configCache;
|
|
123
|
-
}
|
|
124
|
-
_configCache = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
125
|
-
_configMtime = mtime;
|
|
126
|
-
_configPath = configPath;
|
|
127
|
-
return _configCache;
|
|
128
|
-
} catch (_e) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
161
|
+
function stateAdvancePlan() {
|
|
162
|
+
return _stateAdvancePlan(planningDir);
|
|
131
163
|
}
|
|
132
164
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
* Useful in tests where multiple temp directories are used in rapid succession.
|
|
136
|
-
*/
|
|
137
|
-
function configClearCache() {
|
|
138
|
-
_configCache = null;
|
|
139
|
-
_configMtime = 0;
|
|
140
|
-
_configPath = null;
|
|
165
|
+
function stateRecordMetric(metricArgs) {
|
|
166
|
+
return _stateRecordMetric(metricArgs, planningDir);
|
|
141
167
|
}
|
|
142
168
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
function roadmapUpdateStatus(phaseNum, newStatus) {
|
|
170
|
+
return _roadmapUpdateStatus(phaseNum, newStatus, planningDir);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function roadmapUpdatePlans(phaseNum, complete, total) {
|
|
174
|
+
return _roadmapUpdatePlans(phaseNum, complete, total, planningDir);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function frontmatter(filePath) {
|
|
178
|
+
return _frontmatter(filePath);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function planIndex(phaseNum) {
|
|
182
|
+
return _planIndex(phaseNum, planningDir);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mustHavesCollect(phaseNum) {
|
|
186
|
+
return _mustHavesCollect(phaseNum, planningDir);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function phaseInfo(phaseNum) {
|
|
190
|
+
return _phaseInfo(phaseNum, planningDir);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function phaseAdd(slug, afterPhase) {
|
|
194
|
+
return _phaseAdd(slug, afterPhase, planningDir);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function phaseRemove(phaseNum) {
|
|
198
|
+
return _phaseRemove(phaseNum, planningDir);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function phaseList() {
|
|
202
|
+
return _phaseList(planningDir);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function initExecutePhase(phaseNum) {
|
|
206
|
+
return _initExecutePhase(phaseNum, planningDir);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function initPlanPhase(phaseNum) {
|
|
210
|
+
return _initPlanPhase(phaseNum, planningDir);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function initQuick(description) {
|
|
214
|
+
return _initQuick(description, planningDir);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function initVerifyWork(phaseNum) {
|
|
218
|
+
return _initVerifyWork(phaseNum, planningDir);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function initResume() {
|
|
222
|
+
return _initResume(planningDir);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function initProgress() {
|
|
226
|
+
return _initProgress(planningDir);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function historyAppend(entry, dir) {
|
|
230
|
+
return _historyAppend(entry, dir || planningDir);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function historyLoad(dir) {
|
|
234
|
+
return _historyLoad(dir || planningDir);
|
|
164
235
|
}
|
|
165
236
|
|
|
237
|
+
// --- validateProject stays here (cross-cutting across modules) ---
|
|
238
|
+
|
|
166
239
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
240
|
+
* Comprehensive .planning/ integrity check.
|
|
241
|
+
* Returns { valid, errors, warnings, checks } — errors mean workflow should not proceed.
|
|
169
242
|
*/
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
'
|
|
178
|
-
'debug.max_hypothesis_rounds': 3
|
|
179
|
-
},
|
|
180
|
-
standard: {
|
|
181
|
-
'features.research_phase': true,
|
|
182
|
-
'features.plan_checking': true,
|
|
183
|
-
'features.goal_verification': true,
|
|
184
|
-
'features.inline_verify': false,
|
|
185
|
-
'scan.mapper_count': 4,
|
|
186
|
-
'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
|
|
187
|
-
'debug.max_hypothesis_rounds': 5
|
|
188
|
-
},
|
|
189
|
-
comprehensive: {
|
|
190
|
-
'features.research_phase': true,
|
|
191
|
-
'features.plan_checking': true,
|
|
192
|
-
'features.goal_verification': true,
|
|
193
|
-
'features.inline_verify': true,
|
|
194
|
-
'scan.mapper_count': 4,
|
|
195
|
-
'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
|
|
196
|
-
'debug.max_hypothesis_rounds': 10
|
|
243
|
+
function validateProject() {
|
|
244
|
+
const checks = [];
|
|
245
|
+
const errors = [];
|
|
246
|
+
const warnings = [];
|
|
247
|
+
|
|
248
|
+
// 1. .planning/ directory exists
|
|
249
|
+
if (!fs.existsSync(planningDir)) {
|
|
250
|
+
return { valid: false, errors: ['.planning/ directory not found'], warnings: [], checks: ['directory_exists: FAIL'] };
|
|
197
251
|
}
|
|
198
|
-
|
|
252
|
+
checks.push('directory_exists: PASS');
|
|
199
253
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
254
|
+
// 2. config.json exists and is valid
|
|
255
|
+
const config = configLoad();
|
|
256
|
+
if (!config) {
|
|
257
|
+
errors.push('config.json missing or invalid JSON');
|
|
258
|
+
checks.push('config_valid: FAIL');
|
|
259
|
+
} else {
|
|
260
|
+
const configResult = configValidate(config);
|
|
261
|
+
if (!configResult.valid) {
|
|
262
|
+
errors.push(...configResult.errors.map(e => 'config: ' + e));
|
|
263
|
+
}
|
|
264
|
+
warnings.push(...(configResult.warnings || []).map(w => 'config: ' + w));
|
|
265
|
+
checks.push('config_valid: ' + (configResult.valid ? 'PASS' : 'FAIL'));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. STATE.md exists and has valid frontmatter
|
|
269
|
+
const statePath = path.join(planningDir, 'STATE.md');
|
|
270
|
+
if (!fs.existsSync(statePath)) {
|
|
271
|
+
errors.push('STATE.md not found');
|
|
272
|
+
checks.push('state_exists: FAIL');
|
|
273
|
+
} else {
|
|
274
|
+
try {
|
|
275
|
+
const stateContent = fs.readFileSync(statePath, 'utf8');
|
|
276
|
+
const fm = parseYamlFrontmatter(stateContent);
|
|
277
|
+
if (!fm || !fm.current_phase) {
|
|
278
|
+
warnings.push('STATE.md frontmatter missing current_phase');
|
|
279
|
+
checks.push('state_frontmatter: WARN');
|
|
280
|
+
} else {
|
|
281
|
+
checks.push('state_frontmatter: PASS');
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
errors.push('STATE.md unreadable: ' + e.message);
|
|
285
|
+
checks.push('state_readable: FAIL');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. ROADMAP.md exists
|
|
290
|
+
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
291
|
+
if (!fs.existsSync(roadmapPath)) {
|
|
292
|
+
warnings.push('ROADMAP.md not found (may be a new project)');
|
|
293
|
+
checks.push('roadmap_exists: WARN');
|
|
294
|
+
} else {
|
|
295
|
+
checks.push('roadmap_exists: PASS');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 5. Phase directory matches STATE.md current_phase
|
|
299
|
+
try {
|
|
300
|
+
if (fs.existsSync(statePath)) {
|
|
301
|
+
const stateContent = fs.readFileSync(statePath, 'utf8');
|
|
302
|
+
const fm = parseYamlFrontmatter(stateContent);
|
|
303
|
+
if (fm && fm.current_phase) {
|
|
304
|
+
const phaseNum = String(fm.current_phase).padStart(2, '0');
|
|
305
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
306
|
+
if (fs.existsSync(phasesDir)) {
|
|
307
|
+
const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(phaseNum + '-'));
|
|
308
|
+
if (dirs.length === 0) {
|
|
309
|
+
warnings.push(`Phase directory for current_phase ${fm.current_phase} not found in .planning/phases/`);
|
|
310
|
+
checks.push('phase_directory: WARN');
|
|
311
|
+
} else {
|
|
312
|
+
checks.push('phase_directory: PASS');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch (_e) { /* best effort */ }
|
|
318
|
+
|
|
319
|
+
// 6. No stale .active-skill (>2 hours old)
|
|
320
|
+
const activeSkillPath = path.join(planningDir, '.active-skill');
|
|
321
|
+
if (fs.existsSync(activeSkillPath)) {
|
|
322
|
+
try {
|
|
323
|
+
const stat = fs.statSync(activeSkillPath);
|
|
324
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
325
|
+
if (ageMs > 2 * 60 * 60 * 1000) {
|
|
326
|
+
const ageHours = Math.round(ageMs / (60 * 60 * 1000));
|
|
327
|
+
warnings.push(`.active-skill is ${ageHours}h old — may be stale from a crashed session`);
|
|
328
|
+
checks.push('active_skill_fresh: WARN');
|
|
329
|
+
} else {
|
|
330
|
+
checks.push('active_skill_fresh: PASS');
|
|
331
|
+
}
|
|
332
|
+
} catch (_e) { checks.push('active_skill_fresh: SKIP'); }
|
|
333
|
+
} else {
|
|
334
|
+
checks.push('active_skill_fresh: SKIP');
|
|
335
|
+
}
|
|
210
336
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
337
|
+
// 7. No .tmp files left from atomic writes
|
|
338
|
+
try {
|
|
339
|
+
const tmpFiles = fs.readdirSync(planningDir).filter(f => f.endsWith('.tmp.' + process.pid) || f.match(/\.tmp\.\d+$/));
|
|
340
|
+
if (tmpFiles.length > 0) {
|
|
341
|
+
warnings.push(`Found ${tmpFiles.length} leftover temp files in .planning/: ${tmpFiles.join(', ')}`);
|
|
342
|
+
checks.push('no_temp_files: WARN');
|
|
343
|
+
} else {
|
|
344
|
+
checks.push('no_temp_files: PASS');
|
|
345
|
+
}
|
|
346
|
+
} catch (_e) { /* best effort */ }
|
|
214
347
|
|
|
215
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
valid: errors.length === 0,
|
|
350
|
+
errors,
|
|
351
|
+
warnings,
|
|
352
|
+
checks
|
|
353
|
+
};
|
|
216
354
|
}
|
|
217
355
|
|
|
356
|
+
// --- CLI entry point ---
|
|
357
|
+
|
|
218
358
|
async function main() {
|
|
219
359
|
const args = process.argv.slice(2);
|
|
220
360
|
const command = args[0];
|
|
@@ -409,1087 +549,58 @@ async function main() {
|
|
|
409
549
|
output(suggestions.length > 0
|
|
410
550
|
? { suggestions }
|
|
411
551
|
: { suggestions: [], message: 'Not enough shadow samples yet (need >= 20 per operation)' });
|
|
552
|
+
// --- Compound init commands ---
|
|
553
|
+
} else if (command === "init" && subcommand === "execute-phase") {
|
|
554
|
+
const phase = args[2];
|
|
555
|
+
if (!phase) error("Usage: pbr-tools.js init execute-phase <phase-number>");
|
|
556
|
+
output(initExecutePhase(phase));
|
|
557
|
+
} else if (command === "init" && subcommand === "plan-phase") {
|
|
558
|
+
const phase = args[2];
|
|
559
|
+
if (!phase) error("Usage: pbr-tools.js init plan-phase <phase-number>");
|
|
560
|
+
output(initPlanPhase(phase));
|
|
561
|
+
} else if (command === "init" && subcommand === "quick") {
|
|
562
|
+
const desc = args.slice(2).join(" ") || "";
|
|
563
|
+
output(initQuick(desc));
|
|
564
|
+
} else if (command === "init" && subcommand === "verify-work") {
|
|
565
|
+
const phase = args[2];
|
|
566
|
+
if (!phase) error("Usage: pbr-tools.js init verify-work <phase-number>");
|
|
567
|
+
output(initVerifyWork(phase));
|
|
568
|
+
} else if (command === "init" && subcommand === "resume") {
|
|
569
|
+
output(initResume());
|
|
570
|
+
} else if (command === "init" && subcommand === "progress") {
|
|
571
|
+
output(initProgress());
|
|
572
|
+
// --- State patch/advance/metric ---
|
|
573
|
+
} else if (command === "state" && subcommand === "patch") {
|
|
574
|
+
const jsonStr = args[2];
|
|
575
|
+
if (!jsonStr) error("Usage: pbr-tools.js state patch JSON");
|
|
576
|
+
output(statePatch(jsonStr));
|
|
577
|
+
} else if (command === "state" && subcommand === "advance-plan") {
|
|
578
|
+
output(stateAdvancePlan());
|
|
579
|
+
} else if (command === "state" && subcommand === "record-metric") {
|
|
580
|
+
output(stateRecordMetric(args.slice(2)));
|
|
581
|
+
} else if (command === 'phase' && subcommand === 'add') {
|
|
582
|
+
const slug = args[2];
|
|
583
|
+
if (!slug) { error('Usage: phase add <slug> [--after <phase_num>]'); }
|
|
584
|
+
const afterIdx = args.indexOf('--after');
|
|
585
|
+
const afterPhase = afterIdx !== -1 ? args[afterIdx + 1] : null;
|
|
586
|
+
output(phaseAdd(slug, afterPhase));
|
|
587
|
+
} else if (command === 'phase' && subcommand === 'remove') {
|
|
588
|
+
const phaseNum = args[2];
|
|
589
|
+
if (!phaseNum) { error('Usage: phase remove <phase_num>'); }
|
|
590
|
+
output(phaseRemove(phaseNum));
|
|
591
|
+
} else if (command === 'phase' && subcommand === 'list') {
|
|
592
|
+
output(phaseList());
|
|
593
|
+
} else if (command === 'validate-project') {
|
|
594
|
+
output(validateProject());
|
|
412
595
|
} else {
|
|
413
|
-
error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update, config validate, plan-index, frontmatter, must-haves, phase-info, roadmap update-status|update-plans, history append|load, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds`);
|
|
596
|
+
error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update|patch|advance-plan|record-metric, config validate, validate-project, init execute-phase|plan-phase|quick|verify-work|resume|progress, plan-index, frontmatter, must-haves, phase-info, phase add|remove|list, roadmap update-status|update-plans, history append|load, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds`);
|
|
414
597
|
}
|
|
415
598
|
} catch (e) {
|
|
416
599
|
error(e.message);
|
|
417
600
|
}
|
|
418
601
|
}
|
|
419
602
|
|
|
420
|
-
// --- Commands ---
|
|
421
|
-
|
|
422
|
-
function stateLoad() {
|
|
423
|
-
const result = {
|
|
424
|
-
exists: false,
|
|
425
|
-
config: null,
|
|
426
|
-
state: null,
|
|
427
|
-
roadmap: null,
|
|
428
|
-
phase_count: 0,
|
|
429
|
-
current_phase: null,
|
|
430
|
-
progress: null
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
if (!fs.existsSync(planningDir)) {
|
|
434
|
-
return result;
|
|
435
|
-
}
|
|
436
|
-
result.exists = true;
|
|
437
|
-
|
|
438
|
-
// Load config.json
|
|
439
|
-
const configPath = path.join(planningDir, 'config.json');
|
|
440
|
-
if (fs.existsSync(configPath)) {
|
|
441
|
-
try {
|
|
442
|
-
result.config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
443
|
-
} catch (_) {
|
|
444
|
-
result.config = { _error: 'Failed to parse config.json' };
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Load STATE.md
|
|
449
|
-
const statePath = path.join(planningDir, 'STATE.md');
|
|
450
|
-
if (fs.existsSync(statePath)) {
|
|
451
|
-
const content = fs.readFileSync(statePath, 'utf8');
|
|
452
|
-
result.state = parseStateMd(content);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Load ROADMAP.md
|
|
456
|
-
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
457
|
-
if (fs.existsSync(roadmapPath)) {
|
|
458
|
-
const content = fs.readFileSync(roadmapPath, 'utf8');
|
|
459
|
-
result.roadmap = parseRoadmapMd(content);
|
|
460
|
-
result.phase_count = result.roadmap.phases.length;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Extract current phase
|
|
464
|
-
if (result.state && result.state.current_phase) {
|
|
465
|
-
result.current_phase = result.state.current_phase;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Calculate progress
|
|
469
|
-
result.progress = calculateProgress();
|
|
470
|
-
|
|
471
|
-
return result;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function stateCheckProgress() {
|
|
475
|
-
const phasesDir = path.join(planningDir, 'phases');
|
|
476
|
-
if (!fs.existsSync(phasesDir)) {
|
|
477
|
-
return { phases: [], total_plans: 0, completed_plans: 0, percentage: 0 };
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const phases = [];
|
|
481
|
-
let totalPlans = 0;
|
|
482
|
-
let completedPlans = 0;
|
|
483
|
-
|
|
484
|
-
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
485
|
-
.filter(e => e.isDirectory())
|
|
486
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
487
|
-
|
|
488
|
-
for (const entry of entries) {
|
|
489
|
-
const phaseDir = path.join(phasesDir, entry.name);
|
|
490
|
-
const plans = findFiles(phaseDir, /-PLAN\.md$/);
|
|
491
|
-
const summaries = findFiles(phaseDir, /^SUMMARY-.*\.md$/);
|
|
492
|
-
const verification = fs.existsSync(path.join(phaseDir, 'VERIFICATION.md'));
|
|
493
|
-
|
|
494
|
-
const completedSummaries = summaries.filter(s => {
|
|
495
|
-
const content = fs.readFileSync(path.join(phaseDir, s), 'utf8');
|
|
496
|
-
return /status:\s*["']?complete/i.test(content);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
const phaseInfo = {
|
|
500
|
-
directory: entry.name,
|
|
501
|
-
plans: plans.length,
|
|
502
|
-
summaries: summaries.length,
|
|
503
|
-
completed: completedSummaries.length,
|
|
504
|
-
has_verification: verification,
|
|
505
|
-
status: determinePhaseStatus(plans.length, completedSummaries.length, summaries.length, verification, phaseDir)
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
phases.push(phaseInfo);
|
|
509
|
-
totalPlans += plans.length;
|
|
510
|
-
completedPlans += completedSummaries.length;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
phases,
|
|
515
|
-
total_plans: totalPlans,
|
|
516
|
-
completed_plans: completedPlans,
|
|
517
|
-
percentage: totalPlans > 0 ? Math.round((completedPlans / totalPlans) * 100) : 0
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function planIndex(phaseNum) {
|
|
522
|
-
const phasesDir = path.join(planningDir, 'phases');
|
|
523
|
-
if (!fs.existsSync(phasesDir)) {
|
|
524
|
-
return { error: 'No phases directory found' };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Find phase directory matching the number
|
|
528
|
-
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
529
|
-
.filter(e => e.isDirectory());
|
|
530
|
-
|
|
531
|
-
const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
|
|
532
|
-
if (!phaseDir) {
|
|
533
|
-
return { error: `No phase directory found matching phase ${phaseNum}` };
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const fullDir = path.join(phasesDir, phaseDir.name);
|
|
537
|
-
const planFiles = findFiles(fullDir, /-PLAN\.md$/);
|
|
538
|
-
|
|
539
|
-
const plans = [];
|
|
540
|
-
const waves = {};
|
|
541
|
-
|
|
542
|
-
for (const file of planFiles) {
|
|
543
|
-
const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
|
|
544
|
-
const frontmatter = parseYamlFrontmatter(content);
|
|
545
|
-
|
|
546
|
-
const plan = {
|
|
547
|
-
file,
|
|
548
|
-
plan_id: frontmatter.plan || file.replace(/-PLAN\.md$/, ''),
|
|
549
|
-
wave: parseInt(frontmatter.wave, 10) || 1,
|
|
550
|
-
type: frontmatter.type || 'unknown',
|
|
551
|
-
autonomous: frontmatter.autonomous !== false,
|
|
552
|
-
depends_on: frontmatter.depends_on || [],
|
|
553
|
-
gap_closure: frontmatter.gap_closure || false,
|
|
554
|
-
has_summary: fs.existsSync(path.join(fullDir, `SUMMARY-${frontmatter.plan || ''}.md`)),
|
|
555
|
-
must_haves_count: countMustHaves(frontmatter.must_haves)
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
plans.push(plan);
|
|
559
|
-
|
|
560
|
-
const waveKey = `wave_${plan.wave}`;
|
|
561
|
-
if (!waves[waveKey]) waves[waveKey] = [];
|
|
562
|
-
waves[waveKey].push(plan.plan_id);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
return {
|
|
566
|
-
phase: phaseDir.name,
|
|
567
|
-
total_plans: plans.length,
|
|
568
|
-
plans,
|
|
569
|
-
waves
|
|
570
|
-
};
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function configValidate(preloadedConfig) {
|
|
574
|
-
let config;
|
|
575
|
-
if (preloadedConfig) {
|
|
576
|
-
config = preloadedConfig;
|
|
577
|
-
} else {
|
|
578
|
-
const configPath = path.join(planningDir, 'config.json');
|
|
579
|
-
if (!fs.existsSync(configPath)) {
|
|
580
|
-
return { valid: false, errors: ['config.json not found'], warnings: [] };
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
try {
|
|
584
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
585
|
-
} catch (e) {
|
|
586
|
-
return { valid: false, errors: [`config.json is not valid JSON: ${e.message}`], warnings: [] };
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'config-schema.json'), 'utf8'));
|
|
591
|
-
const warnings = [];
|
|
592
|
-
const errors = [];
|
|
593
|
-
|
|
594
|
-
validateObject(config, schema, '', errors, warnings);
|
|
595
|
-
|
|
596
|
-
// Semantic conflict detection — logical contradictions that pass schema validation
|
|
597
|
-
// Clear contradictions → errors; ambiguous/preference issues → warnings
|
|
598
|
-
if (config.mode === 'autonomous' && config.gates) {
|
|
599
|
-
const activeGates = Object.entries(config.gates || {}).filter(([, v]) => v === true).map(([k]) => k);
|
|
600
|
-
if (activeGates.length > 0) {
|
|
601
|
-
errors.push(`mode=autonomous with active gates (${activeGates.join(', ')}): gates are unreachable in autonomous mode`);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
if (config.features && config.features.auto_continue && config.mode === 'interactive') {
|
|
605
|
-
warnings.push('features.auto_continue=true with mode=interactive: auto_continue only fires in autonomous mode');
|
|
606
|
-
}
|
|
607
|
-
if (config.parallelization) {
|
|
608
|
-
if (config.parallelization.enabled === false && config.parallelization.plan_level === true) {
|
|
609
|
-
warnings.push('parallelization.enabled=false with plan_level=true: plan_level is ignored when parallelization is disabled');
|
|
610
|
-
}
|
|
611
|
-
if (config.parallelization.max_concurrent_agents === 1 && config.teams && config.teams.coordination) {
|
|
612
|
-
errors.push('parallelization.max_concurrent_agents=1 with teams.coordination set: teams require concurrent agents to be useful');
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
return {
|
|
617
|
-
valid: errors.length === 0,
|
|
618
|
-
errors,
|
|
619
|
-
warnings
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// --- New read-only commands ---
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Parse a markdown file's YAML frontmatter and return as JSON.
|
|
627
|
-
* Wraps parseYamlFrontmatter() + parseMustHaves().
|
|
628
|
-
*/
|
|
629
|
-
function frontmatter(filePath) {
|
|
630
|
-
const resolved = path.resolve(filePath);
|
|
631
|
-
if (!fs.existsSync(resolved)) {
|
|
632
|
-
return { error: `File not found: ${resolved}` };
|
|
633
|
-
}
|
|
634
|
-
const content = fs.readFileSync(resolved, 'utf8');
|
|
635
|
-
return parseYamlFrontmatter(content);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Collect all must-haves from all PLAN.md files in a phase.
|
|
640
|
-
* Returns per-plan grouping + flat deduplicated list + total count.
|
|
641
|
-
*/
|
|
642
|
-
function mustHavesCollect(phaseNum) {
|
|
643
|
-
const phasesDir = path.join(planningDir, 'phases');
|
|
644
|
-
if (!fs.existsSync(phasesDir)) {
|
|
645
|
-
return { error: 'No phases directory found' };
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
649
|
-
.filter(e => e.isDirectory());
|
|
650
|
-
const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
|
|
651
|
-
if (!phaseDir) {
|
|
652
|
-
return { error: `No phase directory found matching phase ${phaseNum}` };
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const fullDir = path.join(phasesDir, phaseDir.name);
|
|
656
|
-
const planFiles = findFiles(fullDir, /-PLAN\.md$/);
|
|
657
|
-
|
|
658
|
-
const perPlan = {};
|
|
659
|
-
const allTruths = new Set();
|
|
660
|
-
const allArtifacts = new Set();
|
|
661
|
-
const allKeyLinks = new Set();
|
|
662
|
-
|
|
663
|
-
for (const file of planFiles) {
|
|
664
|
-
const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
|
|
665
|
-
const fm = parseYamlFrontmatter(content);
|
|
666
|
-
const planId = fm.plan || file.replace(/-PLAN\.md$/, '');
|
|
667
|
-
const mh = fm.must_haves || { truths: [], artifacts: [], key_links: [] };
|
|
668
|
-
|
|
669
|
-
perPlan[planId] = mh;
|
|
670
|
-
(mh.truths || []).forEach(t => allTruths.add(t));
|
|
671
|
-
(mh.artifacts || []).forEach(a => allArtifacts.add(a));
|
|
672
|
-
(mh.key_links || []).forEach(k => allKeyLinks.add(k));
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const all = {
|
|
676
|
-
truths: [...allTruths],
|
|
677
|
-
artifacts: [...allArtifacts],
|
|
678
|
-
key_links: [...allKeyLinks]
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
return {
|
|
682
|
-
phase: phaseDir.name,
|
|
683
|
-
plans: perPlan,
|
|
684
|
-
all,
|
|
685
|
-
total: all.truths.length + all.artifacts.length + all.key_links.length
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Comprehensive single-phase status combining roadmap, filesystem, and plan data.
|
|
691
|
-
*/
|
|
692
|
-
function phaseInfo(phaseNum) {
|
|
693
|
-
const phasesDir = path.join(planningDir, 'phases');
|
|
694
|
-
if (!fs.existsSync(phasesDir)) {
|
|
695
|
-
return { error: 'No phases directory found' };
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
699
|
-
.filter(e => e.isDirectory());
|
|
700
|
-
const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
|
|
701
|
-
if (!phaseDir) {
|
|
702
|
-
return { error: `No phase directory found matching phase ${phaseNum}` };
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const fullDir = path.join(phasesDir, phaseDir.name);
|
|
706
|
-
|
|
707
|
-
// Get roadmap info
|
|
708
|
-
let roadmapInfo = null;
|
|
709
|
-
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
710
|
-
if (fs.existsSync(roadmapPath)) {
|
|
711
|
-
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
|
712
|
-
const roadmap = parseRoadmapMd(roadmapContent);
|
|
713
|
-
roadmapInfo = roadmap.phases.find(p => p.number === phaseNum.padStart(2, '0')) || null;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Get plan index
|
|
717
|
-
const plans = planIndex(phaseNum);
|
|
718
|
-
|
|
719
|
-
// Check for verification
|
|
720
|
-
const verificationPath = path.join(fullDir, 'VERIFICATION.md');
|
|
721
|
-
let verification = null;
|
|
722
|
-
if (fs.existsSync(verificationPath)) {
|
|
723
|
-
const vContent = fs.readFileSync(verificationPath, 'utf8');
|
|
724
|
-
verification = parseYamlFrontmatter(vContent);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// Check summaries
|
|
728
|
-
const summaryFiles = findFiles(fullDir, /^SUMMARY-.*\.md$/);
|
|
729
|
-
const summaries = summaryFiles.map(f => {
|
|
730
|
-
const content = fs.readFileSync(path.join(fullDir, f), 'utf8');
|
|
731
|
-
const fm = parseYamlFrontmatter(content);
|
|
732
|
-
return { file: f, plan: fm.plan || f.replace(/^SUMMARY-|\.md$/g, ''), status: fm.status || 'unknown' };
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
// Determine filesystem status
|
|
736
|
-
const planCount = plans.total_plans || 0;
|
|
737
|
-
const completedCount = summaries.filter(s => s.status === 'complete').length;
|
|
738
|
-
const hasVerification = fs.existsSync(verificationPath);
|
|
739
|
-
const fsStatus = determinePhaseStatus(planCount, completedCount, summaryFiles.length, hasVerification, fullDir);
|
|
740
|
-
|
|
741
|
-
return {
|
|
742
|
-
phase: phaseDir.name,
|
|
743
|
-
name: roadmapInfo ? roadmapInfo.name : phaseDir.name.replace(/^\d+-/, ''),
|
|
744
|
-
goal: roadmapInfo ? roadmapInfo.goal : null,
|
|
745
|
-
roadmap_status: roadmapInfo ? roadmapInfo.status : null,
|
|
746
|
-
filesystem_status: fsStatus,
|
|
747
|
-
plans: plans.plans || [],
|
|
748
|
-
plan_count: planCount,
|
|
749
|
-
summaries,
|
|
750
|
-
completed: completedCount,
|
|
751
|
-
verification,
|
|
752
|
-
has_context: fs.existsSync(path.join(fullDir, 'CONTEXT.md'))
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// --- Mutation commands ---
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Atomically update a field in STATE.md using lockedFileUpdate.
|
|
760
|
-
* Supports both legacy and frontmatter (v2) formats.
|
|
761
|
-
*
|
|
762
|
-
* @param {string} field - One of: current_phase, status, plans_complete, last_activity
|
|
763
|
-
* @param {string} value - New value (use 'now' for last_activity to auto-timestamp)
|
|
764
|
-
*/
|
|
765
|
-
function stateUpdate(field, value) {
|
|
766
|
-
const statePath = path.join(planningDir, 'STATE.md');
|
|
767
|
-
if (!fs.existsSync(statePath)) {
|
|
768
|
-
return { success: false, error: 'STATE.md not found' };
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const validFields = ['current_phase', 'status', 'plans_complete', 'last_activity'];
|
|
772
|
-
if (!validFields.includes(field)) {
|
|
773
|
-
return { success: false, error: `Invalid field: ${field}. Valid fields: ${validFields.join(', ')}` };
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Auto-timestamp
|
|
777
|
-
if (field === 'last_activity' && value === 'now') {
|
|
778
|
-
value = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const result = lockedFileUpdate(statePath, (content) => {
|
|
782
|
-
const fm = parseYamlFrontmatter(content);
|
|
783
|
-
if (fm.version === 2 || fm.current_phase !== undefined) {
|
|
784
|
-
return updateFrontmatterField(content, field, value);
|
|
785
|
-
}
|
|
786
|
-
return updateLegacyStateField(content, field, value);
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
if (result.success) {
|
|
790
|
-
return { success: true, field, value };
|
|
791
|
-
}
|
|
792
|
-
return { success: false, error: result.error };
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Append a record to HISTORY.md. Creates the file if it doesn't exist.
|
|
797
|
-
* Each entry is a markdown section appended at the end.
|
|
798
|
-
*
|
|
799
|
-
* @param {object} entry - { type: 'milestone'|'phase', title: string, body: string }
|
|
800
|
-
* @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
|
|
801
|
-
* @returns {{success: boolean, error?: string}}
|
|
802
|
-
*/
|
|
803
|
-
function historyAppend(entry, dir) {
|
|
804
|
-
const historyPath = path.join(dir || planningDir, 'HISTORY.md');
|
|
805
|
-
const timestamp = new Date().toISOString().slice(0, 10);
|
|
806
|
-
|
|
807
|
-
let header = '';
|
|
808
|
-
if (!fs.existsSync(historyPath)) {
|
|
809
|
-
header = '# Project History\n\nCompleted milestones and phase records. This file is append-only.\n\n';
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const section = `${header}## ${entry.type === 'milestone' ? 'Milestone' : 'Phase'}: ${entry.title}\n_Completed: ${timestamp}_\n\n${entry.body.trim()}\n\n---\n\n`;
|
|
813
|
-
|
|
814
|
-
try {
|
|
815
|
-
fs.appendFileSync(historyPath, section, 'utf8');
|
|
816
|
-
return { success: true };
|
|
817
|
-
} catch (e) {
|
|
818
|
-
return { success: false, error: e.message };
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Load HISTORY.md and parse it into structured records.
|
|
824
|
-
* Returns null if HISTORY.md doesn't exist.
|
|
825
|
-
*
|
|
826
|
-
* @param {string} [dir] - Path to .planning directory
|
|
827
|
-
* @returns {object|null} { records: [{type, title, date, body}], line_count }
|
|
828
|
-
*/
|
|
829
|
-
function historyLoad(dir) {
|
|
830
|
-
const historyPath = path.join(dir || planningDir, 'HISTORY.md');
|
|
831
|
-
if (!fs.existsSync(historyPath)) return null;
|
|
832
|
-
|
|
833
|
-
const content = fs.readFileSync(historyPath, 'utf8');
|
|
834
|
-
const records = [];
|
|
835
|
-
const sectionRegex = /^## (Milestone|Phase): (.+)\n_Completed: (\d{4}-\d{2}-\d{2})_\n\n([\s\S]*?)(?=\n---|\s*$)/gm;
|
|
836
|
-
|
|
837
|
-
let match;
|
|
838
|
-
while ((match = sectionRegex.exec(content)) !== null) {
|
|
839
|
-
records.push({
|
|
840
|
-
type: match[1].toLowerCase(),
|
|
841
|
-
title: match[2].trim(),
|
|
842
|
-
date: match[3],
|
|
843
|
-
body: match[4].trim()
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
return {
|
|
848
|
-
records,
|
|
849
|
-
line_count: content.split('\n').length
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Update the Status column for a phase in ROADMAP.md's Phase Overview table.
|
|
855
|
-
*/
|
|
856
|
-
function roadmapUpdateStatus(phaseNum, newStatus) {
|
|
857
|
-
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
858
|
-
if (!fs.existsSync(roadmapPath)) {
|
|
859
|
-
return { success: false, error: 'ROADMAP.md not found' };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
let oldStatus = null;
|
|
863
|
-
|
|
864
|
-
const result = lockedFileUpdate(roadmapPath, (content) => {
|
|
865
|
-
const lines = content.split('\n');
|
|
866
|
-
const rowIdx = findRoadmapRow(lines, phaseNum);
|
|
867
|
-
if (rowIdx === -1) {
|
|
868
|
-
return content; // No matching row found
|
|
869
|
-
}
|
|
870
|
-
const parts = lines[rowIdx].split('|');
|
|
871
|
-
oldStatus = parts[6] ? parts[6].trim() : 'unknown';
|
|
872
|
-
lines[rowIdx] = updateTableRow(lines[rowIdx], 5, newStatus);
|
|
873
|
-
return lines.join('\n');
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
if (!oldStatus) {
|
|
877
|
-
return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Advisory transition validation — warn on suspicious transitions but don't block
|
|
881
|
-
const transition = validateStatusTransition(oldStatus, newStatus);
|
|
882
|
-
if (!transition.valid && transition.warning) {
|
|
883
|
-
process.stderr.write(`[pbr-tools] WARNING: ${transition.warning}\n`);
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
if (result.success) {
|
|
887
|
-
const response = { success: true, old_status: oldStatus, new_status: newStatus };
|
|
888
|
-
if (!transition.valid) {
|
|
889
|
-
response.transition_warning = transition.warning;
|
|
890
|
-
}
|
|
891
|
-
return response;
|
|
892
|
-
}
|
|
893
|
-
return { success: false, error: result.error };
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Update the Plans column for a phase in ROADMAP.md's Phase Overview table.
|
|
898
|
-
*/
|
|
899
|
-
function roadmapUpdatePlans(phaseNum, complete, total) {
|
|
900
|
-
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
901
|
-
if (!fs.existsSync(roadmapPath)) {
|
|
902
|
-
return { success: false, error: 'ROADMAP.md not found' };
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
let oldPlans = null;
|
|
906
|
-
const newPlans = `${complete}/${total}`;
|
|
907
|
-
|
|
908
|
-
const result = lockedFileUpdate(roadmapPath, (content) => {
|
|
909
|
-
const lines = content.split('\n');
|
|
910
|
-
const rowIdx = findRoadmapRow(lines, phaseNum);
|
|
911
|
-
if (rowIdx === -1) {
|
|
912
|
-
return content;
|
|
913
|
-
}
|
|
914
|
-
const parts = lines[rowIdx].split('|');
|
|
915
|
-
oldPlans = parts[4] ? parts[4].trim() : 'unknown';
|
|
916
|
-
lines[rowIdx] = updateTableRow(lines[rowIdx], 3, newPlans);
|
|
917
|
-
return lines.join('\n');
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
if (!oldPlans) {
|
|
921
|
-
return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (result.success) {
|
|
925
|
-
return { success: true, old_plans: oldPlans, new_plans: newPlans };
|
|
926
|
-
}
|
|
927
|
-
return { success: false, error: result.error };
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// --- Mutation helpers ---
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Update a field in legacy (non-frontmatter) STATE.md content.
|
|
934
|
-
* Pure function: content in, content out.
|
|
935
|
-
*/
|
|
936
|
-
function updateLegacyStateField(content, field, value) {
|
|
937
|
-
const lines = content.split('\n');
|
|
938
|
-
|
|
939
|
-
switch (field) {
|
|
940
|
-
case 'current_phase': {
|
|
941
|
-
const idx = lines.findIndex(l => /Phase:\s*\d+\s+of\s+\d+/.test(l));
|
|
942
|
-
if (idx !== -1) {
|
|
943
|
-
lines[idx] = lines[idx].replace(/(Phase:\s*)\d+/, (_, prefix) => `${prefix}${value}`);
|
|
944
|
-
}
|
|
945
|
-
break;
|
|
946
|
-
}
|
|
947
|
-
case 'status': {
|
|
948
|
-
const idx = lines.findIndex(l => /^Status:/i.test(l));
|
|
949
|
-
if (idx !== -1) {
|
|
950
|
-
lines[idx] = `Status: ${value}`;
|
|
951
|
-
} else {
|
|
952
|
-
const phaseIdx = lines.findIndex(l => /Phase:/.test(l));
|
|
953
|
-
if (phaseIdx !== -1) {
|
|
954
|
-
lines.splice(phaseIdx + 1, 0, `Status: ${value}`);
|
|
955
|
-
} else {
|
|
956
|
-
lines.push(`Status: ${value}`);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
break;
|
|
960
|
-
}
|
|
961
|
-
case 'plans_complete': {
|
|
962
|
-
const idx = lines.findIndex(l => /Plan:\s*\d+\s+of\s+\d+/.test(l));
|
|
963
|
-
if (idx !== -1) {
|
|
964
|
-
lines[idx] = lines[idx].replace(/(Plan:\s*)\d+/, (_, prefix) => `${prefix}${value}`);
|
|
965
|
-
}
|
|
966
|
-
break;
|
|
967
|
-
}
|
|
968
|
-
case 'last_activity': {
|
|
969
|
-
const idx = lines.findIndex(l => /^Last Activity:/i.test(l));
|
|
970
|
-
if (idx !== -1) {
|
|
971
|
-
lines[idx] = `Last Activity: ${value}`;
|
|
972
|
-
} else {
|
|
973
|
-
const statusIdx = lines.findIndex(l => /^Status:/i.test(l));
|
|
974
|
-
if (statusIdx !== -1) {
|
|
975
|
-
lines.splice(statusIdx + 1, 0, `Last Activity: ${value}`);
|
|
976
|
-
} else {
|
|
977
|
-
lines.push(`Last Activity: ${value}`);
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
break;
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
return lines.join('\n');
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/**
|
|
988
|
-
* Update a field in YAML frontmatter content.
|
|
989
|
-
* Pure function: content in, content out.
|
|
990
|
-
*/
|
|
991
|
-
function updateFrontmatterField(content, field, value) {
|
|
992
|
-
const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
|
|
993
|
-
if (!match) return content;
|
|
994
|
-
|
|
995
|
-
const before = match[1];
|
|
996
|
-
let yaml = match[2];
|
|
997
|
-
const after = match[3];
|
|
998
|
-
const rest = content.slice(match[0].length);
|
|
999
|
-
|
|
1000
|
-
// Format value: integers stay bare, strings get quotes
|
|
1001
|
-
const isNum = /^\d+$/.test(String(value));
|
|
1002
|
-
const formatted = isNum ? value : `"${value}"`;
|
|
1003
|
-
|
|
1004
|
-
const fieldRegex = new RegExp(`^(${field})\\s*:.*$`, 'm');
|
|
1005
|
-
if (fieldRegex.test(yaml)) {
|
|
1006
|
-
yaml = yaml.replace(fieldRegex, () => `${field}: ${formatted}`);
|
|
1007
|
-
} else {
|
|
1008
|
-
yaml = yaml + `\n${field}: ${formatted}`;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
return before + yaml + after + rest;
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* Find the row index of a phase in a ROADMAP.md table.
|
|
1016
|
-
* @returns {number} Line index or -1 if not found
|
|
1017
|
-
*/
|
|
1018
|
-
function findRoadmapRow(lines, phaseNum) {
|
|
1019
|
-
const paddedPhase = phaseNum.padStart(2, '0');
|
|
1020
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1021
|
-
if (!lines[i].includes('|')) continue;
|
|
1022
|
-
const parts = lines[i].split('|');
|
|
1023
|
-
if (parts.length < 3) continue;
|
|
1024
|
-
const phaseCol = parts[1] ? parts[1].trim() : '';
|
|
1025
|
-
if (phaseCol === paddedPhase) {
|
|
1026
|
-
return i;
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
return -1;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* Update a specific column in a markdown table row.
|
|
1034
|
-
* @param {string} row - The full table row string (e.g., "| 01 | Setup | ... |")
|
|
1035
|
-
* @param {number} columnIndex - 0-based column index (Phase=0, Name=1, ..., Status=5)
|
|
1036
|
-
* @param {string} newValue - New cell value
|
|
1037
|
-
* @returns {string} Updated row
|
|
1038
|
-
*/
|
|
1039
|
-
function updateTableRow(row, columnIndex, newValue) {
|
|
1040
|
-
const parts = row.split('|');
|
|
1041
|
-
// parts[0] is empty (before first |), data starts at parts[1]
|
|
1042
|
-
const partIndex = columnIndex + 1;
|
|
1043
|
-
if (partIndex < parts.length) {
|
|
1044
|
-
parts[partIndex] = ` ${newValue} `;
|
|
1045
|
-
}
|
|
1046
|
-
return parts.join('|');
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
/**
|
|
1050
|
-
* Lightweight JSON Schema validator — supports type, enum, properties,
|
|
1051
|
-
* additionalProperties, minimum, maximum for the config schema.
|
|
1052
|
-
*/
|
|
1053
|
-
function validateObject(value, schema, prefix, errors, warnings) {
|
|
1054
|
-
if (schema.type && typeof value !== schema.type) {
|
|
1055
|
-
if (!(schema.type === 'integer' && typeof value === 'number' && Number.isInteger(value))) {
|
|
1056
|
-
errors.push(`${prefix || 'root'}: expected ${schema.type}, got ${typeof value}`);
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
if (schema.enum && !schema.enum.includes(value)) {
|
|
1062
|
-
errors.push(`${prefix || 'root'}: value "${value}" not in allowed values [${schema.enum.join(', ')}]`);
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
1067
|
-
errors.push(`${prefix || 'root'}: value ${value} is below minimum ${schema.minimum}`);
|
|
1068
|
-
}
|
|
1069
|
-
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
1070
|
-
errors.push(`${prefix || 'root'}: value ${value} is above maximum ${schema.maximum}`);
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
if (schema.type === 'object' && schema.properties) {
|
|
1074
|
-
const knownKeys = new Set(Object.keys(schema.properties));
|
|
1075
|
-
|
|
1076
|
-
for (const key of Object.keys(value)) {
|
|
1077
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
1078
|
-
if (!knownKeys.has(key)) {
|
|
1079
|
-
if (schema.additionalProperties === false) {
|
|
1080
|
-
warnings.push(`${fullKey}: unrecognized key (possible typo?)`);
|
|
1081
|
-
}
|
|
1082
|
-
continue;
|
|
1083
|
-
}
|
|
1084
|
-
validateObject(value[key], schema.properties[key], fullKey, errors, warnings);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/**
|
|
1090
|
-
* Locked file update: read-modify-write with exclusive lockfile.
|
|
1091
|
-
* Prevents concurrent writes to STATE.md and ROADMAP.md.
|
|
1092
|
-
*
|
|
1093
|
-
* @param {string} filePath - Absolute path to the file to update
|
|
1094
|
-
* @param {function} updateFn - Receives current content, returns new content
|
|
1095
|
-
* @param {object} opts - Options: { retries: 3, retryDelayMs: 100, timeoutMs: 5000 }
|
|
1096
|
-
* @returns {object} { success, content?, error? }
|
|
1097
|
-
*/
|
|
1098
|
-
function lockedFileUpdate(filePath, updateFn, opts = {}) {
|
|
1099
|
-
const retries = opts.retries || 3;
|
|
1100
|
-
const retryDelayMs = opts.retryDelayMs || 100;
|
|
1101
|
-
const timeoutMs = opts.timeoutMs || 5000;
|
|
1102
|
-
const lockPath = filePath + '.lock';
|
|
1103
|
-
|
|
1104
|
-
let lockFd = null;
|
|
1105
|
-
let lockAcquired = false;
|
|
1106
|
-
|
|
1107
|
-
try {
|
|
1108
|
-
// Acquire lock with retries
|
|
1109
|
-
for (let attempt = 0; attempt < retries; attempt++) {
|
|
1110
|
-
try {
|
|
1111
|
-
lockFd = fs.openSync(lockPath, 'wx');
|
|
1112
|
-
lockAcquired = true;
|
|
1113
|
-
break;
|
|
1114
|
-
} catch (e) {
|
|
1115
|
-
if (e.code === 'EEXIST') {
|
|
1116
|
-
// Lock exists — check if stale (older than timeoutMs)
|
|
1117
|
-
try {
|
|
1118
|
-
const stats = fs.statSync(lockPath);
|
|
1119
|
-
if (Date.now() - stats.mtimeMs > timeoutMs) {
|
|
1120
|
-
// Stale lock — remove and retry
|
|
1121
|
-
fs.unlinkSync(lockPath);
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
} catch (_statErr) {
|
|
1125
|
-
// Lock disappeared between check — retry
|
|
1126
|
-
continue;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
if (attempt < retries - 1) {
|
|
1130
|
-
// Wait and retry
|
|
1131
|
-
const waitMs = retryDelayMs * (attempt + 1);
|
|
1132
|
-
const start = Date.now();
|
|
1133
|
-
while (Date.now() - start < waitMs) {
|
|
1134
|
-
// Busy wait (synchronous context)
|
|
1135
|
-
}
|
|
1136
|
-
continue;
|
|
1137
|
-
}
|
|
1138
|
-
return { success: false, error: `Could not acquire lock for ${path.basename(filePath)} after ${retries} attempts` };
|
|
1139
|
-
}
|
|
1140
|
-
throw e;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
if (!lockAcquired) {
|
|
1145
|
-
return { success: false, error: `Could not acquire lock for ${path.basename(filePath)}` };
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Write PID to lock file for debugging
|
|
1149
|
-
fs.writeSync(lockFd, `${process.pid}`);
|
|
1150
|
-
fs.closeSync(lockFd);
|
|
1151
|
-
lockFd = null;
|
|
1152
|
-
|
|
1153
|
-
// Read current content
|
|
1154
|
-
let content = '';
|
|
1155
|
-
if (fs.existsSync(filePath)) {
|
|
1156
|
-
content = fs.readFileSync(filePath, 'utf8');
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
// Apply update
|
|
1160
|
-
const newContent = updateFn(content);
|
|
1161
|
-
|
|
1162
|
-
// Write back atomically
|
|
1163
|
-
const writeResult = atomicWrite(filePath, newContent);
|
|
1164
|
-
if (!writeResult.success) {
|
|
1165
|
-
return { success: false, error: writeResult.error };
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
return { success: true, content: newContent };
|
|
1169
|
-
} catch (e) {
|
|
1170
|
-
return { success: false, error: e.message };
|
|
1171
|
-
} finally {
|
|
1172
|
-
// Close fd if still open
|
|
1173
|
-
try {
|
|
1174
|
-
if (lockFd !== null) fs.closeSync(lockFd);
|
|
1175
|
-
} catch (_e) { /* ignore */ }
|
|
1176
|
-
// Only release lock if we acquired it
|
|
1177
|
-
if (lockAcquired) {
|
|
1178
|
-
try {
|
|
1179
|
-
fs.unlinkSync(lockPath);
|
|
1180
|
-
} catch (_e) { /* ignore — may already be cleaned up */ }
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// --- Parsers ---
|
|
1186
|
-
|
|
1187
|
-
function parseStateMd(content) {
|
|
1188
|
-
const result = {
|
|
1189
|
-
current_phase: null,
|
|
1190
|
-
phase_name: null,
|
|
1191
|
-
progress: null,
|
|
1192
|
-
status: null,
|
|
1193
|
-
line_count: content.split('\n').length,
|
|
1194
|
-
format: 'legacy' // 'legacy' or 'frontmatter'
|
|
1195
|
-
};
|
|
1196
|
-
|
|
1197
|
-
// Check for YAML frontmatter (version 2 format)
|
|
1198
|
-
const frontmatter = parseYamlFrontmatter(content);
|
|
1199
|
-
if (frontmatter.version === 2 || frontmatter.current_phase !== undefined) {
|
|
1200
|
-
result.format = 'frontmatter';
|
|
1201
|
-
result.current_phase = frontmatter.current_phase || null;
|
|
1202
|
-
result.total_phases = frontmatter.total_phases || null;
|
|
1203
|
-
result.phase_name = frontmatter.phase_slug || frontmatter.phase_name || null;
|
|
1204
|
-
result.status = frontmatter.status || null;
|
|
1205
|
-
result.progress = frontmatter.progress_percent !== undefined ? frontmatter.progress_percent : null;
|
|
1206
|
-
result.plans_total = frontmatter.plans_total || null;
|
|
1207
|
-
result.plans_complete = frontmatter.plans_complete || null;
|
|
1208
|
-
result.last_activity = frontmatter.last_activity || null;
|
|
1209
|
-
result.last_command = frontmatter.last_command || null;
|
|
1210
|
-
result.blockers = frontmatter.blockers || [];
|
|
1211
|
-
return result;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// Legacy regex-based parsing (version 1 format, no frontmatter)
|
|
1215
|
-
// DEPRECATED (2026-02): v1 STATE.md format (no YAML frontmatter) is deprecated.
|
|
1216
|
-
// New projects should use v2 (frontmatter) format, generated by /pbr:setup.
|
|
1217
|
-
// v1 support will be removed in a future major version.
|
|
1218
|
-
process.stderr.write('[pbr] WARNING: STATE.md uses legacy v1 format. Run /pbr:setup to migrate to v2 format.\n');
|
|
1219
|
-
// Extract "Phase: N of M"
|
|
1220
|
-
const phaseMatch = content.match(/Phase:\s*(\d+)\s+of\s+(\d+)/);
|
|
1221
|
-
if (phaseMatch) {
|
|
1222
|
-
result.current_phase = parseInt(phaseMatch[1], 10);
|
|
1223
|
-
result.total_phases = parseInt(phaseMatch[2], 10);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// Extract phase name (line after "Phase:")
|
|
1227
|
-
const nameMatch = content.match(/--\s+(.+?)(?:\n|$)/);
|
|
1228
|
-
if (nameMatch) {
|
|
1229
|
-
result.phase_name = nameMatch[1].trim();
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// Extract progress percentage
|
|
1233
|
-
const progressMatch = content.match(/(\d+)%/);
|
|
1234
|
-
if (progressMatch) {
|
|
1235
|
-
result.progress = parseInt(progressMatch[1], 10);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Extract plan status
|
|
1239
|
-
const statusMatch = content.match(/Status:\s*(.+?)(?:\n|$)/i);
|
|
1240
|
-
if (statusMatch) {
|
|
1241
|
-
result.status = statusMatch[1].trim();
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
return result;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
function parseRoadmapMd(content) {
|
|
1248
|
-
const result = { phases: [], has_progress_table: false };
|
|
1249
|
-
|
|
1250
|
-
// Find Phase Overview table
|
|
1251
|
-
const overviewMatch = content.match(/## Phase Overview[\s\S]*?\|[\s\S]*?(?=\n##|\s*$)/);
|
|
1252
|
-
if (overviewMatch) {
|
|
1253
|
-
const rows = overviewMatch[0].split('\n').filter(r => r.includes('|'));
|
|
1254
|
-
// Skip header and separator rows
|
|
1255
|
-
for (let i = 2; i < rows.length; i++) {
|
|
1256
|
-
const cols = rows[i].split('|').map(c => c.trim()).filter(Boolean);
|
|
1257
|
-
if (cols.length >= 3) {
|
|
1258
|
-
result.phases.push({
|
|
1259
|
-
number: cols[0],
|
|
1260
|
-
name: cols[1],
|
|
1261
|
-
goal: cols[2],
|
|
1262
|
-
plans: cols[3] || '',
|
|
1263
|
-
wave: cols[4] || '',
|
|
1264
|
-
status: cols[5] || 'pending'
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
// Check for Progress table
|
|
1271
|
-
result.has_progress_table = /## Progress/.test(content);
|
|
1272
|
-
|
|
1273
|
-
return result;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function parseYamlFrontmatter(content) {
|
|
1277
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1278
|
-
if (!match) return {};
|
|
1279
|
-
|
|
1280
|
-
const yaml = match[1];
|
|
1281
|
-
const result = {};
|
|
1282
|
-
|
|
1283
|
-
// Simple YAML parser for flat and basic nested values
|
|
1284
|
-
const lines = yaml.split('\n');
|
|
1285
|
-
let currentKey = null;
|
|
1286
|
-
|
|
1287
|
-
for (const line of lines) {
|
|
1288
|
-
// Array item
|
|
1289
|
-
if (/^\s+-\s+/.test(line) && currentKey) {
|
|
1290
|
-
const val = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
|
|
1291
|
-
if (!result[currentKey]) result[currentKey] = [];
|
|
1292
|
-
if (Array.isArray(result[currentKey])) {
|
|
1293
|
-
result[currentKey].push(val);
|
|
1294
|
-
}
|
|
1295
|
-
continue;
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// Key-value pair
|
|
1299
|
-
const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)/);
|
|
1300
|
-
if (kvMatch) {
|
|
1301
|
-
currentKey = kvMatch[1];
|
|
1302
|
-
let val = kvMatch[2].trim();
|
|
1303
|
-
|
|
1304
|
-
if (val === '' || val === '|') {
|
|
1305
|
-
// Possible array or block follows
|
|
1306
|
-
continue;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Handle arrays on same line: [a, b, c]
|
|
1310
|
-
if (val.startsWith('[') && val.endsWith(']')) {
|
|
1311
|
-
result[currentKey] = val.slice(1, -1).split(',')
|
|
1312
|
-
.map(v => v.trim().replace(/^["']|["']$/g, ''))
|
|
1313
|
-
.filter(Boolean);
|
|
1314
|
-
continue;
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
// Clean quotes
|
|
1318
|
-
val = val.replace(/^["']|["']$/g, '');
|
|
1319
|
-
|
|
1320
|
-
// Type coercion
|
|
1321
|
-
if (val === 'true') val = true;
|
|
1322
|
-
else if (val === 'false') val = false;
|
|
1323
|
-
else if (/^\d+$/.test(val)) val = parseInt(val, 10);
|
|
1324
|
-
|
|
1325
|
-
result[currentKey] = val;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// Handle must_haves as a nested object
|
|
1330
|
-
if (yaml.includes('must_haves:')) {
|
|
1331
|
-
result.must_haves = parseMustHaves(yaml);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
return result;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
function parseMustHaves(yaml) {
|
|
1338
|
-
const result = { truths: [], artifacts: [], key_links: [] };
|
|
1339
|
-
let section = null;
|
|
1340
|
-
|
|
1341
|
-
const inMustHaves = yaml.split('\n');
|
|
1342
|
-
let collecting = false;
|
|
1343
|
-
|
|
1344
|
-
for (const line of inMustHaves) {
|
|
1345
|
-
if (/^\s*must_haves:/.test(line)) {
|
|
1346
|
-
collecting = true;
|
|
1347
|
-
continue;
|
|
1348
|
-
}
|
|
1349
|
-
if (collecting) {
|
|
1350
|
-
if (/^\s{2}truths:/.test(line)) { section = 'truths'; continue; }
|
|
1351
|
-
if (/^\s{2}artifacts:/.test(line)) { section = 'artifacts'; continue; }
|
|
1352
|
-
if (/^\s{2}key_links:/.test(line)) { section = 'key_links'; continue; }
|
|
1353
|
-
if (/^\w/.test(line)) break; // New top-level key, stop
|
|
1354
|
-
|
|
1355
|
-
if (section && /^\s+-\s+/.test(line)) {
|
|
1356
|
-
result[section].push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
return result;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// --- Helpers ---
|
|
1365
|
-
|
|
1366
|
-
function findFiles(dir, pattern) {
|
|
1367
|
-
try {
|
|
1368
|
-
return fs.readdirSync(dir).filter(f => pattern.test(f)).sort();
|
|
1369
|
-
} catch (_) {
|
|
1370
|
-
return [];
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
function determinePhaseStatus(planCount, completedCount, summaryCount, hasVerification, phaseDir) {
|
|
1375
|
-
if (planCount === 0) {
|
|
1376
|
-
// Check for CONTEXT.md (discussed only)
|
|
1377
|
-
if (fs.existsSync(path.join(phaseDir, 'CONTEXT.md'))) return 'discussed';
|
|
1378
|
-
return 'not_started';
|
|
1379
|
-
}
|
|
1380
|
-
if (completedCount === 0 && summaryCount === 0) return 'planned';
|
|
1381
|
-
if (completedCount < planCount) return 'building';
|
|
1382
|
-
if (!hasVerification) return 'built';
|
|
1383
|
-
// Check verification status
|
|
1384
|
-
try {
|
|
1385
|
-
const vContent = fs.readFileSync(path.join(phaseDir, 'VERIFICATION.md'), 'utf8');
|
|
1386
|
-
if (/status:\s*["']?passed/i.test(vContent)) return 'verified';
|
|
1387
|
-
if (/status:\s*["']?gaps_found/i.test(vContent)) return 'needs_fixes';
|
|
1388
|
-
return 'reviewed';
|
|
1389
|
-
} catch (_) {
|
|
1390
|
-
return 'built';
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
function countMustHaves(mustHaves) {
|
|
1395
|
-
if (!mustHaves) return 0;
|
|
1396
|
-
return (mustHaves.truths || []).length +
|
|
1397
|
-
(mustHaves.artifacts || []).length +
|
|
1398
|
-
(mustHaves.key_links || []).length;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
function calculateProgress() {
|
|
1402
|
-
const phasesDir = path.join(planningDir, 'phases');
|
|
1403
|
-
if (!fs.existsSync(phasesDir)) {
|
|
1404
|
-
return { total: 0, completed: 0, percentage: 0 };
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
let total = 0;
|
|
1408
|
-
let completed = 0;
|
|
1409
|
-
|
|
1410
|
-
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
1411
|
-
.filter(e => e.isDirectory());
|
|
1412
|
-
|
|
1413
|
-
for (const entry of entries) {
|
|
1414
|
-
const dir = path.join(phasesDir, entry.name);
|
|
1415
|
-
const plans = findFiles(dir, /-PLAN\.md$/);
|
|
1416
|
-
total += plans.length;
|
|
1417
|
-
|
|
1418
|
-
const summaries = findFiles(dir, /^SUMMARY-.*\.md$/);
|
|
1419
|
-
for (const s of summaries) {
|
|
1420
|
-
const content = fs.readFileSync(path.join(dir, s), 'utf8');
|
|
1421
|
-
if (/status:\s*["']?complete/i.test(content)) completed++;
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
return {
|
|
1426
|
-
total,
|
|
1427
|
-
completed,
|
|
1428
|
-
percentage: total > 0 ? Math.round((completed / total) * 100) : 0
|
|
1429
|
-
};
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
function output(data) {
|
|
1433
|
-
process.stdout.write(JSON.stringify(data, null, 2));
|
|
1434
|
-
process.exit(0);
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
function error(msg) {
|
|
1438
|
-
process.stdout.write(JSON.stringify({ error: msg }));
|
|
1439
|
-
process.exit(1);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
/**
|
|
1443
|
-
* Write content to a file atomically: write to .tmp, backup original to .bak,
|
|
1444
|
-
* rename .tmp over original. On failure, restore from .bak if available.
|
|
1445
|
-
*
|
|
1446
|
-
* @param {string} filePath - Target file path
|
|
1447
|
-
* @param {string} content - Content to write
|
|
1448
|
-
* @returns {{success: boolean, error?: string}} Result
|
|
1449
|
-
*/
|
|
1450
|
-
function atomicWrite(filePath, content) {
|
|
1451
|
-
const tmpPath = filePath + '.tmp';
|
|
1452
|
-
const bakPath = filePath + '.bak';
|
|
1453
|
-
|
|
1454
|
-
try {
|
|
1455
|
-
// 1. Write to temp file
|
|
1456
|
-
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
1457
|
-
|
|
1458
|
-
// 2. Backup original if it exists
|
|
1459
|
-
if (fs.existsSync(filePath)) {
|
|
1460
|
-
try {
|
|
1461
|
-
fs.copyFileSync(filePath, bakPath);
|
|
1462
|
-
} catch (_e) {
|
|
1463
|
-
// Backup failure is non-fatal — proceed with rename
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
// 3. Rename temp over original (atomic on most filesystems)
|
|
1468
|
-
fs.renameSync(tmpPath, filePath);
|
|
1469
|
-
|
|
1470
|
-
return { success: true };
|
|
1471
|
-
} catch (e) {
|
|
1472
|
-
// Rename failed — try to restore from backup
|
|
1473
|
-
try {
|
|
1474
|
-
if (fs.existsSync(bakPath)) {
|
|
1475
|
-
fs.copyFileSync(bakPath, filePath);
|
|
1476
|
-
}
|
|
1477
|
-
} catch (_restoreErr) {
|
|
1478
|
-
// Restore also failed — nothing more we can do
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
// Clean up temp file if it still exists
|
|
1482
|
-
try {
|
|
1483
|
-
if (fs.existsSync(tmpPath)) {
|
|
1484
|
-
fs.unlinkSync(tmpPath);
|
|
1485
|
-
}
|
|
1486
|
-
} catch (_cleanupErr) {
|
|
1487
|
-
// Best-effort cleanup
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
return { success: false, error: e.message };
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
603
|
if (require.main === module || process.argv[1] === __filename) { main().catch(err => { process.stderr.write(err.message + '\n'); process.exit(1); }); }
|
|
1495
|
-
module.exports = { parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition };
|
|
604
|
+
module.exports = { KNOWN_AGENTS, initExecutePhase, initPlanPhase, initQuick, initVerifyWork, initResume, initProgress, statePatch, stateAdvancePlan, stateRecordMetric, parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill, validateProject, phaseAdd, phaseRemove, phaseList };
|
|
605
|
+
// NOTE: validateProject, phaseAdd, phaseRemove, phaseList were previously CLI-only (not exported).
|
|
606
|
+
// They are now exported for testability. This is additive and backwards-compatible.
|