mindforge-cc 10.7.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 (85) hide show
  1. package/.agent/hooks/mindforge-statusline.js +2 -2
  2. package/.mindforge/MINDFORGE-V2-SCHEMA.json +43 -10
  3. package/.mindforge/config.json +18 -4
  4. package/CHANGELOG.md +165 -0
  5. package/MINDFORGE.md +3 -3
  6. package/README.md +49 -4
  7. package/RELEASENOTES.md +81 -1
  8. package/SECURITY.md +20 -8
  9. package/bin/autonomous/audit-writer.js +105 -70
  10. package/bin/autonomous/auto-runner.js +377 -34
  11. package/bin/autonomous/context-refactorer.js +26 -11
  12. package/bin/autonomous/dependency-dag.js +59 -0
  13. package/bin/autonomous/state-manager.js +62 -6
  14. package/bin/autonomous/stuck-monitor.js +46 -7
  15. package/bin/autonomous/wave-executor.js +86 -26
  16. package/bin/council-cli.js +161 -0
  17. package/bin/dashboard/api-router.js +43 -0
  18. package/bin/dashboard/approval-handler.js +3 -1
  19. package/bin/dashboard/metrics-aggregator.js +28 -1
  20. package/bin/dashboard/server.js +68 -5
  21. package/bin/dashboard/sse-bridge.js +10 -13
  22. package/bin/engine/council-runtime.js +124 -0
  23. package/bin/engine/feedback-loop.js +8 -0
  24. package/bin/engine/intelligence-interlock.js +32 -15
  25. package/bin/engine/logic-drift-detector.js +2 -1
  26. package/bin/engine/nexus-tracer.js +3 -2
  27. package/bin/engine/otel-exporter.js +123 -0
  28. package/bin/engine/remediation-engine.js +155 -32
  29. package/bin/engine/self-corrective-synthesizer.js +84 -10
  30. package/bin/engine/sre-manager.js +12 -4
  31. package/bin/engine/temporal-cli.js +4 -2
  32. package/bin/engine/temporal-hub.js +131 -34
  33. package/bin/engine/verification-runner.js +131 -0
  34. package/bin/engine/verify-cli.js +34 -0
  35. package/bin/eval/eval-harness.js +82 -0
  36. package/bin/eval/golden-set-retrieval.json +46 -0
  37. package/bin/governance/approve.js +41 -5
  38. package/bin/governance/audit-hash.js +12 -0
  39. package/bin/governance/audit-verifier.js +60 -0
  40. package/bin/governance/impact-analyzer.js +28 -0
  41. package/bin/governance/policy-engine.js +10 -3
  42. package/bin/governance/quantum-crypto.js +95 -28
  43. package/bin/governance/rbac-manager.js +74 -2
  44. package/bin/governance/ztai-manager.js +79 -9
  45. package/bin/hindsight-injector.js +8 -9
  46. package/bin/hooks/instinct-capture-hook.js +186 -0
  47. package/bin/memory/auto-shadow.js +32 -3
  48. package/bin/memory/eis-client.js +71 -34
  49. package/bin/memory/embedding-engine.js +61 -0
  50. package/bin/memory/identity-synthesizer.js +2 -2
  51. package/bin/memory/knowledge-graph.js +58 -5
  52. package/bin/memory/knowledge-indexer.js +53 -6
  53. package/bin/memory/knowledge-store.js +52 -6
  54. package/bin/memory/retrieval-fusion.js +58 -0
  55. package/bin/memory/semantic-hub.js +2 -2
  56. package/bin/memory/vector-hub.js +111 -6
  57. package/bin/migrations/10.7.0-to-11.0.0.js +110 -0
  58. package/bin/migrations/schema-versions.js +13 -0
  59. package/bin/mindforge-cli.js +4 -5
  60. package/bin/models/anthropic-provider.js +58 -4
  61. package/bin/models/cloud-broker.js +68 -20
  62. package/bin/models/cost-tracker.js +3 -1
  63. package/bin/models/difficulty-scorer.js +54 -0
  64. package/bin/models/gemini-provider.js +57 -2
  65. package/bin/models/model-client.js +20 -0
  66. package/bin/models/model-router.js +59 -26
  67. package/bin/models/openai-provider.js +50 -3
  68. package/bin/models/pricing-registry.js +128 -0
  69. package/bin/review/ads-engine.js +1 -1
  70. package/bin/security/trust-boundaries.js +102 -0
  71. package/bin/security/trust-gate-hook.js +39 -0
  72. package/bin/skill-registry.js +3 -2
  73. package/bin/skills-builder/marketplace-cli.js +5 -3
  74. package/bin/skills-builder/skill-registrar.js +4 -6
  75. package/bin/sre/sentinel.js +7 -5
  76. package/bin/utils/append-queue.js +55 -0
  77. package/bin/utils/file-io.js +90 -38
  78. package/bin/utils/index.js +58 -0
  79. package/bin/utils/version-check.js +59 -0
  80. package/bin/verify-audit.js +12 -0
  81. package/bin/wizard/theme.js +1 -2
  82. package/docs/getting-started.md +1 -1
  83. package/docs/user-guide.md +2 -2
  84. package/package.json +2 -2
  85. package/bin/dashboard/team-tracker.js +0 -0
