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.
Files changed (56) hide show
  1. package/.agent/hooks/mindforge-statusline.js +2 -2
  2. package/.mindforge/config.json +13 -4
  3. package/CHANGELOG.md +101 -0
  4. package/MINDFORGE.md +3 -3
  5. package/RELEASENOTES.md +1 -1
  6. package/bin/autonomous/audit-writer.js +108 -86
  7. package/bin/autonomous/auto-runner.js +304 -19
  8. package/bin/autonomous/dependency-dag.js +59 -0
  9. package/bin/autonomous/wave-executor.js +20 -1
  10. package/bin/council-cli.js +161 -0
  11. package/bin/dashboard/approval-handler.js +3 -1
  12. package/bin/dashboard/server.js +1 -1
  13. package/bin/dashboard/sse-bridge.js +9 -12
  14. package/bin/engine/council-runtime.js +124 -0
  15. package/bin/engine/otel-exporter.js +123 -0
  16. package/bin/engine/remediation-engine.js +1 -1
  17. package/bin/engine/self-corrective-synthesizer.js +1 -1
  18. package/bin/engine/temporal-cli.js +4 -2
  19. package/bin/engine/verification-runner.js +131 -0
  20. package/bin/engine/verify-cli.js +34 -0
  21. package/bin/eval/eval-harness.js +82 -0
  22. package/bin/eval/golden-set-retrieval.json +46 -0
  23. package/bin/governance/audit-hash.js +12 -0
  24. package/bin/governance/audit-verifier.js +60 -0
  25. package/bin/governance/quantum-crypto.js +63 -9
  26. package/bin/governance/ztai-manager.js +30 -2
  27. package/bin/hindsight-injector.js +5 -6
  28. package/bin/hooks/instinct-capture-hook.js +186 -0
  29. package/bin/memory/auto-shadow.js +32 -3
  30. package/bin/memory/identity-synthesizer.js +2 -2
  31. package/bin/memory/knowledge-store.js +30 -6
  32. package/bin/memory/retrieval-fusion.js +58 -0
  33. package/bin/memory/semantic-hub.js +2 -2
  34. package/bin/memory/vector-hub.js +111 -6
  35. package/bin/mindforge-cli.js +4 -5
  36. package/bin/models/anthropic-provider.js +13 -4
  37. package/bin/models/cost-tracker.js +3 -1
  38. package/bin/models/difficulty-scorer.js +54 -0
  39. package/bin/models/gemini-provider.js +6 -2
  40. package/bin/models/model-router.js +31 -18
  41. package/bin/models/openai-provider.js +6 -3
  42. package/bin/models/pricing-registry.js +128 -0
  43. package/bin/review/ads-engine.js +1 -1
  44. package/bin/security/trust-boundaries.js +102 -0
  45. package/bin/security/trust-gate-hook.js +39 -0
  46. package/bin/skill-registry.js +3 -2
  47. package/bin/skills-builder/marketplace-cli.js +5 -3
  48. package/bin/skills-builder/skill-registrar.js +4 -6
  49. package/bin/sre/sentinel.js +7 -5
  50. package/bin/utils/append-queue.js +55 -0
  51. package/bin/utils/file-io.js +27 -37
  52. package/bin/utils/version-check.js +59 -0
  53. package/bin/verify-audit.js +12 -0
  54. package/bin/wizard/theme.js +1 -2
  55. package/package.json +1 -1
  56. 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 { createAuditWriter } = require('./audit-writer');
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
- this.waves = this._buildWaves(handoff.handoffs);
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
- const crypto = require('crypto');
367
- if (!event.id) event = Object.assign({}, event, { id: crypto.randomBytes(8).toString('hex') });
368
- if (!event.timestamp) event = Object.assign({}, event, { timestamp: new Date().toISOString() });
369
-
370
- // Synchronous write for backward compat (monitor needs immediate data)
371
- fs.appendFileSync(this.auditPath, JSON.stringify(event) + '\n');
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(event.event)) {
654
+ if (STATE_CHANGING_EVENTS.includes(stamped.event)) {
378
655
  _TemporalHub = lazyRequire(_TemporalHub, '../engine/temporal-hub');
379
- _TemporalHub.captureState(event.id, { agent: event.agent || 'auto-runner', event: event.event, phase: this.phase }).catch(() => {});
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(event);
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 = new _ZTAIManager();
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
- fs.appendFileSync(paths.audit, JSON.stringify(entry) + '\n');
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