mindforge-cc 11.0.0 → 11.2.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/.agent/hooks/mindforge-statusline.js +2 -2
- package/.mindforge/config.json +13 -4
- package/CHANGELOG.md +101 -0
- package/MINDFORGE.md +3 -3
- package/RELEASENOTES.md +1 -1
- package/bin/autonomous/audit-writer.js +108 -86
- package/bin/autonomous/auto-runner.js +304 -19
- package/bin/autonomous/dependency-dag.js +59 -0
- package/bin/autonomous/wave-executor.js +20 -1
- package/bin/council-cli.js +161 -0
- package/bin/dashboard/approval-handler.js +3 -1
- package/bin/dashboard/server.js +1 -1
- package/bin/dashboard/sse-bridge.js +9 -12
- package/bin/engine/council-runtime.js +124 -0
- package/bin/engine/otel-exporter.js +123 -0
- package/bin/engine/remediation-engine.js +1 -1
- package/bin/engine/self-corrective-synthesizer.js +1 -1
- package/bin/engine/temporal-cli.js +4 -2
- package/bin/engine/verification-runner.js +131 -0
- package/bin/engine/verify-cli.js +34 -0
- package/bin/eval/eval-harness.js +82 -0
- package/bin/eval/golden-set-retrieval.json +46 -0
- package/bin/governance/audit-hash.js +12 -0
- package/bin/governance/audit-verifier.js +60 -0
- package/bin/governance/quantum-crypto.js +63 -9
- package/bin/governance/ztai-manager.js +30 -2
- package/bin/hindsight-injector.js +5 -6
- package/bin/hooks/instinct-capture-hook.js +186 -0
- package/bin/memory/auto-shadow.js +32 -3
- package/bin/memory/identity-synthesizer.js +2 -2
- package/bin/memory/knowledge-store.js +30 -6
- package/bin/memory/retrieval-fusion.js +58 -0
- package/bin/memory/semantic-hub.js +2 -2
- package/bin/memory/vector-hub.js +111 -6
- package/bin/mindforge-cli.js +4 -5
- package/bin/models/anthropic-provider.js +13 -4
- package/bin/models/cost-tracker.js +3 -1
- package/bin/models/difficulty-scorer.js +54 -0
- package/bin/models/gemini-provider.js +6 -2
- package/bin/models/model-router.js +31 -18
- package/bin/models/openai-provider.js +6 -3
- package/bin/models/pricing-registry.js +128 -0
- package/bin/review/ads-engine.js +1 -1
- package/bin/security/trust-boundaries.js +102 -0
- package/bin/security/trust-gate-hook.js +39 -0
- package/bin/skill-registry.js +3 -2
- package/bin/skills-builder/marketplace-cli.js +5 -3
- package/bin/skills-builder/skill-registrar.js +4 -6
- package/bin/sre/sentinel.js +7 -5
- package/bin/utils/append-queue.js +55 -0
- package/bin/utils/file-io.js +27 -37
- package/bin/utils/version-check.js +59 -0
- package/bin/verify-audit.js +12 -0
- package/bin/wizard/theme.js +1 -2
- package/package.json +1 -1
- package/bin/dashboard/team-tracker.js +0 -0
|
@@ -19,7 +19,7 @@ const progressStream = require('./progress-stream');
|
|
|
19
19
|
const ContextRefactorer = require('./context-refactorer');
|
|
20
20
|
|
|
21
21
|
// Extracted modules (lightweight, always needed)
|
|
22
|
-
const {
|
|
22
|
+
const { appendAuditEntrySync } = require('./audit-writer');
|
|
23
23
|
const { createStateManager } = require('./state-manager');
|
|
24
24
|
const { createWaveExecutor, Semaphore } = require('./wave-executor');
|
|
25
25
|
|
|
@@ -44,6 +44,68 @@ function lazyRequire(cached, modulePath) {
|
|
|
44
44
|
return cached;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* UC-14: Pure timeout predicate evaluated at wave boundaries.
|
|
49
|
+
*
|
|
50
|
+
* Three cases:
|
|
51
|
+
* 1. falsy `timeoutAt` (null / undefined / '') → NO timeout is configured →
|
|
52
|
+
* false. Autonomous mode never times out in that case.
|
|
53
|
+
* 2. truthy but UNPARSEABLE deadline (`Date.parse` → NaN, e.g. 'garbage') → a
|
|
54
|
+
* MALFORMED deadline. We FAIL CLOSED: warn to stderr AND return true (treat
|
|
55
|
+
* as expired). For a stability/bounding feature, a broken deadline silently
|
|
56
|
+
* never firing would let a run proceed UNBOUNDED — the wrong direction. A
|
|
57
|
+
* malformed deadline must be VISIBLE and must HALT, not silently fail open.
|
|
58
|
+
* 3. valid date → `now > parsed` (timed out once `now` strictly exceeds it).
|
|
59
|
+
*
|
|
60
|
+
* `Date.now()` is the sane default for a RUNTIME comparison (unlike the council
|
|
61
|
+
* UC, which avoided `Date.now()` only for deterministic/resumable FILENAMES — a
|
|
62
|
+
* wall-clock read here is exactly what a timeout check wants). Callers inside the
|
|
63
|
+
* run-loop should still pass an explicit `now` for testability/consistency within
|
|
64
|
+
* a single iteration.
|
|
65
|
+
*
|
|
66
|
+
* NOTE: this is otherwise a PURE function (used directly in tests). The only
|
|
67
|
+
* side-effect is the diagnostic stderr write on the malformed branch — never on
|
|
68
|
+
* the falsy or valid-date paths — so existing pure assertions are unaffected.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|null|undefined} timeoutAt — ISO-8601 deadline, or falsy for "no timeout"
|
|
71
|
+
* @param {number} [now=Date.now()] — Epoch ms to compare against
|
|
72
|
+
* @returns {boolean} true iff a deadline is set AND it has passed (or is malformed)
|
|
73
|
+
*/
|
|
74
|
+
function isTimedOut(timeoutAt, now = Date.now()) {
|
|
75
|
+
if (!timeoutAt) return false;
|
|
76
|
+
const parsed = Date.parse(timeoutAt);
|
|
77
|
+
if (Number.isNaN(parsed)) {
|
|
78
|
+
// Malformed deadline → fail CLOSED (visible + halt), never silently fail open.
|
|
79
|
+
process.stderr.write(`[auto-runner] malformed timeout_at "${timeoutAt}" — treating as expired to fail closed\n`);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return now > parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* UC-14: Pure decision for the opt-in terminal-ESCALATE rollback hook.
|
|
87
|
+
*
|
|
88
|
+
* SAFE BY DEFAULT: rollback-on-escalate is opt-in via
|
|
89
|
+
* `wave_execution.rollback_on_escalate` (default false). Even when enabled, an
|
|
90
|
+
* actual destructive `git reset` is gated on the runner tracking per-wave commit
|
|
91
|
+
* SHAs — which it does NOT today. So `gitReset` is always false until commit
|
|
92
|
+
* tracking exists; we only ever RECORD the rollback point.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} config — Parsed .mindforge/config.json (or {})
|
|
95
|
+
* @param {boolean} [hasCommitTracking=false] — Whether per-wave commit SHAs are tracked
|
|
96
|
+
* @returns {{ record: boolean, gitReset: boolean, reason: string }}
|
|
97
|
+
*/
|
|
98
|
+
function decideRollback(config, hasCommitTracking = false) {
|
|
99
|
+
const enabled = config?.wave_execution?.rollback_on_escalate === true;
|
|
100
|
+
if (!enabled) {
|
|
101
|
+
return { record: true, gitReset: false, reason: 'rollback_on_escalate disabled (default) — recording intent only' };
|
|
102
|
+
}
|
|
103
|
+
if (!hasCommitTracking) {
|
|
104
|
+
return { record: true, gitReset: false, reason: 'rollback_on_escalate enabled but per-wave commit tracking unavailable — recording intent only, actual git reset deferred' };
|
|
105
|
+
}
|
|
106
|
+
return { record: true, gitReset: true, reason: 'rollback_on_escalate enabled and commit tracking available' };
|
|
107
|
+
}
|
|
108
|
+
|
|
47
109
|
class AutoRunner {
|
|
48
110
|
constructor(options = {}) {
|
|
49
111
|
if (options.phase != null && !/^[a-zA-Z0-9_-]+$/.test(String(options.phase))) {
|
|
@@ -61,7 +123,6 @@ class AutoRunner {
|
|
|
61
123
|
|
|
62
124
|
// Extracted module instances
|
|
63
125
|
this.stateManager = createStateManager(planningDir);
|
|
64
|
-
this.auditWriter = createAuditWriter(this.auditPath);
|
|
65
126
|
this.waveExecutor = createWaveExecutor({
|
|
66
127
|
onTaskStart: ({ task, wave }) => this.writeAudit({ event: 'task_started', phase: this.phase, wave: wave + 1, task_id: task.id, task_name: task.name }),
|
|
67
128
|
onTaskComplete: ({ task, wave, duration_ms }) => this.writeAudit({ event: 'task_completed', phase: this.phase, wave: wave + 1, task_id: task.id, task_name: task.name, duration_ms }),
|
|
@@ -173,6 +234,10 @@ class AutoRunner {
|
|
|
173
234
|
|
|
174
235
|
while (await this.hasNextWave()) {
|
|
175
236
|
if (this.isPaused) break;
|
|
237
|
+
// UC-14: enforce the wave-boundary timeout BEFORE dispatching this wave.
|
|
238
|
+
// Re-read auto-state each iteration so an externally-updated timeout_at is
|
|
239
|
+
// honored; stop cleanly (status 'timeout' + resumable state) when passed.
|
|
240
|
+
if (this._enforceWaveTimeout()) return;
|
|
176
241
|
const permit = await this.evaluateWavePolicy();
|
|
177
242
|
if (!permit) { this.writeAudit({ event: 'auto_mode_denied', reason: 'Policy violation detected' }); break; }
|
|
178
243
|
const isReliable = await this.checkArbitrage();
|
|
@@ -188,10 +253,77 @@ class AutoRunner {
|
|
|
188
253
|
await this.complete();
|
|
189
254
|
}
|
|
190
255
|
|
|
256
|
+
/**
|
|
257
|
+
* UC-14: Wave-boundary timeout enforcement.
|
|
258
|
+
*
|
|
259
|
+
* Re-reads the current `timeout_at` from auto-state.json (V9 design field) and,
|
|
260
|
+
* if the deadline has passed, stops the run CLEANLY:
|
|
261
|
+
* 1. persists resumable state (currentWaveIndex + completedTasks) so a later
|
|
262
|
+
* `/mindforge:auto` resumes exactly where it left off,
|
|
263
|
+
* 2. sets status to 'timeout' (a VALID_STATUS), keeping the resumable fields,
|
|
264
|
+
* 3. writes an `auto_mode_timeout` audit event,
|
|
265
|
+
* 4. logs the resume command and returns true so the caller stops the loop.
|
|
266
|
+
*
|
|
267
|
+
* Returns false (no-op) when no timeout is set or the deadline hasn't passed.
|
|
268
|
+
* @returns {boolean} true iff the run timed out and the loop should stop
|
|
269
|
+
*/
|
|
270
|
+
_enforceWaveTimeout() {
|
|
271
|
+
const autoState = this.stateManager.getState();
|
|
272
|
+
if (!isTimedOut(autoState.timeout_at)) return false;
|
|
273
|
+
|
|
274
|
+
const waveNum = this.currentWaveIndex + 1;
|
|
275
|
+
|
|
276
|
+
// 1 + 2: persist resumable progress AND flip status to 'timeout' in one write.
|
|
277
|
+
this.updateState({
|
|
278
|
+
status: 'timeout',
|
|
279
|
+
currentWaveIndex: this.currentWaveIndex,
|
|
280
|
+
completedTasks: Array.from(this.completedTasks),
|
|
281
|
+
timedOutAt: new Date().toISOString(),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// 3: audit the clean stop.
|
|
285
|
+
this.writeAudit({
|
|
286
|
+
event: 'auto_mode_timeout',
|
|
287
|
+
phase: this.phase,
|
|
288
|
+
wave: waveNum,
|
|
289
|
+
timeout_at: autoState.timeout_at,
|
|
290
|
+
completed_tasks: this.completedTasks.size,
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 4: clear operator-facing message including the resume command.
|
|
295
|
+
console.warn(
|
|
296
|
+
`\n⏲️ TIMEOUT at wave boundary ${waveNum}/${this.waves.length} ` +
|
|
297
|
+
`(deadline ${autoState.timeout_at}). Stopped cleanly with ${this.completedTasks.size} task(s) completed.\n` +
|
|
298
|
+
` Resume with: /mindforge:auto --phase ${this.phase}`
|
|
299
|
+
);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
191
303
|
runPreFlight() {
|
|
192
304
|
console.log('🔍 Running pre-flight checks...');
|
|
305
|
+
|
|
306
|
+
// UC-01: fail closed on version drift before any wave executes
|
|
307
|
+
try {
|
|
308
|
+
const { assertVersionConsistency } = require('../utils/version-check');
|
|
309
|
+
assertVersionConsistency(process.cwd());
|
|
310
|
+
} catch (e) {
|
|
311
|
+
throw new Error(`[pre-flight] ${e.message}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
193
314
|
const handoff = this.stateManager.readHandoff();
|
|
194
|
-
|
|
315
|
+
|
|
316
|
+
// FIX 3.2: read config ONCE for the whole pre-flight path and thread the
|
|
317
|
+
// resolved useDag boolean into both _assertNoCycles and _buildWaves, instead
|
|
318
|
+
// of each method re-reading + re-parsing .mindforge/config.json.
|
|
319
|
+
const useDag = this._useDagMode();
|
|
320
|
+
|
|
321
|
+
// UC-03: when DAG ordering is enabled (opt-in via config), detect cycles
|
|
322
|
+
// BEFORE any wave executes and HALT LOUD. Default OFF — legacy behavior
|
|
323
|
+
// (single-wave / explicit .wave grouping) is untouched.
|
|
324
|
+
this._assertNoCycles(handoff.handoffs, useDag);
|
|
325
|
+
|
|
326
|
+
this.waves = this._buildWaves(handoff.handoffs, useDag);
|
|
195
327
|
this.currentWaveIndex = 0;
|
|
196
328
|
|
|
197
329
|
const savedState = this.stateManager.getState();
|
|
@@ -253,6 +385,9 @@ class AutoRunner {
|
|
|
253
385
|
const strategy = repairOperator.determineRepairStrategy({ planId: task.plan || taskId, phase: this.phase, attemptNumber: 1, errorOutput: error.message, isTier3Change: false, isOnCriticalPath: (task.depends_on || []).length > 0 });
|
|
254
386
|
if (strategy === 'ESCALATE') {
|
|
255
387
|
this.writeAudit({ event: 'auto_mode_escalated', reason: `Task ${taskId} unrecoverable` });
|
|
388
|
+
// UC-14: opt-in rollback hook on terminal ESCALATE. SAFE DEFAULT —
|
|
389
|
+
// records the rollback point only; never mutates git (see decideRollback).
|
|
390
|
+
this._recordRollbackPoint(waveNum, taskId);
|
|
256
391
|
this.isPaused = true;
|
|
257
392
|
return;
|
|
258
393
|
}
|
|
@@ -277,12 +412,158 @@ class AutoRunner {
|
|
|
277
412
|
return 3;
|
|
278
413
|
}
|
|
279
414
|
|
|
415
|
+
/**
|
|
416
|
+
* UC-14: Rollback-on-escalate is OPT-IN. Reads
|
|
417
|
+
* `wave_execution.rollback_on_escalate` from config; defaults to false so the
|
|
418
|
+
* SAFE behavior (record rollback intent only — NO git mutation) is the default.
|
|
419
|
+
* Even when true, an actual git reset is further gated on per-wave commit
|
|
420
|
+
* tracking, which the runner does not yet have (see decideRollback).
|
|
421
|
+
* @returns {boolean}
|
|
422
|
+
*/
|
|
423
|
+
_rollbackOnEscalate() {
|
|
424
|
+
try {
|
|
425
|
+
const configPath = path.join(process.cwd(), '.mindforge', 'config.json');
|
|
426
|
+
if (fs.existsSync(configPath)) {
|
|
427
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
428
|
+
return config.wave_execution?.rollback_on_escalate === true;
|
|
429
|
+
}
|
|
430
|
+
} catch (e) { /* Fall through to default */ }
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* UC-14: Records the rollback point on terminal ESCALATE WITHOUT mutating git.
|
|
436
|
+
*
|
|
437
|
+
* The runner does NOT track per-wave commit SHAs, so even with
|
|
438
|
+
* `rollback_on_escalate` enabled the actual `git reset --hard` is deferred —
|
|
439
|
+
* we never run a destructive reset against untracked commit boundaries. We
|
|
440
|
+
* emit a `rollback_point_recorded` audit event (and a clear log) recording the
|
|
441
|
+
* intended rollback wave so a human can act on it.
|
|
442
|
+
*
|
|
443
|
+
* @param {number} waveNum — 1-based wave number that hit terminal escalation
|
|
444
|
+
* @param {string} taskId — The task id whose repair was exhausted
|
|
445
|
+
*/
|
|
446
|
+
_recordRollbackPoint(waveNum, taskId) {
|
|
447
|
+
const config = (() => {
|
|
448
|
+
try {
|
|
449
|
+
const configPath = path.join(process.cwd(), '.mindforge', 'config.json');
|
|
450
|
+
if (fs.existsSync(configPath)) return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
451
|
+
} catch (e) { /* ignore — treat as no config */ }
|
|
452
|
+
return {};
|
|
453
|
+
})();
|
|
454
|
+
|
|
455
|
+
// hasCommitTracking is hard-false: no per-wave SHA tracking exists yet.
|
|
456
|
+
const decision = decideRollback(config, /* hasCommitTracking */ false);
|
|
457
|
+
|
|
458
|
+
// DRY: derive the flag from the `config` we ALREADY read above instead of
|
|
459
|
+
// calling _rollbackOnEscalate(), which would re-read + re-parse the SAME
|
|
460
|
+
// config.json a second time within this single escalation path.
|
|
461
|
+
const rollbackEnabled = config?.wave_execution?.rollback_on_escalate === true;
|
|
462
|
+
|
|
463
|
+
this.writeAudit({
|
|
464
|
+
event: 'rollback_point_recorded',
|
|
465
|
+
phase: this.phase,
|
|
466
|
+
wave: waveNum,
|
|
467
|
+
task_id: taskId,
|
|
468
|
+
commits: [], // none tracked yet — rollback target is the prior wave boundary
|
|
469
|
+
git_reset_performed: decision.gitReset,
|
|
470
|
+
reason: decision.reason,
|
|
471
|
+
timestamp: new Date().toISOString(),
|
|
472
|
+
});
|
|
473
|
+
console.warn(`↩️ ROLLBACK POINT recorded at wave ${waveNum} (task ${taskId}): ${decision.reason}`);
|
|
474
|
+
if (rollbackEnabled && !decision.gitReset) {
|
|
475
|
+
console.warn(' (rollback_on_escalate is ON, but actual git reset is deferred until per-wave commit tracking exists.)');
|
|
476
|
+
}
|
|
477
|
+
return decision;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* UC-03: DAG wave ordering is OPT-IN. Reads `wave_execution.use_dag` from
|
|
482
|
+
* config; defaults to false so legacy single-wave / explicit-.wave behavior
|
|
483
|
+
* is untouched. Enabling this reorders tasks by `depends_on` topology.
|
|
484
|
+
* @returns {boolean}
|
|
485
|
+
*/
|
|
486
|
+
_useDagMode() {
|
|
487
|
+
try {
|
|
488
|
+
const configPath = path.join(process.cwd(), '.mindforge', 'config.json');
|
|
489
|
+
if (fs.existsSync(configPath)) {
|
|
490
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
491
|
+
return config.wave_execution?.use_dag === true;
|
|
492
|
+
}
|
|
493
|
+
} catch (e) { /* Fall through to default */ }
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* UC-03: Pre-flight cycle detection. ONLY active when DAG mode is enabled
|
|
499
|
+
* (opt-in) AND no explicit numeric `.wave` field is present (DAG would be the
|
|
500
|
+
* active strategy). Throws `[pre-flight] Circular dependency...` to HALT LOUD
|
|
501
|
+
* before any wave executes — never warn-and-continue. No-op when DAG is off.
|
|
502
|
+
*
|
|
503
|
+
* FIX 1: the pre-flight graph must NOT differ from the graph planWaves
|
|
504
|
+
* actually executes. planWaves builds its graph from normalizeTask, which
|
|
505
|
+
* SYNTHESIZES a random id (`task_<rand>`) for any handoff lacking both id and
|
|
506
|
+
* task_id. A random id can't be matched between two separate calls, AND a task
|
|
507
|
+
* with no stable id can never be a `depends_on` TARGET (nothing can reference
|
|
508
|
+
* an id that doesn't exist until it's randomly minted). So we cycle-check the
|
|
509
|
+
* SUBSET of handoffs that carry a real, stable id (id || task_id) — a subset
|
|
510
|
+
* guaranteed consistent with execution — and log a warning for any id-less
|
|
511
|
+
* handoff excluded from the check.
|
|
512
|
+
*
|
|
513
|
+
* @param {Array} handoffs — Raw handoffs array from HANDOFF.json
|
|
514
|
+
* @param {boolean} [useDag] — Resolved DAG mode (threaded from runPreFlight to
|
|
515
|
+
* avoid re-reading config). Falls back to _useDagMode() for direct callers.
|
|
516
|
+
*/
|
|
517
|
+
_assertNoCycles(handoffs, useDag = this._useDagMode()) {
|
|
518
|
+
if (!Array.isArray(handoffs) || handoffs.length === 0) return;
|
|
519
|
+
if (!useDag) return;
|
|
520
|
+
|
|
521
|
+
// Explicit .wave field wins over DAG, so cycle check is irrelevant there.
|
|
522
|
+
const hasWaveField = handoffs.some(h => typeof h.wave === 'number');
|
|
523
|
+
if (hasWaveField) return;
|
|
524
|
+
|
|
525
|
+
// Only handoffs with a STABLE id participate in cycle-checking (see above).
|
|
526
|
+
const stable = handoffs.filter(h => h.id || h.task_id);
|
|
527
|
+
const idless = handoffs.length - stable.length;
|
|
528
|
+
if (idless > 0) {
|
|
529
|
+
console.warn(
|
|
530
|
+
`[pre-flight] ${idless} handoff(s) lack a stable id (id/task_id) and are ` +
|
|
531
|
+
'excluded from cycle-checking; an id-less task cannot be a dependency target.'
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (stable.length === 0) return;
|
|
535
|
+
|
|
536
|
+
const { buildGraph, groupIntoWaves } = require('./dependency-dag');
|
|
537
|
+
let graph;
|
|
538
|
+
try {
|
|
539
|
+
graph = buildGraph(stable.map(h => ({
|
|
540
|
+
id: h.id || h.task_id,
|
|
541
|
+
depends_on: Array.isArray(h.depends_on) ? h.depends_on : [],
|
|
542
|
+
})));
|
|
543
|
+
} catch (e) {
|
|
544
|
+
// Unknown-dependency reference — also a planning error; fail loud.
|
|
545
|
+
throw new Error(`[pre-flight] ${e.message}`);
|
|
546
|
+
}
|
|
547
|
+
// FIX 3.1: hasCircularDependency() is itself `try{groupIntoWaves}catch`, so
|
|
548
|
+
// running it then groupIntoWaves again ran Kahn 2-3 times and left an
|
|
549
|
+
// unreachable defensive throw. Run Kahn ONCE here; on throw, rethrow with the
|
|
550
|
+
// descriptive (circular OR unknown-dep) message prefixed for pre-flight.
|
|
551
|
+
try {
|
|
552
|
+
groupIntoWaves(graph);
|
|
553
|
+
} catch (e) {
|
|
554
|
+
throw new Error(`[pre-flight] ${e.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
280
558
|
/**
|
|
281
559
|
* Build wave groups from HANDOFF handoffs array.
|
|
282
560
|
* Kept as instance method for backward compatibility with tests.
|
|
561
|
+
* @param {Array} handoffs — Raw handoffs array from HANDOFF.json
|
|
562
|
+
* @param {boolean} [useDag] — Resolved DAG mode (threaded from runPreFlight).
|
|
563
|
+
* Falls back to _useDagMode() for direct callers (e.g. tests).
|
|
283
564
|
*/
|
|
284
|
-
_buildWaves(handoffs) {
|
|
285
|
-
return this.waveExecutor.planWaves(handoffs);
|
|
565
|
+
_buildWaves(handoffs, useDag = this._useDagMode()) {
|
|
566
|
+
return this.waveExecutor.planWaves(handoffs, { useDag });
|
|
286
567
|
}
|
|
287
568
|
|
|
288
569
|
async checkIntelligenceDrift() {
|
|
@@ -359,27 +640,23 @@ class AutoRunner {
|
|
|
359
640
|
} catch (e) { /* GC failure is non-critical */ }
|
|
360
641
|
|
|
361
642
|
this.writeAudit({ event: 'auto_mode_completed', timestamp: new Date().toISOString() });
|
|
362
|
-
await this.auditWriter.close();
|
|
363
643
|
}
|
|
364
644
|
|
|
365
645
|
writeAudit(event) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// Also buffer in async writer for future async consumers
|
|
374
|
-
this.auditWriter.write(event);
|
|
646
|
+
// UC-04b: single unified, hash-chained, synchronous-DURABLE append. The sync
|
|
647
|
+
// fsync'd write means in-process consumers (StuckMonitor reads the event
|
|
648
|
+
// object directly below; any file re-readers see it immediately) get durable
|
|
649
|
+
// data at once — replacing the old dual raw-appendFileSync + async-buffer write
|
|
650
|
+
// that left the live AUDIT.jsonl chain unverifiable.
|
|
651
|
+
const stamped = appendAuditEntrySync(this.auditPath, event);
|
|
375
652
|
|
|
376
653
|
const STATE_CHANGING_EVENTS = ['auto_mode_started', 'phase_planned', 'phase_execution_started', 'task_completed', 'hindsight_injected', 'auto_mode_completed'];
|
|
377
|
-
if (STATE_CHANGING_EVENTS.includes(
|
|
654
|
+
if (STATE_CHANGING_EVENTS.includes(stamped.event)) {
|
|
378
655
|
_TemporalHub = lazyRequire(_TemporalHub, '../engine/temporal-hub');
|
|
379
|
-
_TemporalHub.captureState(
|
|
656
|
+
_TemporalHub.captureState(stamped.id, { agent: stamped.agent || 'auto-runner', event: stamped.event, phase: this.phase }).catch(() => {});
|
|
380
657
|
}
|
|
381
658
|
|
|
382
|
-
const result = this.monitor.analyze(
|
|
659
|
+
const result = this.monitor.analyze(stamped);
|
|
383
660
|
if (result) this.handleStuck(result);
|
|
384
661
|
}
|
|
385
662
|
|
|
@@ -412,7 +689,7 @@ class AutoRunner {
|
|
|
412
689
|
|
|
413
690
|
async evaluateWavePolicy() {
|
|
414
691
|
_ZTAIManager = lazyRequire(_ZTAIManager, '../governance/ztai-manager');
|
|
415
|
-
const manager =
|
|
692
|
+
const manager = _ZTAIManager;
|
|
416
693
|
const identity = await manager.getIdentity();
|
|
417
694
|
const intent = { did: identity.did, action: 'process_phase_wave', resource: `projects/${process.env.MF_PROJECT_ID || 'MF-ALPHA'}/phases/${this.phase}/*`, tier: identity.tier || 1, metadata: { engine: 'Nimbus-S4', mode: 'autonomous', wave_timestamp: new Date().toISOString() } };
|
|
418
695
|
const result = this.policyEngine.evaluate(intent);
|
|
@@ -504,4 +781,12 @@ class AutoRunner {
|
|
|
504
781
|
}
|
|
505
782
|
}
|
|
506
783
|
|
|
784
|
+
// Primary export remains the AutoRunner class (back-compat with all callers/tests
|
|
785
|
+
// that do `const AutoRunner = require('./auto-runner')`). UC-14 pure helpers are
|
|
786
|
+
// attached as named properties so `require('./auto-runner').isTimedOut` works too.
|
|
787
|
+
AutoRunner.isTimedOut = isTimedOut;
|
|
788
|
+
AutoRunner.decideRollback = decideRollback;
|
|
789
|
+
|
|
507
790
|
module.exports = AutoRunner;
|
|
791
|
+
module.exports.isTimedOut = isTimedOut;
|
|
792
|
+
module.exports.decideRollback = decideRollback;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* MindForge — Dependency DAG (UC-03).
|
|
4
|
+
* Kahn topological sort + cycle detection.
|
|
5
|
+
* Ported from the previously test-only implementation into the real engine.
|
|
6
|
+
*
|
|
7
|
+
* TODO(UC-xx): same-wave file-conflict detection once tasks carry file lists.
|
|
8
|
+
* Handoff tasks (see normalizeTask in wave-executor.js and validateHandoff in
|
|
9
|
+
* state-manager.js) currently expose only id/name/plan/depends_on — there is
|
|
10
|
+
* no `files` field to compare. A `findFileConflicts(plans)` check (two tasks
|
|
11
|
+
* in the SAME wave writing the same file -> warn) cannot be wired meaningfully
|
|
12
|
+
* until the handoff schema captures per-task file lists, so it is intentionally
|
|
13
|
+
* not implemented here rather than left as dead exported surface.
|
|
14
|
+
*/
|
|
15
|
+
function groupIntoWaves(graph) {
|
|
16
|
+
// Self-defense: every dependency target must be a known graph node. Throw a
|
|
17
|
+
// DISTINCT "unknown dependency" error (not the misleading "Circular" message)
|
|
18
|
+
// so callers that invoke groupIntoWaves standalone still fail descriptively.
|
|
19
|
+
for (const id of Object.keys(graph)) {
|
|
20
|
+
const deps = graph[id].dependsOn || [];
|
|
21
|
+
for (const d of deps) {
|
|
22
|
+
if (!(d in graph)) {
|
|
23
|
+
throw new Error(`Unknown dependency "${d}" referenced by "${id}"`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const remaining = new Set(Object.keys(graph));
|
|
28
|
+
const completed = new Set();
|
|
29
|
+
const waves = [];
|
|
30
|
+
while (remaining.size > 0) {
|
|
31
|
+
const wave = [];
|
|
32
|
+
for (const id of remaining) {
|
|
33
|
+
const deps = graph[id].dependsOn || [];
|
|
34
|
+
if (deps.every(d => completed.has(d))) wave.push(id);
|
|
35
|
+
}
|
|
36
|
+
if (wave.length === 0 && remaining.size > 0) {
|
|
37
|
+
throw new Error(`Circular dependency detected among: ${[...remaining].join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
waves.push(wave.sort());
|
|
40
|
+
wave.forEach(id => { completed.add(id); remaining.delete(id); });
|
|
41
|
+
}
|
|
42
|
+
return waves;
|
|
43
|
+
}
|
|
44
|
+
function hasCircularDependency(graph) {
|
|
45
|
+
try { groupIntoWaves(graph); return false; } catch { return true; }
|
|
46
|
+
}
|
|
47
|
+
function buildGraph(tasks) {
|
|
48
|
+
const ids = new Set(tasks.map(t => t.id));
|
|
49
|
+
const graph = {};
|
|
50
|
+
for (const t of tasks) {
|
|
51
|
+
const deps = Array.isArray(t.depends_on) ? t.depends_on : [];
|
|
52
|
+
for (const d of deps) {
|
|
53
|
+
if (!ids.has(d)) throw new Error(`Task "${t.id}" depends on unknown task "${d}"`);
|
|
54
|
+
}
|
|
55
|
+
graph[t.id] = { dependsOn: deps };
|
|
56
|
+
}
|
|
57
|
+
return graph;
|
|
58
|
+
}
|
|
59
|
+
module.exports = { groupIntoWaves, hasCircularDependency, buildGraph };
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
|
+
const { buildGraph, groupIntoWaves } = require('./dependency-dag');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Semaphore for bounding concurrency within a wave.
|
|
@@ -64,10 +65,19 @@ function createWaveExecutor(config = {}) {
|
|
|
64
65
|
/**
|
|
65
66
|
* Groups handoff tasks into sequential waves based on wave field or dependency topology.
|
|
66
67
|
* Returns a new array of wave objects — does not mutate input.
|
|
68
|
+
*
|
|
69
|
+
* Resolution order (UC-03):
|
|
70
|
+
* 1. Explicit numeric `.wave` field ALWAYS wins (legacy behavior, unchanged).
|
|
71
|
+
* 2. Else if `options.useDag === true` (OPT-IN), order by `depends_on` via Kahn
|
|
72
|
+
* topological sort. Halts loud (throws) on cycles or unknown dependencies.
|
|
73
|
+
* 3. Else (legacy default), all tasks in a single parallel wave (unchanged).
|
|
74
|
+
*
|
|
75
|
+
* DAG ordering is OPT-IN to avoid silently reordering existing PLAN files.
|
|
67
76
|
* @param {Array} handoffs — Raw handoffs array from HANDOFF.json
|
|
77
|
+
* @param {object} [options] — { useDag?: boolean }
|
|
68
78
|
* @returns {Array<{ wave: number, tasks: Array }>}
|
|
69
79
|
*/
|
|
70
|
-
function planWaves(handoffs) {
|
|
80
|
+
function planWaves(handoffs, options = {}) {
|
|
71
81
|
if (!Array.isArray(handoffs) || handoffs.length === 0) {
|
|
72
82
|
waves = [];
|
|
73
83
|
return [];
|
|
@@ -86,6 +96,15 @@ function createWaveExecutor(config = {}) {
|
|
|
86
96
|
waves = Array.from(byWave.entries())
|
|
87
97
|
.sort((a, b) => a[0] - b[0])
|
|
88
98
|
.map(([waveNum, tasks]) => Object.freeze({ wave: waveNum, tasks: Object.freeze(tasks) }));
|
|
99
|
+
} else if (options.useDag === true) {
|
|
100
|
+
const normalized = handoffs.map(normalizeTask);
|
|
101
|
+
const graph = buildGraph(normalized);
|
|
102
|
+
const waveIds = groupIntoWaves(graph);
|
|
103
|
+
const byId = new Map(normalized.map(t => [t.id, t]));
|
|
104
|
+
waves = waveIds.map((ids, i) => Object.freeze({
|
|
105
|
+
wave: i,
|
|
106
|
+
tasks: Object.freeze(ids.map(id => byId.get(id))),
|
|
107
|
+
}));
|
|
89
108
|
} else {
|
|
90
109
|
// Single wave with all tasks
|
|
91
110
|
waves = [Object.freeze({
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* MindForge — Council CLI (UC-22)
|
|
5
|
+
*
|
|
6
|
+
* Thin CLI wrapper around council-runtime.runCouncil. Provides the injectable
|
|
7
|
+
* model function via ModelClient and formats structured output for the
|
|
8
|
+
* /mindforge:council command.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node bin/council-cli.js "Should we adopt event sourcing for the payments domain?"
|
|
12
|
+
* node bin/council-cli.js --id payment-es "Should we adopt event sourcing?"
|
|
13
|
+
*
|
|
14
|
+
* Exit codes:
|
|
15
|
+
* 0 — PROCEED
|
|
16
|
+
* 1 — REVISE
|
|
17
|
+
* 2 — NO_CONSENSUS
|
|
18
|
+
* 3 — Runtime error
|
|
19
|
+
*/
|
|
20
|
+
const { runCouncil } = require('./engine/council-runtime');
|
|
21
|
+
const ModelClient = require('./models/model-client');
|
|
22
|
+
|
|
23
|
+
const VOICE_SYSTEM_PROMPTS = {
|
|
24
|
+
architect: 'You are the Architect voice in a decision council. You focus on system design, scalability, maintainability, and long-term architectural integrity. Evaluate the decision from a structural perspective.',
|
|
25
|
+
skeptic: 'You are the Skeptic voice in a decision council. You challenge assumptions, identify risks, hidden costs, and failure modes. Your job is to stress-test the proposal.',
|
|
26
|
+
pragmatist: 'You are the Pragmatist voice in a decision council. You focus on delivery timelines, team capacity, incremental value, and practical trade-offs. Favor what ships reliably.',
|
|
27
|
+
critic: 'You are the Critic voice in a decision council. You evaluate quality, correctness, edge cases, and whether the solution meets its stated goals without over-engineering.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const POSITION_INSTRUCTION = `
|
|
31
|
+
Respond with ONLY a JSON object (no markdown fences, no prose) in this exact shape:
|
|
32
|
+
{
|
|
33
|
+
"recommendation": "PROCEED" or "REVISE",
|
|
34
|
+
"confidence": <number between 0 and 1>,
|
|
35
|
+
"rationale": "<1-3 sentence explanation>"
|
|
36
|
+
}
|
|
37
|
+
Do NOT include any text outside the JSON object.`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Injectable model function for runCouncil — calls ModelClient.complete per voice.
|
|
41
|
+
*/
|
|
42
|
+
async function councilModel({ voice, question }) {
|
|
43
|
+
const systemPrompt = (VOICE_SYSTEM_PROMPTS[voice] || VOICE_SYSTEM_PROMPTS.architect) +
|
|
44
|
+
'\n' + POSITION_INSTRUCTION;
|
|
45
|
+
|
|
46
|
+
const result = await ModelClient.complete({
|
|
47
|
+
persona: 'council',
|
|
48
|
+
tier: 2,
|
|
49
|
+
systemPrompt,
|
|
50
|
+
userMessage: `Decision under review:\n${question}`,
|
|
51
|
+
maxTokens: 300,
|
|
52
|
+
temperature: 0.4,
|
|
53
|
+
taskName: `council-${voice}`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Parse the JSON response from the model
|
|
57
|
+
const content = (result.content || '').trim();
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
// Strip markdown fences if model adds them despite instructions
|
|
61
|
+
const cleaned = content.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
62
|
+
parsed = JSON.parse(cleaned);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Council voice "${voice}" returned unparseable response: ${content.slice(0, 200)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
recommendation: parsed.recommendation,
|
|
69
|
+
confidence: parsed.confidence,
|
|
70
|
+
rationale: parsed.rationale,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- CLI argument parsing ---
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const args = argv.slice(2);
|
|
77
|
+
const opts = { question: null, decisionId: null };
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
if (args[i] === '--id' && args[i + 1]) {
|
|
81
|
+
opts.decisionId = args[++i];
|
|
82
|
+
} else if (!args[i].startsWith('-')) {
|
|
83
|
+
opts.question = opts.question ? `${opts.question} ${args[i]}` : args[i];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return opts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Formatted output ---
|
|
90
|
+
function formatOutput(result) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push('=== COUNCIL VERDICT ===');
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push(`Question: ${result.question}`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('--- Positions ---');
|
|
98
|
+
for (const pos of result.positions) {
|
|
99
|
+
const icon = pos.recommendation === 'PROCEED' ? '[+]' : '[-]';
|
|
100
|
+
lines.push(` ${icon} ${pos.voice.toUpperCase()} (${pos.recommendation}, confidence: ${pos.confidence.toFixed(2)})`);
|
|
101
|
+
lines.push(` ${pos.rationale}`);
|
|
102
|
+
}
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(`--- Consensus: ${(result.consensus * 100).toFixed(1)}% ---`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push(`VERDICT: ${result.verdict}`);
|
|
107
|
+
|
|
108
|
+
if (result.verdict === 'NO_CONSENSUS' && result.dissent.length > 0) {
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push('--- Dissent (full split) ---');
|
|
111
|
+
for (const d of result.dissent) {
|
|
112
|
+
lines.push(` * ${d.voice.toUpperCase()} (${d.recommendation}): ${d.rationale}`);
|
|
113
|
+
}
|
|
114
|
+
} else if (result.dissent.length > 0) {
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push('--- Dissent ---');
|
|
117
|
+
for (const d of result.dissent) {
|
|
118
|
+
lines.push(` * ${d.voice.toUpperCase()}: ${d.rationale}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('Council is advisory -- you have final say.');
|
|
124
|
+
lines.push('');
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Main ---
|
|
129
|
+
async function main() {
|
|
130
|
+
const { question, decisionId } = parseArgs(process.argv);
|
|
131
|
+
|
|
132
|
+
if (!question) {
|
|
133
|
+
process.stderr.write('Usage: node bin/council-cli.js [--id <decision-id>] "<question>"\n');
|
|
134
|
+
process.exit(3);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await runCouncil(question, {
|
|
139
|
+
model: councilModel,
|
|
140
|
+
writeDecision: true,
|
|
141
|
+
decisionId: decisionId || undefined,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Output structured JSON to stdout for programmatic consumption
|
|
145
|
+
console.log(JSON.stringify(result, null, 2));
|
|
146
|
+
|
|
147
|
+
// Output formatted human-readable summary to stderr
|
|
148
|
+
process.stderr.write(formatOutput(result));
|
|
149
|
+
|
|
150
|
+
// Exit code reflects verdict
|
|
151
|
+
const exitCode = result.verdict === 'PROCEED' ? 0
|
|
152
|
+
: result.verdict === 'REVISE' ? 1
|
|
153
|
+
: 2;
|
|
154
|
+
process.exit(exitCode);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
process.stderr.write(`[council-cli] ERROR: ${err.message}\n`);
|
|
157
|
+
process.exit(3);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
main();
|
|
@@ -115,7 +115,9 @@ function writeAuditEntry(entry) {
|
|
|
115
115
|
try {
|
|
116
116
|
const paths = getPaths();
|
|
117
117
|
if (!fs.existsSync(path.dirname(paths.audit))) return;
|
|
118
|
-
|
|
118
|
+
// UC-04b: unified, hash-chained, durable append into the single verifiable chain.
|
|
119
|
+
const { appendAuditEntrySync } = require('../autonomous/audit-writer');
|
|
120
|
+
appendAuditEntrySync(paths.audit, entry);
|
|
119
121
|
} catch { /* ignore AUDIT write failures */ }
|
|
120
122
|
}
|
|
121
123
|
|