@entelligentsia/forgecli 1.0.10 → 1.0.20
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 +191 -0
- package/dist/CHANGELOG-forge-plugin.md +211 -0
- package/dist/bin/forge.js +0 -0
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/context-governor-compaction.d.ts +83 -0
- package/dist/extensions/forgecli/context-governor-compaction.js +302 -0
- package/dist/extensions/forgecli/context-governor-compaction.js.map +1 -0
- package/dist/extensions/forgecli/context-governor.d.ts +173 -0
- package/dist/extensions/forgecli/context-governor.js +618 -0
- package/dist/extensions/forgecli/context-governor.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/component.d.ts +105 -0
- package/dist/extensions/forgecli/dashboard/component.js +861 -0
- package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
- package/dist/extensions/forgecli/dashboard/register.js +31 -0
- package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/theme.d.ts +27 -0
- package/dist/extensions/forgecli/dashboard/theme.js +91 -0
- package/dist/extensions/forgecli/dashboard/theme.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
- package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
- package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
- package/dist/extensions/forgecli/fix-bug.js +126 -7
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js +2 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
- package/dist/extensions/forgecli/forge-commands.js +1 -0
- package/dist/extensions/forgecli/forge-commands.js.map +1 -1
- package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
- package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
- package/dist/extensions/forgecli/forge-subagent.d.ts +20 -1
- package/dist/extensions/forgecli/forge-subagent.js +23 -7
- package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
- package/dist/extensions/forgecli/forge-tools.js +3 -1
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/hook-dispatcher.d.ts +3 -1
- package/dist/extensions/forgecli/hook-dispatcher.js +37 -3
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/index.js +38 -1
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/lib/halt-advisor.d.ts +59 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js +113 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
- package/dist/extensions/forgecli/migration-engine.js +25 -12
- package/dist/extensions/forgecli/migration-engine.js.map +1 -1
- package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +26 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js +213 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
- package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
- package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
- package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
- package/dist/extensions/forgecli/project-orientation.js +12 -8
- package/dist/extensions/forgecli/project-orientation.js.map +1 -1
- package/dist/extensions/forgecli/regenerate.d.ts +16 -0
- package/dist/extensions/forgecli/regenerate.js +110 -0
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/run-sprint.d.ts +3 -1
- package/dist/extensions/forgecli/run-sprint.js +34 -3
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.d.ts +66 -1
- package/dist/extensions/forgecli/run-task.js +323 -12
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/thread-switcher.d.ts +4 -1
- package/dist/extensions/forgecli/thread-switcher.js +118 -762
- package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
- package/dist/extensions/forgecli/viewport-events.js +32 -0
- package/dist/extensions/forgecli/viewport-events.js.map +1 -1
- package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
- package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
- package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
- package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
- package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
- package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
- package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
- package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
- package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/implement_plan.md +15 -7
- package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
- package/dist/forge-payload/.base-pack/workflows/plan_task.md +12 -6
- package/dist/forge-payload/.base-pack/workflows/review_code.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/review_plan.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
- package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
- package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/validate_task.md +9 -9
- package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/config.schema.json +2 -3
- package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/.schemas/event.schema.json +16 -0
- package/dist/forge-payload/.schemas/migrations.json +359 -18
- package/dist/forge-payload/commands/health.md +29 -0
- package/dist/forge-payload/commands/rebuild.md +143 -15
- package/dist/forge-payload/commands/update.md +28 -27
- package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
- package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
- package/dist/forge-payload/integrity.json +7 -6
- package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
- package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
- package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
- package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
- package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
- package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
- package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
- package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
- package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
- package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
- package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
- package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
- package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
- package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
- package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
- package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-review-implementation.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-plan.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-validate.md +9 -9
- package/dist/forge-payload/schemas/config.schema.json +2 -3
- package/dist/forge-payload/schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/schemas/event.schema.json +16 -0
- package/dist/forge-payload/schemas/structure-manifest.json +75 -73
- package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
- package/dist/forge-payload/tools/banners.cjs +29 -10
- package/dist/forge-payload/tools/check-structure.cjs +88 -7
- package/dist/forge-payload/tools/collate.cjs +48 -2
- package/dist/forge-payload/tools/manage-config.cjs +5 -7
- package/dist/forge-payload/tools/parse-gates.cjs +73 -1
- package/dist/forge-payload/tools/postflight-gate.cjs +298 -0
- package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
- package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
- package/dist/forge-payload/tools/verify-phase.cjs +17 -0
- package/package.json +2 -2
- package/dist/bin/forgecli.d.ts +0 -2
- package/dist/bin/forgecli.js +0 -6
- package/dist/bin/forgecli.js.map +0 -1
- package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
- package/dist/extensions/forgecli/config-tui/index.js +0 -5
- package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
- package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
- package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
- package/dist/extensions/forgecli/loaders/template-render.js +0 -85
- package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
- package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
- package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
- package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
- package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
- package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
- package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// node banners.cjs --subtitle "Forging your SDLC"
|
|
33
33
|
// node banners.cjs --progress 5 12 "Templates"
|
|
34
34
|
// node banners.cjs --phase 7 12 "Workflows" ember
|
|
35
|
+
// node banners.cjs --badge forge --quiet (automated/orchestrator use: zero stdout)
|
|
35
36
|
|
|
36
37
|
// ─── Plain-mode detection ─────────────────────────────────────────────────────
|
|
37
38
|
// Resolved at call-time so tests can flip env vars dynamically.
|
|
@@ -51,6 +52,12 @@ function stripAnsi(s) {
|
|
|
51
52
|
// Soft override — set by `--plain` CLI flag to force plain mode for one run.
|
|
52
53
|
let FORCE_PLAIN = false;
|
|
53
54
|
|
|
55
|
+
// Quiet mode — set by `--quiet` CLI flag to suppress all stdout output.
|
|
56
|
+
// Used by the orchestrator loop so banner output does not enter the LLM context
|
|
57
|
+
// window (output remains visible on a real TTY via the human's terminal but is
|
|
58
|
+
// not fed back as tool-call response text).
|
|
59
|
+
let QUIET_MODE = false;
|
|
60
|
+
|
|
54
61
|
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
55
62
|
const R = '\x1b[0m';
|
|
56
63
|
const B = '\x1b[1m';
|
|
@@ -376,6 +383,7 @@ function _maybePlain(s) {
|
|
|
376
383
|
|
|
377
384
|
// ─── CLI ───────────────────────────────────────────────────────────────────────
|
|
378
385
|
// node banners.cjs [<name>] [--gallery] [--badge <name>] [--mark <name>] [--list]
|
|
386
|
+
// node banners.cjs --badge <name> --quiet (suppress all stdout; orchestrator use)
|
|
379
387
|
|
|
380
388
|
if (require.main === module) {
|
|
381
389
|
let args = process.argv.slice(2);
|
|
@@ -387,38 +395,49 @@ if (require.main === module) {
|
|
|
387
395
|
args = args.slice(0, plainIdx).concat(args.slice(plainIdx + 1));
|
|
388
396
|
}
|
|
389
397
|
|
|
398
|
+
// --quiet may appear anywhere; consume it before dispatch.
|
|
399
|
+
// When set, all stdout is suppressed (exit 0 still guaranteed on success).
|
|
400
|
+
const quietIdx = args.indexOf('--quiet');
|
|
401
|
+
if (quietIdx !== -1) {
|
|
402
|
+
QUIET_MODE = true;
|
|
403
|
+
args = args.slice(0, quietIdx).concat(args.slice(quietIdx + 1));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Helper: write to stdout only when not in quiet mode.
|
|
407
|
+
const emit = (s) => { if (!QUIET_MODE) process.stdout.write(s); };
|
|
408
|
+
|
|
390
409
|
try {
|
|
391
410
|
if (!args.length || args[0] === '--gallery') {
|
|
392
|
-
|
|
411
|
+
emit(gallery() + '\n');
|
|
393
412
|
} else if (args[0] === '--list') {
|
|
394
|
-
|
|
413
|
+
emit(list().map(n => {
|
|
395
414
|
const b = BANNERS[n];
|
|
396
415
|
return `${b.emoji} ${n.padEnd(8)} — ${b.tagline}`;
|
|
397
|
-
}).join('\n'));
|
|
416
|
+
}).join('\n') + '\n');
|
|
398
417
|
} else if (args[0] === '--badge') {
|
|
399
|
-
|
|
418
|
+
emit(badge(args[1] || '') + '\n');
|
|
400
419
|
} else if (args[0] === '--mark') {
|
|
401
|
-
|
|
420
|
+
emit(mark(args[1] || '') + '\n');
|
|
402
421
|
} else if (args[0] === '--subtitle') {
|
|
403
|
-
|
|
422
|
+
emit(subtitle(args.slice(1).join(' ')) + '\n');
|
|
404
423
|
} else if (args[0] === '--progress') {
|
|
405
424
|
const n = Number(args[1]);
|
|
406
425
|
const total = Number(args[2]);
|
|
407
426
|
const label = args.slice(3).join(' ') || undefined;
|
|
408
|
-
|
|
427
|
+
emit(progressBar(n, total, { label }) + '\n');
|
|
409
428
|
} else if (args[0] === '--phase') {
|
|
410
429
|
const n = Number(args[1]);
|
|
411
430
|
const total = Number(args[2]);
|
|
412
431
|
const name = args[3] || '';
|
|
413
432
|
const bannerKey = args[4] || 'forge';
|
|
414
433
|
const mode = args[5]; // optional 'fast' | 'full'
|
|
415
|
-
|
|
434
|
+
emit(phaseHeader(n, total, name, bannerKey, mode ? { mode } : undefined) + '\n');
|
|
416
435
|
} else if (args[0] === '--rule') {
|
|
417
436
|
// --rule [text] Zen-blue em-dash horizontal rule (with optional label)
|
|
418
437
|
const text = args.slice(1).join(' ') || undefined;
|
|
419
|
-
|
|
438
|
+
emit(ruleLine(text) + '\n');
|
|
420
439
|
} else {
|
|
421
|
-
|
|
440
|
+
emit(render(args[0]) + '\n');
|
|
422
441
|
}
|
|
423
442
|
} catch (e) {
|
|
424
443
|
console.error(e.message);
|
|
@@ -132,9 +132,17 @@ function validateManifest(manifest, forgeRoot) {
|
|
|
132
132
|
fragments: path.join(basePackDir, 'workflows', '_fragments'),
|
|
133
133
|
templates: path.join(basePackDir, 'templates'),
|
|
134
134
|
commands: path.join(basePackDir, 'commands'),
|
|
135
|
+
'workflows-js': path.join(basePackDir, 'workflows-js'),
|
|
136
|
+
// tools: not base-pack-sourced — source is forgeRoot/tools/ (verbatim vendor).
|
|
137
|
+
// Uses recursive enumeration to include lib/*.cjs with the lib/ prefix.
|
|
138
|
+
tools: path.join(forgeRoot, 'tools'),
|
|
135
139
|
// schemas: not base-pack-sourced — source is forgeRoot/schemas/
|
|
136
140
|
};
|
|
137
141
|
|
|
142
|
+
// Namespaces that require recursive enumeration (files may include path prefixes
|
|
143
|
+
// like lib/*.cjs). The walker returns relative paths with forward slashes.
|
|
144
|
+
const recursiveNs = new Set(['tools']);
|
|
145
|
+
|
|
138
146
|
const manifestOnly = []; // Files in manifest but not in base-pack
|
|
139
147
|
const basePackOnly = []; // Files in base-pack but not in manifest
|
|
140
148
|
|
|
@@ -147,14 +155,31 @@ function validateManifest(manifest, forgeRoot) {
|
|
|
147
155
|
|
|
148
156
|
const manifestFiles = new Set(ns.files || []);
|
|
149
157
|
|
|
150
|
-
// Read base-pack source directory
|
|
158
|
+
// Read base-pack source directory (flat or recursive depending on namespace)
|
|
151
159
|
let basePackFiles = [];
|
|
152
160
|
try {
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
if (recursiveNs.has(nsKey)) {
|
|
162
|
+
// Recursive walk: enumerate all files, returning paths relative to sourceDir
|
|
163
|
+
// (with forward-slash separators). Excludes *.test.cjs files.
|
|
164
|
+
const walkDir = (dir, prefix) => {
|
|
165
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
168
|
+
if (entry.isDirectory()) {
|
|
169
|
+
walkDir(path.join(dir, entry.name), relPath);
|
|
170
|
+
} else if (entry.isFile() && !entry.name.endsWith('.test.cjs')) {
|
|
171
|
+
basePackFiles.push(relPath);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
walkDir(sourceDir, '');
|
|
176
|
+
} else {
|
|
177
|
+
basePackFiles = fs.readdirSync(sourceDir).filter(f => {
|
|
178
|
+
// Only include regular files, skip subdirectories (like _fragments under workflows)
|
|
179
|
+
const stat = fs.statSync(path.join(sourceDir, f));
|
|
180
|
+
return stat.isFile();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
158
183
|
} catch {
|
|
159
184
|
// Directory doesn't exist — all manifest files are manifestOnly
|
|
160
185
|
for (const f of manifestFiles) {
|
|
@@ -183,9 +208,65 @@ function validateManifest(manifest, forgeRoot) {
|
|
|
183
208
|
return { manifestOnly, basePackOnly };
|
|
184
209
|
}
|
|
185
210
|
|
|
211
|
+
// Checks whether the vendored .forge/tools/ directory is present, and whether
|
|
212
|
+
// the version marker (.forge/tools/.forge-tools-version) matches the active
|
|
213
|
+
// plugin version from .forge/config.json (paths.forgeRef).
|
|
214
|
+
//
|
|
215
|
+
// Returns:
|
|
216
|
+
// { present, vendoredVersion, activeVersion, stale, reason }
|
|
217
|
+
//
|
|
218
|
+
// present — true if .forge/tools/ directory exists
|
|
219
|
+
// vendoredVersion — version string from .forge-tools-version (or null)
|
|
220
|
+
// activeVersion — version string from paths.forgeRef in config (or null)
|
|
221
|
+
// stale — true when: dir absent=false; marker absent=true; versions differ=true
|
|
222
|
+
// reason — 'ok' | 'missing' | 'marker-absent' | 'version-mismatch'
|
|
223
|
+
function checkToolsVersion(projectRoot) {
|
|
224
|
+
const toolsDir = path.join(projectRoot, '.forge', 'tools');
|
|
225
|
+
const markerFile = path.join(toolsDir, '.forge-tools-version');
|
|
226
|
+
const configFile = path.join(projectRoot, '.forge', 'config.json');
|
|
227
|
+
|
|
228
|
+
// Dir absent → not stale (just missing)
|
|
229
|
+
if (!fs.existsSync(toolsDir)) {
|
|
230
|
+
return { present: false, vendoredVersion: null, activeVersion: null, stale: false, reason: 'missing' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Read active version from config
|
|
234
|
+
let activeVersion = null;
|
|
235
|
+
if (fs.existsSync(configFile)) {
|
|
236
|
+
try {
|
|
237
|
+
const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
238
|
+
activeVersion = (cfg && cfg.paths && cfg.paths.forgeRef) ? cfg.paths.forgeRef : null;
|
|
239
|
+
} catch {
|
|
240
|
+
// unparseable — leave activeVersion null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Marker absent → stale
|
|
245
|
+
if (!fs.existsSync(markerFile)) {
|
|
246
|
+
return { present: true, vendoredVersion: null, activeVersion, stale: true, reason: 'marker-absent' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Read vendored version from marker
|
|
250
|
+
let vendoredVersion = null;
|
|
251
|
+
try {
|
|
252
|
+
const marker = JSON.parse(fs.readFileSync(markerFile, 'utf8'));
|
|
253
|
+
vendoredVersion = (marker && marker.version) ? marker.version : null;
|
|
254
|
+
} catch {
|
|
255
|
+
// unparseable marker → treat as marker-absent
|
|
256
|
+
return { present: true, vendoredVersion: null, activeVersion, stale: true, reason: 'marker-absent' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Version mismatch → stale
|
|
260
|
+
if (activeVersion && vendoredVersion && vendoredVersion !== activeVersion) {
|
|
261
|
+
return { present: true, vendoredVersion, activeVersion, stale: true, reason: 'version-mismatch' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { present: true, vendoredVersion, activeVersion, stale: false, reason: 'ok' };
|
|
265
|
+
}
|
|
266
|
+
|
|
186
267
|
// ── Exports ────────────────────────────────────────────────────────────────────
|
|
187
268
|
|
|
188
|
-
module.exports = { checkNamespaces, validateManifest };
|
|
269
|
+
module.exports = { checkNamespaces, validateManifest, checkToolsVersion };
|
|
189
270
|
|
|
190
271
|
// ── CLI ────────────────────────────────────────────────────────────────────────
|
|
191
272
|
|
|
@@ -108,9 +108,23 @@ function resolveTaskDir(task, sprintDirPath, engPath) {
|
|
|
108
108
|
const normalizedTaskPath = task.path ? task.path.replace(/\\/g, '/').replace(/\/$/, '') : null;
|
|
109
109
|
const normalizedEngPath = engPath ? engPath.replace(/\\/g, '/').replace(/\/$/, '') : '';
|
|
110
110
|
|
|
111
|
-
// Case 1: path is under the engineering root — it IS
|
|
111
|
+
// Case 1: path is under the engineering root — it IS (or is a file inside)
|
|
112
|
+
// the task directory. Some store records carry task.path pointing at a file
|
|
113
|
+
// inside the task dir (e.g. .../FORGE-S22-T04/PLAN.md) rather than the dir
|
|
114
|
+
// itself; in that case basename(path) is the filename, not the dir. Only
|
|
115
|
+
// trust Case 1 when its candidate resolves to a real directory on disk —
|
|
116
|
+
// otherwise fall through to the filesystem scan, which resolves by taskId.
|
|
112
117
|
if (normalizedTaskPath && normalizedEngPath && normalizedTaskPath.startsWith(normalizedEngPath + '/')) {
|
|
113
|
-
|
|
118
|
+
const candidate = path.basename(normalizedTaskPath);
|
|
119
|
+
// Can't verify against disk — preserve legacy behaviour and trust the path.
|
|
120
|
+
if (!sprintDirPath) return resultOk(candidate);
|
|
121
|
+
const candidatePath = path.join(sprintDirPath, candidate);
|
|
122
|
+
// Trust Case 1 only when the candidate is a real directory. If it's a file
|
|
123
|
+
// (task.path pointed at a doc inside the dir) or absent, fall through to the
|
|
124
|
+
// filesystem scan below, which resolves the dir by taskId.
|
|
125
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
|
|
126
|
+
return resultOk(candidate);
|
|
127
|
+
}
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
// Case 2 (and fallback for case 1 missing): filesystem scan
|
|
@@ -597,6 +611,38 @@ function buildCostReport(sprint, events, orphanSidecars, huskPrimaries) {
|
|
|
597
611
|
}
|
|
598
612
|
}
|
|
599
613
|
|
|
614
|
+
// --- Section 3b: Incomplete passes (aborted / failed attempts) ---
|
|
615
|
+
// forge-cli ≥1.0.16 emits phase events with verdict "aborted" (user cancel)
|
|
616
|
+
// or "failed" (halt-on-failure) so the provider-billed tokens of incomplete
|
|
617
|
+
// attempts reach the store. Those tokens ARE included in the Per-Task /
|
|
618
|
+
// Per-Role / Model Split totals above (totals are verdict-agnostic — this
|
|
619
|
+
// closes the under-count where aborted passes were invisible); this section
|
|
620
|
+
// makes the incomplete share visible rather than silently mixed in.
|
|
621
|
+
lines.push('## Incomplete Passes', '');
|
|
622
|
+
{
|
|
623
|
+
const INCOMPLETE_VERDICTS = new Set(['aborted', 'failed']);
|
|
624
|
+
const incompleteEvents = tokenEvents.filter(e => INCOMPLETE_VERDICTS.has(e.verdict));
|
|
625
|
+
if (incompleteEvents.length === 0) {
|
|
626
|
+
lines.push('_No incomplete passes in this sprint._', '');
|
|
627
|
+
} else {
|
|
628
|
+
const rows = [['Task', 'Phase', 'Outcome', 'Input Tokens', 'Output Tokens', 'Est. Cost USD']];
|
|
629
|
+
const sorted = [...incompleteEvents].sort((a, b) =>
|
|
630
|
+
String(a.taskId || '').localeCompare(String(b.taskId || '')) ||
|
|
631
|
+
String(a.phase || '').localeCompare(String(b.phase || '')));
|
|
632
|
+
for (const e of sorted) {
|
|
633
|
+
rows.push([
|
|
634
|
+
e.taskId || '(unknown)',
|
|
635
|
+
e.phase || '(unknown)',
|
|
636
|
+
e.verdict,
|
|
637
|
+
fmtTokens(e.inputTokens || 0),
|
|
638
|
+
fmtTokens(e.outputTokens || 0),
|
|
639
|
+
fmtCost(e.estimatedCostUSD || 0),
|
|
640
|
+
]);
|
|
641
|
+
}
|
|
642
|
+
lines.push(padTable(rows), '');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
600
646
|
// --- Section 4: Model split ---
|
|
601
647
|
lines.push('## Model Split', '');
|
|
602
648
|
{
|
|
@@ -418,7 +418,7 @@ if (subcmd === 'resolve-forge-root') {
|
|
|
418
418
|
|
|
419
419
|
const { config } = readConfig();
|
|
420
420
|
const forgeRef = getByPath(config, 'paths.forgeRef');
|
|
421
|
-
|
|
421
|
+
// Note: paths.forgeRoot read removed in FORGE-S29-T03 (Priority 3 deprecated).
|
|
422
422
|
|
|
423
423
|
// Priority 2: Scan cache/marketplace directories by forgeRef
|
|
424
424
|
if (forgeRef && typeof forgeRef === 'string') {
|
|
@@ -442,14 +442,12 @@ if (subcmd === 'resolve-forge-root') {
|
|
|
442
442
|
}
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
// Priority 3
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
process.exit(0);
|
|
449
|
-
}
|
|
445
|
+
// Priority 3 (paths.forgeRoot fallback) removed — FORGE-S29-T03.
|
|
446
|
+
// paths.forgeRoot is deprecated and no longer used as a resolution fallback.
|
|
447
|
+
// Relying on it masks misconfigured projects and blocks the FORGE_ROOT retirement.
|
|
450
448
|
|
|
451
449
|
// No resolution possible
|
|
452
|
-
console.error('× Cannot resolve Forge plugin root: no CLAUDE_PLUGIN_ROOT env var
|
|
450
|
+
console.error('× Cannot resolve Forge plugin root: no CLAUDE_PLUGIN_ROOT env var and no forgeRef cache match. Run /forge:update to refresh.');
|
|
453
451
|
process.exit(1);
|
|
454
452
|
}
|
|
455
453
|
|
|
@@ -154,4 +154,76 @@ function parseAfter(rest, lineNo) {
|
|
|
154
154
|
return { phase: m[1], verdict };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
// parseOutputs — same fence-scan logic as parseGates but for ```outputs phase=X blocks.
|
|
158
|
+
// Closed grammar: only `artifact` and `require` directives are allowed.
|
|
159
|
+
// Unknown directives throw (parity with parseGates).
|
|
160
|
+
const OUTPUTS_FENCE_OPEN = /^```outputs\s+phase=([A-Za-z0-9_-]+)\s*$/;
|
|
161
|
+
|
|
162
|
+
function parseOutputs(markdown) {
|
|
163
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return {};
|
|
164
|
+
const lines = markdown.split('\n');
|
|
165
|
+
const result = {};
|
|
166
|
+
let currentPhase = null;
|
|
167
|
+
let inFence = false;
|
|
168
|
+
let fenceStartLine = -1;
|
|
169
|
+
let fenceBuffer = [];
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
const lineNo = i + 1;
|
|
173
|
+
if (!inFence) {
|
|
174
|
+
const m = line.match(OUTPUTS_FENCE_OPEN);
|
|
175
|
+
if (m) {
|
|
176
|
+
currentPhase = m[1];
|
|
177
|
+
if (result[currentPhase]) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`parse-gates: line ${lineNo}: duplicate outputs block for phase "${currentPhase}"`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
inFence = true;
|
|
183
|
+
fenceStartLine = lineNo;
|
|
184
|
+
fenceBuffer = [];
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Inside an outputs fence
|
|
189
|
+
if (FENCE_CLOSE.test(line)) {
|
|
190
|
+
result[currentPhase] = parseOutputsBlock(fenceBuffer, fenceStartLine);
|
|
191
|
+
inFence = false;
|
|
192
|
+
currentPhase = null;
|
|
193
|
+
fenceBuffer = [];
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
fenceBuffer.push({ text: line, lineNo });
|
|
197
|
+
}
|
|
198
|
+
if (inFence) {
|
|
199
|
+
throw new Error(`parse-gates: unterminated \`\`\`outputs fence opened at line ${fenceStartLine}`);
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseOutputsBlock(bufferedLines, _fenceStartLine) {
|
|
205
|
+
const spec = { artifacts: [], require: [] };
|
|
206
|
+
for (const { text, lineNo } of bufferedLines) {
|
|
207
|
+
const trimmed = text.trim();
|
|
208
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
209
|
+
const firstSpace = trimmed.search(/\s/);
|
|
210
|
+
if (firstSpace < 0) {
|
|
211
|
+
throw new Error(`parse-gates: line ${lineNo}: malformed directive "${trimmed}"`);
|
|
212
|
+
}
|
|
213
|
+
const directive = trimmed.slice(0, firstSpace);
|
|
214
|
+
const rest = trimmed.slice(firstSpace + 1).trim();
|
|
215
|
+
switch (directive) {
|
|
216
|
+
case 'artifact':
|
|
217
|
+
spec.artifacts.push(parseArtifact(rest, lineNo));
|
|
218
|
+
break;
|
|
219
|
+
case 'require':
|
|
220
|
+
spec.require.push(parsePredicate(rest, lineNo));
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
throw new Error(`parse-gates: line ${lineNo}: unknown directive "${directive}"`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return spec;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { parseGates, parseOutputs };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// postflight-gate.cjs — evaluates a phase's declared outputs block against
|
|
4
|
+
// the filesystem / task state, post-subagent-return. Returns structured failure
|
|
5
|
+
// data so the orchestrator (run-task.ts) can halt before FSM advance when outputs
|
|
6
|
+
// are not satisfied.
|
|
7
|
+
//
|
|
8
|
+
// Pure function: only fs.existsSync / fs.statSync / fs.readFileSync. No writes,
|
|
9
|
+
// no network, no LLM, no process spawns.
|
|
10
|
+
//
|
|
11
|
+
// CLI shim: node postflight-gate.cjs --phase <name> --task <taskId>
|
|
12
|
+
// Exit codes: 0 (ok), 1 (guard failed), 2 (invalid args / parse error)
|
|
13
|
+
// Stdout on exit 1: single JSON line { phase, reasonCode, detail, remediation }
|
|
14
|
+
//
|
|
15
|
+
// New reasonCodes (FORGE-S26-T19):
|
|
16
|
+
// output-missing — artifact path does not exist
|
|
17
|
+
// output-stub — artifact exists but size < min= bytes
|
|
18
|
+
// require-failed — require predicate over state field failed
|
|
19
|
+
// tool-error — internal parse or store-read failure
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* postflight({ phase, outputs, state, substitutions })
|
|
26
|
+
*
|
|
27
|
+
* @param {string} phase - Phase name (e.g. 'implement')
|
|
28
|
+
* @param {object} outputs - Parsed outputs spec from parseOutputs() — { [phase]: { artifacts, require } }
|
|
29
|
+
* @param {object} [state] - Task/bug state for require predicates (e.g. { task: {...} })
|
|
30
|
+
* @param {object} [substitutions] - Template variable substitutions (e.g. { engineering, sprint, task })
|
|
31
|
+
* @returns {{ ok: boolean, missing: string[], reasonCode: string, detail: string, remediation: string }}
|
|
32
|
+
*/
|
|
33
|
+
function postflight({ phase, outputs, state = {}, substitutions = {} }) {
|
|
34
|
+
const spec = outputs && outputs[phase];
|
|
35
|
+
if (!spec) {
|
|
36
|
+
// No outputs block for this phase — pass through (no-op)
|
|
37
|
+
return { ok: true, missing: [], reasonCode: null, detail: '', remediation: '' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const missing = [];
|
|
41
|
+
|
|
42
|
+
for (const art of spec.artifacts || []) {
|
|
43
|
+
const resolved = applySubstitutions(art.path, substitutions);
|
|
44
|
+
let exists = false;
|
|
45
|
+
let size = 0;
|
|
46
|
+
try {
|
|
47
|
+
const st = fs.statSync(resolved);
|
|
48
|
+
exists = st.isFile();
|
|
49
|
+
size = st.size;
|
|
50
|
+
} catch (_) {
|
|
51
|
+
exists = false;
|
|
52
|
+
}
|
|
53
|
+
if (!exists) {
|
|
54
|
+
missing.push(`output-missing: artifact absent: ${resolved}`);
|
|
55
|
+
} else if (size < (art.minBytes || 0)) {
|
|
56
|
+
missing.push(
|
|
57
|
+
`output-stub: artifact too small: ${resolved} (${size} bytes, need >= ${art.minBytes})`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const pred of spec.require || []) {
|
|
63
|
+
if (!evalPredicate(pred, state)) {
|
|
64
|
+
missing.push(
|
|
65
|
+
`require-failed: ${describePredicate(pred)} (got ${JSON.stringify(readField(pred.field, state))})`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (missing.length === 0) {
|
|
71
|
+
return { ok: true, missing: [], reasonCode: null, detail: '', remediation: '' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { reasonCode, detail, remediation } = buildStructuredFailure(phase, missing);
|
|
75
|
+
return { ok: false, missing, reasonCode, detail, remediation };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function applySubstitutions(template, subs) {
|
|
79
|
+
return template.replace(/\{(\w+)\}/g, (full, key) => {
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(subs, key)) return String(subs[key]);
|
|
81
|
+
return full;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function walkPath(dottedPath, root) {
|
|
86
|
+
const parts = dottedPath.split('.');
|
|
87
|
+
let cur = root;
|
|
88
|
+
for (const p of parts) {
|
|
89
|
+
if (cur === null || cur === undefined) return undefined;
|
|
90
|
+
cur = cur[p];
|
|
91
|
+
}
|
|
92
|
+
return cur;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readField(dottedPath, state) {
|
|
96
|
+
// Direct walk first (supports explicit `task.status` / `bug.status` paths).
|
|
97
|
+
const direct = walkPath(dottedPath, state);
|
|
98
|
+
if (direct !== undefined) return direct;
|
|
99
|
+
// The materialized outputs blocks use BARE record paths
|
|
100
|
+
// (`summaries.plan.verdict`) while the CLI wraps the record as
|
|
101
|
+
// { task: record } — fall back to the entity record so those resolve.
|
|
102
|
+
// (CART-S03-T01 false-halt: every bare require evaluated undefined.)
|
|
103
|
+
if (state && state.task) {
|
|
104
|
+
const viaTask = walkPath(dottedPath, state.task);
|
|
105
|
+
if (viaTask !== undefined) return viaTask;
|
|
106
|
+
}
|
|
107
|
+
if (state && state.bug) {
|
|
108
|
+
const viaBug = walkPath(dottedPath, state.bug);
|
|
109
|
+
if (viaBug !== undefined) return viaBug;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build template substitutions for an entity's artifact directory.
|
|
116
|
+
* Canonical layouts:
|
|
117
|
+
* tasks: <engineering>/sprints/<sprintId>/<taskId>/
|
|
118
|
+
* bugs: <engineering>/bugs/<bugId>/
|
|
119
|
+
* {sprint} is defined as the path segment under <engineering> that contains
|
|
120
|
+
* the entity dir — `sprints/<sprintId>` for tasks, `bugs` for bugs.
|
|
121
|
+
* (CART-S03-T01 false-halt: {sprint} was substituted with the bare sprintId,
|
|
122
|
+
* dropping the `sprints/` segment, so every artifact check missed.)
|
|
123
|
+
*/
|
|
124
|
+
function buildSubstitutions({ taskRecord, engineeringRoot, entityId }) {
|
|
125
|
+
const rec = taskRecord || {};
|
|
126
|
+
const isBug = Boolean(rec.bugId) || /-BUG-/.test(String(entityId || ''));
|
|
127
|
+
let sprint;
|
|
128
|
+
if (isBug) {
|
|
129
|
+
sprint = 'bugs';
|
|
130
|
+
} else if (rec.sprintId) {
|
|
131
|
+
sprint = `sprints/${rec.sprintId}`;
|
|
132
|
+
} else {
|
|
133
|
+
sprint = '{sprint}'; // unresolvable — leave the placeholder visible in the report
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
engineering: engineeringRoot,
|
|
137
|
+
sprint,
|
|
138
|
+
task: entityId,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function evalPredicate(pred, state) {
|
|
143
|
+
const actual = readField(pred.field, state);
|
|
144
|
+
switch (pred.op) {
|
|
145
|
+
case '==':
|
|
146
|
+
return String(actual) === String(pred.value);
|
|
147
|
+
case '!=':
|
|
148
|
+
return String(actual) !== String(pred.value);
|
|
149
|
+
case 'in':
|
|
150
|
+
return (pred.value || []).map(String).includes(String(actual));
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`postflight-gate: unknown predicate op "${pred.op}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function describePredicate(pred) {
|
|
157
|
+
if (pred.op === 'in') return `${pred.field} in [${(pred.value || []).join(', ')}]`;
|
|
158
|
+
return `${pred.field} ${pred.op} ${pred.value}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildStructuredFailure(phase, missing) {
|
|
162
|
+
let reasonCode = 'tool-error';
|
|
163
|
+
const detailParts = [];
|
|
164
|
+
|
|
165
|
+
for (const m of missing) {
|
|
166
|
+
detailParts.push(m);
|
|
167
|
+
if (reasonCode === 'tool-error') {
|
|
168
|
+
if (/^output-missing/i.test(m)) {
|
|
169
|
+
reasonCode = 'output-missing';
|
|
170
|
+
} else if (/^output-stub/i.test(m)) {
|
|
171
|
+
reasonCode = 'output-stub';
|
|
172
|
+
} else if (/^require-failed/i.test(m)) {
|
|
173
|
+
reasonCode = 'require-failed';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const detail = detailParts.join('; ');
|
|
179
|
+
|
|
180
|
+
const remediationMap = {
|
|
181
|
+
'output-missing': 'Re-run the phase that produces this artifact (e.g. /forge:implement), then retry.',
|
|
182
|
+
'output-stub': 'The artifact was produced but appears incomplete (stub). Ensure the phase wrote a complete file.',
|
|
183
|
+
'require-failed': 'Correct the task/bug state so it satisfies the postflight require predicate, then retry.',
|
|
184
|
+
'tool-error': 'Check the outputs block configuration and store records; run node .forge/tools/postflight-gate.cjs manually for diagnostics.',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
reasonCode,
|
|
189
|
+
detail,
|
|
190
|
+
remediation: remediationMap[reasonCode],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { postflight, buildSubstitutions };
|
|
195
|
+
|
|
196
|
+
// CLI shim — only runs when invoked directly
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
const args = parseArgs(process.argv.slice(2));
|
|
199
|
+
if (!args.phase || !args.task) {
|
|
200
|
+
process.stderr.write('Usage: postflight-gate.cjs --phase <phaseName> --task <taskId>\n');
|
|
201
|
+
process.exit(2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { parseOutputs } = require('./parse-gates.cjs');
|
|
205
|
+
|
|
206
|
+
// Resolve config
|
|
207
|
+
let engineeringRoot = 'engineering';
|
|
208
|
+
try {
|
|
209
|
+
const cfg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), '.forge/config.json'), 'utf8'));
|
|
210
|
+
if (cfg.paths && cfg.paths.engineering) engineeringRoot = cfg.paths.engineering;
|
|
211
|
+
} catch (_) { /* fall back to default */ }
|
|
212
|
+
|
|
213
|
+
// Load task record
|
|
214
|
+
let taskRecord = null;
|
|
215
|
+
try {
|
|
216
|
+
const store = require('./store.cjs');
|
|
217
|
+
taskRecord = store.getTask(args.task);
|
|
218
|
+
} catch (_) {
|
|
219
|
+
// store.cjs not available or task not found — continue with substitutions from args
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Build substitutions (canonical entity-dir resolution — sprints/<id> or bugs)
|
|
223
|
+
const substitutions = buildSubstitutions({
|
|
224
|
+
taskRecord,
|
|
225
|
+
engineeringRoot,
|
|
226
|
+
entityId: args.task,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Load workflow markdown (scan .forge/workflows/ for phase)
|
|
230
|
+
const workflowMd = loadWorkflowMarkdown(args.phase);
|
|
231
|
+
if (!workflowMd) {
|
|
232
|
+
process.stderr.write(`postflight-gate: no outputs block defined for phase "${args.phase}" — skipping\n`);
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let outputs;
|
|
237
|
+
try {
|
|
238
|
+
outputs = parseOutputs(workflowMd);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
process.stderr.write(`postflight-gate: ${err.message}\n`);
|
|
241
|
+
process.exit(2);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!outputs[args.phase]) {
|
|
245
|
+
process.stderr.write(`postflight-gate: no outputs block for phase "${args.phase}" — skipping\n`);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build state from task record
|
|
250
|
+
const state = {};
|
|
251
|
+
if (taskRecord) state.task = taskRecord;
|
|
252
|
+
|
|
253
|
+
const result = postflight({ phase: args.phase, outputs, state, substitutions });
|
|
254
|
+
|
|
255
|
+
if (result.ok) process.exit(0);
|
|
256
|
+
|
|
257
|
+
process.stderr.write(`Postflight guard failed for phase "${args.phase}":\n`);
|
|
258
|
+
for (const m of result.missing) process.stderr.write(` - ${m}\n`);
|
|
259
|
+
|
|
260
|
+
// Emit structured JSON on stdout for orchestrators
|
|
261
|
+
process.stdout.write(JSON.stringify({
|
|
262
|
+
phase: args.phase,
|
|
263
|
+
reasonCode: result.reasonCode,
|
|
264
|
+
detail: result.detail,
|
|
265
|
+
remediation: result.remediation,
|
|
266
|
+
}) + '\n');
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parseArgs(argv) {
|
|
271
|
+
const out = {};
|
|
272
|
+
for (let i = 0; i < argv.length; i++) {
|
|
273
|
+
const a = argv[i];
|
|
274
|
+
if (a === '--phase') out.phase = argv[++i];
|
|
275
|
+
else if (a === '--task') out.task = argv[++i];
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function loadWorkflowMarkdown(phaseName) {
|
|
281
|
+
const workflowsDir = path.resolve(process.cwd(), '.forge/workflows');
|
|
282
|
+
let entries;
|
|
283
|
+
try {
|
|
284
|
+
entries = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md'));
|
|
285
|
+
} catch (_) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const fencePattern = new RegExp('^```outputs\\s+phase=' + escapeRegex(phaseName) + '\\s*$', 'm');
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const md = fs.readFileSync(path.join(workflowsDir, entry), 'utf8');
|
|
291
|
+
if (fencePattern.test(md)) return md;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function escapeRegex(s) {
|
|
297
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
298
|
+
}
|