@@ -19,9 +19,9 @@ 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
- const { createWaveExecutor } = require('./wave-executor');
24
+ const { createWaveExecutor, Semaphore } = require('./wave-executor');
25
25
 
26
26
  // ── Lazy-loaded heavy modules ────────────────────────────────────────────────
27
27
  // These are only required at the point of first use to reduce startup cost.
@@ -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();
@@ -218,24 +350,47 @@ class AutoRunner {
218
350
  const wave = this.waves[this.currentWaveIndex];
219
351
  const waveNum = this.currentWaveIndex + 1;
220
352
  const pending = wave.tasks.filter(t => !this.completedTasks.has(t.id));
353
+ const maxConcurrency = this._getWaveConcurrency();
221
354
 
222
- console.log(`\n⚡ Wave ${waveNum}/${this.waves.length}: ${pending.length} tasks`);
355
+ console.log(`\n⚡ Wave ${waveNum}/${this.waves.length}: ${pending.length} tasks (concurrency: ${maxConcurrency})`);
223
356
  if (idcStatus.action === 'UPGRADE_MIR') console.log(` [IDC-ACTIVE] MIR Override: ${idcStatus.new_mir}`);
224
357
  this.writeAudit({ event: 'wave_started', phase: this.phase, wave: waveNum, task_count: pending.length });
225
358
 
226
- for (const task of pending) {
227
- const taskStart = Date.now();
228
- console.log(` → Task: ${task.name || task.id}`);
229
- try {
230
- this.writeAudit({ event: 'task_started', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id });
231
- this.writeAudit({ event: 'task_completed', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id, duration_ms: Date.now() - taskStart });
232
- this.completedTasks.add(task.id);
233
- } catch (err) {
234
- console.error(` Task failed: ${task.id} ${err.message}`);
235
- this.writeAudit({ event: 'task_failed', phase: this.phase, wave: waveNum, task_id: task.id, error: err.message, duration_ms: Date.now() - taskStart });
236
- const strategy = repairOperator.determineRepairStrategy({ planId: task.plan || task.id, phase: this.phase, attemptNumber: 1, errorOutput: err.message, isTier3Change: false, isOnCriticalPath: (task.depends_on || []).length > 0 });
237
- if (strategy === 'RETRY') { console.log(` Repair: retrying ${task.id}`); continue; }
238
- if (strategy === 'ESCALATE') { this.writeAudit({ event: 'auto_mode_escalated', reason: `Task ${task.id} unrecoverable` }); this.isPaused = true; return; }
359
+ const semaphore = new Semaphore(maxConcurrency);
360
+
361
+ const settled = await Promise.allSettled(
362
+ pending.map(async (task) => {
363
+ await semaphore.acquire();
364
+ const taskStart = Date.now();
365
+ console.log(` → Task: ${task.name || task.id}`);
366
+ try {
367
+ this.writeAudit({ event: 'task_started', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id });
368
+ this.writeAudit({ event: 'task_completed', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id, duration_ms: Date.now() - taskStart });
369
+ this.completedTasks.add(task.id);
370
+ return { taskId: task.id, status: 'fulfilled' };
371
+ } catch (err) {
372
+ console.error(` Task failed: ${task.id} — ${err.message}`);
373
+ this.writeAudit({ event: 'task_failed', phase: this.phase, wave: waveNum, task_id: task.id, error: err.message, duration_ms: Date.now() - taskStart });
374
+ throw { taskId: task.id, error: err, task };
375
+ } finally {
376
+ semaphore.release();
377
+ }
378
+ })
379
+ );
380
+
381
+ const failures = settled.filter(r => r.status === 'rejected');
382
+ if (failures.length > 0) {
383
+ for (const failure of failures) {
384
+ const { taskId, error, task } = failure.reason;
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 });
386
+ if (strategy === 'ESCALATE') {
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);
391
+ this.isPaused = true;
392
+ return;
393
+ }
239
394
  }
240
395
  }
