mindforge-cc 11.0.0 → 11.2.1
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 +14 -4
- package/CHANGELOG.md +137 -0
- package/MINDFORGE.md +5 -5
- 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/mesh-self-healer.js +101 -28
- package/bin/autonomous/wave-executor.js +20 -1
- package/bin/browser/regression-writer.js +45 -3
- package/bin/browser/session-manager.js +21 -17
- 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/logic-drift-detector.js +14 -6
- package/bin/engine/logic-validator.js +155 -25
- package/bin/engine/orbital-guardian.js +56 -10
- package/bin/engine/otel-exporter.js +123 -0
- package/bin/engine/reason-source-aligner.js +19 -6
- package/bin/engine/remediation-engine.js +1 -1
- package/bin/engine/self-corrective-synthesizer.js +1 -1
- package/bin/engine/sre-manager.js +33 -6
- 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/policy-engine.js +17 -4
- package/bin/governance/quantum-crypto.js +63 -9
- package/bin/governance/ztai-archiver.js +74 -9
- package/bin/governance/ztai-manager.js +33 -5
- package/bin/hindsight-injector.js +5 -6
- package/bin/hooks/instinct-capture-hook.js +186 -0
- package/bin/installer-core.js +31 -2
- package/bin/memory/auto-shadow.js +32 -3
- package/bin/memory/eis-client.js +45 -4
- 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 +143 -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/review/finding-synthesizer.js +35 -6
- package/bin/security/trust-boundaries.js +194 -0
- package/bin/security/trust-gate-hook.js +49 -0
- package/bin/skill-registry.js +34 -22
- 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/sre/shadow-mirror.js +90 -40
- package/bin/utils/append-queue.js +67 -0
- package/bin/utils/file-io.js +29 -80
- package/bin/utils/version-check.js +75 -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 };
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MindForge v7 — Proactive Semantic Homing (Pillar XII)
|
|
3
3
|
* Mesh Self-Healer: Peer agents "home in" on drifting nodes to provide collaborative reasoning.
|
|
4
|
+
*
|
|
5
|
+
* UC-22 (audit finding #16) — HONEST LABELLING:
|
|
6
|
+
* There is no live, runtime peer-reasoning mesh in this build. Previously this
|
|
7
|
+
* module FABRICATED a collective consensus (hardcoded peers, canned
|
|
8
|
+
* confidence:94, "100% agreement" log). That emitted false assurance on the
|
|
9
|
+
* live auto-runner self-heal path. It now consults the ONLY real peer source
|
|
10
|
+
* available — the ztai-manager session-agent registry — and, when that yields
|
|
11
|
+
* no real peers (the common case at runtime), degrades GRACEFULLY to a clearly
|
|
12
|
+
* labelled single-source advisory with NO fabricated confidence or consensus.
|
|
4
13
|
*/
|
|
5
14
|
'use strict';
|
|
6
15
|
|
|
7
|
-
const fs = require('node:fs');
|
|
8
16
|
const path = require('node:path');
|
|
9
17
|
|
|
10
18
|
class MeshSelfHealer {
|
|
@@ -14,52 +22,117 @@ class MeshSelfHealer {
|
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
24
|
* Peer agents "home in" on a node with high logic drift.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} driftingAgentDid - DID of the drifting node.
|
|
27
|
+
* @param {number} driftScore - Logic-drift score (only acts on >= 80).
|
|
28
|
+
* @param {object} [options] - { sessionId } to scope the real peer lookup.
|
|
29
|
+
* @returns {Promise<object|null>} Honest advisory object, or null below threshold.
|
|
17
30
|
*/
|
|
18
|
-
async homeIn(driftingAgentDid, driftScore) {
|
|
31
|
+
async homeIn(driftingAgentDid, driftScore, options = {}) {
|
|
19
32
|
if (driftScore < 80) return null; // Only home in on major drift
|
|
20
|
-
|
|
21
|
-
console.log(`[HOMING-HEAL] Global Mesh Alert: Agent ${driftingAgentDid} experiencing critical logic drift (${driftScore}). Peer agents redirecting...`);
|
|
22
|
-
|
|
23
|
-
// Find nearby idle agents or specialists
|
|
24
|
-
const peers = this.findAvailablePeers(driftingAgentDid);
|
|
25
|
-
const healingNodes = [];
|
|
26
33
|
|
|
34
|
+
console.log(`[HOMING-HEAL] Global Mesh Alert: Agent ${driftingAgentDid} experiencing critical logic drift (${driftScore}). Seeking peer reasoning support...`);
|
|
35
|
+
|
|
36
|
+
// Consult the only REAL peer source: the ztai-manager session registry.
|
|
37
|
+
// Returns an empty array when no live peers are registered.
|
|
38
|
+
const peers = this.findAvailablePeers(driftingAgentDid, options.sessionId);
|
|
39
|
+
|
|
40
|
+
if (peers.length === 0) {
|
|
41
|
+
return this.degradedAdvisory(driftingAgentDid);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const healingNodes = [];
|
|
27
45
|
for (const peer of peers) {
|
|
28
|
-
console.log(`[HOMING-HEAL]
|
|
46
|
+
console.log(`[HOMING-HEAL] Peer ${peer.did} homing in on ${driftingAgentDid} to provide reasoning support.`);
|
|
29
47
|
const supportTrace = await this.provideCollectiveReasoning(peer, driftingAgentDid);
|
|
30
48
|
healingNodes.push(supportTrace);
|
|
31
49
|
}
|
|
32
50
|
|
|
33
|
-
return this.reconcileReasoning(healingNodes);
|
|
51
|
+
return this.reconcileReasoning(healingNodes, driftingAgentDid);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Discovers REAL peer agents from the live registry. There are no invented
|
|
56
|
+
* peers — if nothing is registered for the session, this returns []
|
|
57
|
+
* and the caller degrades honestly.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} driftingAgentDid - The node being healed (excluded from peers).
|
|
60
|
+
* @param {string|null} sessionId - Session scope for the registry lookup.
|
|
61
|
+
* @returns {Array<{did:string, persona?:string}>} Real peers (possibly empty).
|
|
62
|
+
*/
|
|
63
|
+
findAvailablePeers(driftingAgentDid, sessionId = null) {
|
|
64
|
+
let agents = [];
|
|
65
|
+
try {
|
|
66
|
+
// Lazy require to avoid a hard coupling / load cost on the cold path.
|
|
67
|
+
const ztaiManager = require('../governance/ztai-manager');
|
|
68
|
+
if (typeof ztaiManager.getSessionAgents === 'function') {
|
|
69
|
+
agents = ztaiManager.getSessionAgents(sessionId) || [];
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// No registry available — treat as no live peers (honest degraded mode).
|
|
73
|
+
agents = [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Exclude the drifting node itself; only real, distinct peers may help.
|
|
77
|
+
return agents.filter(a => a && a.did && a.did !== driftingAgentDid);
|
|
34
78
|
}
|
|
35
79
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Honest single-source advisory used when no live peer mesh is available.
|
|
82
|
+
* Carries NO fabricated confidence and makes NO consensus claim.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} driftingAgentDid
|
|
85
|
+
* @returns {object}
|
|
86
|
+
*/
|
|
87
|
+
degradedAdvisory(driftingAgentDid) {
|
|
88
|
+
console.log('[HOMING-HEAL] No live peer mesh available — emitting single-source advisory (degraded).');
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'advisory',
|
|
92
|
+
mesh_available: false,
|
|
93
|
+
degraded: true,
|
|
94
|
+
confidence: null,
|
|
95
|
+
consensus: null,
|
|
96
|
+
target: driftingAgentDid,
|
|
97
|
+
recommendation: 'Heuristic single-source steering: pause the drifting node, re-anchor to the last verified plan/spec, and require human or higher-tier review before resuming. No multi-agent consensus was available to corroborate this.',
|
|
98
|
+
source: 'Mesh-Self-Healing (degraded: no live peers)'
|
|
99
|
+
};
|
|
42
100
|
}
|
|
43
101
|
|
|
44
102
|
async provideCollectiveReasoning(peer, target) {
|
|
45
|
-
//
|
|
103
|
+
// A real peer contributes a reasoning node. Confidence is left null here:
|
|
104
|
+
// this build has no model-backed scoring, so we do not invent a number.
|
|
46
105
|
return {
|
|
47
106
|
provider: peer.did,
|
|
48
|
-
target
|
|
49
|
-
reasoning: '
|
|
50
|
-
confidence:
|
|
107
|
+
target,
|
|
108
|
+
reasoning: 'Peer steering note: re-sync drifting node with the last verified plan state.',
|
|
109
|
+
confidence: null
|
|
51
110
|
};
|
|
52
111
|
}
|
|
53
112
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Reconciles multiple REAL peer reasoning nodes. With live peers present we
|
|
115
|
+
* report how many corroborated, but we still never invent a confidence score
|
|
116
|
+
* or a "100% agreement" claim.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array<object>} nodes - Real peer reasoning contributions.
|
|
119
|
+
* @param {string} driftingAgentDid
|
|
120
|
+
* @returns {object}
|
|
121
|
+
*/
|
|
122
|
+
reconcileReasoning(nodes, driftingAgentDid) {
|
|
123
|
+
const peerCount = nodes.length;
|
|
124
|
+
console.log(`[HOMING-HEAL] Collective reasoning gathered from ${peerCount} live peer(s); no confidence fabricated.`);
|
|
125
|
+
|
|
59
126
|
return {
|
|
60
|
-
type: '
|
|
61
|
-
|
|
62
|
-
|
|
127
|
+
type: 'advisory',
|
|
128
|
+
mesh_available: true,
|
|
129
|
+
degraded: false,
|
|
130
|
+
confidence: null, // No model-backed scoring in this build — stay honest.
|
|
131
|
+
consensus: nodes[0].reasoning,
|
|
132
|
+
peer_count: peerCount,
|
|
133
|
+
target: driftingAgentDid,
|
|
134
|
+
recommendation: nodes[0].reasoning,
|
|
135
|
+
source: `Mesh-Self-Healing (${peerCount} live peer(s))`
|
|
63
136
|
};
|
|
64
137
|
}
|
|
65
138
|
}
|
|
@@ -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({
|