241
396
 
@@ -244,12 +399,171 @@ class AutoRunner {
244
399
  this.currentWaveIndex++;
245
400
  }
246
401
 
402
+ _getWaveConcurrency() {
403
+ try {
404
+ const configPath = path.join(process.cwd(), '.mindforge', 'config.json');
405
+ if (fs.existsSync(configPath)) {
406
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
407
+ if (typeof config.wave_concurrency === 'number' && config.wave_concurrency > 0) {
408
+ return config.wave_concurrency;
409
+ }
410
+ }
411
+ } catch (e) { /* Fall through to default */ }
412
+ return 3;
413
+ }
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
+
247
558
  /**
248
559
  * Build wave groups from HANDOFF handoffs array.
249
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).
250
564
  */
251
- _buildWaves(handoffs) {
252
- return this.waveExecutor.planWaves(handoffs);
565
+ _buildWaves(handoffs, useDag = this._useDagMode()) {
566
+ return this.waveExecutor.planWaves(handoffs, { useDag });
253
567
  }
254
568
 
255
569
  async checkIntelligenceDrift() {
@@ -316,28 +630,33 @@ class AutoRunner {
316
630
  if (captured.length + stability.length > 0) console.log(`🧠 Knowledge Graph: Captured ${captured.length + stability.length} insights.`);
317
631
  } catch (err) { console.error('⚠️ Knowledge Capture failed:', err.message); }
318
632
 
633
+ try {
634
+ _TemporalHub = lazyRequire(_TemporalHub, '../engine/temporal-hub');
635
+ const gcConfig = this._loadTemporalGcConfig();
636
+ const gcResult = await _TemporalHub.gc(gcConfig);
637
+ if (gcResult.deleted > 0) {
638
+ this.writeAudit({ event: 'temporal_gc_completed', deleted: gcResult.deleted, remaining: gcResult.remaining });
639
+ }
640
+ } catch (e) { /* GC failure is non-critical */ }
641
+
319
642
  this.writeAudit({ event: 'auto_mode_completed', timestamp: new Date().toISOString() });
320
- await this.auditWriter.close();
321
643
  }
322
644
 
323
645
  writeAudit(event) {
324
- const crypto = require('crypto');
325
- if (!event.id) event = Object.assign({}, event, { id: crypto.randomBytes(8).toString('hex') });
326
- if (!event.timestamp) event = Object.assign({}, event, { timestamp: new Date().toISOString() });
327
-
328
- // Synchronous write for backward compat (monitor needs immediate data)
329
- fs.appendFileSync(this.auditPath, JSON.stringify(event) + '\n');
330
-
331
- // Also buffer in async writer for future async consumers
332
- 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);
333
652
 
334
653
  const STATE_CHANGING_EVENTS = ['auto_mode_started', 'phase_planned', 'phase_execution_started', 'task_completed', 'hindsight_injected', 'auto_mode_completed'];
335
- if (STATE_CHANGING_EVENTS.includes(event.event)) {
654
+ if (STATE_CHANGING_EVENTS.includes(stamped.event)) {
336
655
  _TemporalHub = lazyRequire(_TemporalHub, '../engine/temporal-hub');
337
- _TemporalHub.captureState(event.id, { agent: event.agent || 'auto-runner', event: event.event, phase: this.phase });
656
+ _TemporalHub.captureState(stamped.id, { agent: stamped.agent || 'auto-runner', event: stamped.event, phase: this.phase }).catch(() => {});
338
657
  }
339
658
 
340
- const result = this.monitor.analyze(event);
659
+ const result = this.monitor.analyze(stamped);
341
660
  if (result) this.handleStuck(result);
342
661
  }
343
662
 
@@ -370,7 +689,7 @@ class AutoRunner {
370
689
 
371
690
  async evaluateWavePolicy() {
372
691
  _ZTAIManager = lazyRequire(_ZTAIManager, '../governance/ztai-manager');
373
- const manager = new _ZTAIManager();
692
+ const manager = _ZTAIManager;
374
693
  const identity = await manager.getIdentity();
375
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() } };
376
695
  const result = this.policyEngine.evaluate(intent);
@@ -444,6 +763,30 @@ class AutoRunner {
444
763
  const lines = buf.toString('utf8').trim().split('\n');
445
764
  return lines.slice(-count).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
446
765
  }
766
+
767
+ _loadTemporalGcConfig() {
768
+ try {
769
+ const configPath = path.join(process.cwd(), '.mindforge', 'config.json');
770
+ if (fs.existsSync(configPath)) {
771
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
772
+ if (config.temporal) {
773
+ return {
774
+ maxSnapshots: config.temporal.max_snapshots || 50,
775
+ maxAgeDays: config.temporal.max_age_days || 7
776
+ };
777
+ }
778
+ }
779
+ } catch (e) { /* Fall through to defaults */ }
780
+ return { maxSnapshots: 50, maxAgeDays: 7 };
781
+ }
447
782
  }
448
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
+
449
790
  module.exports = AutoRunner;
791
+ module.exports.isTimedOut = isTimedOut;
792
+ module.exports.decideRollback = decideRollback;
@@ -14,18 +14,15 @@ class ContextRefactorer {
14
14
  this.history = [];
15
15
  }
16
16
 
17
- /**
18
- * Analyze the current context density.
19
- * Density = (Implementation Events) / (Total Events)
20
- */
21
17
  analyzeDensity(events) {
22
- this.history = events.slice(-this.windowSize);
23
-
24
- if (this.history.length < 5) return { density: 1.0, shouldRefactor: false };
18
+ const windowSize = this._getAdaptiveWindow(events);
19
+ this.history = events.slice(-windowSize);
25
20
 
26
- const implementationEvents = this.history.filter(h =>
27
- h.tool === 'run_command' ||
28
- h.tool === 'replace_file_content' ||
21
+ if (this.history.length < 5) return { density: 1.0, shouldRefactor: false, windowSize };
22
+
23
+ const implementationEvents = this.history.filter(h =>
24
+ h.tool === 'run_command' ||
25
+ h.tool === 'replace_file_content' ||
29
26
  h.tool === 'multi_replace_file_content' ||
30
27
  h.event === 'task_completed'
31
28
  );
@@ -35,10 +32,28 @@ class ContextRefactorer {
35
32
 
36
33
  return {
37
34
  density: parseFloat(density.toFixed(2)),
38
- shouldRefactor: density < this.threshold
35
+ shouldRefactor: density < this.threshold,
36
+ windowSize
39
37
  };
40
38
  }
41
39
 
40
+ _getAdaptiveWindow(events) {
41
+ const recent = events.slice(-10);
42
+ if (recent.length === 0) return 20;
43
+
44
+ const implEvents = recent.filter(e =>
45
+ e.event === 'run_command' ||
46
+ e.event?.includes('replace_file') ||
47
+ e.event === 'task_completed'
48
+ ).length;
49
+
50
+ const velocity = implEvents / recent.length;
51
+
52
+ if (velocity > 0.6) return 10;
53
+ if (velocity < 0.3) return 30;
54
+ return 20;
55
+ }
56
+
42
57
  /**
43
58
  * Generates a "Context Refactor" recommendation.
44
59
  */
@@ -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 };