zelari-code 2.13.0 → 2.14.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 (84) hide show
  1. package/dist/cli/headless/policyGate.js +101 -0
  2. package/dist/cli/headless/policyGate.js.map +1 -0
  3. package/dist/cli/headless.js.map +1 -1
  4. package/dist/cli/kraken/completionProof.js +89 -41
  5. package/dist/cli/kraken/completionProof.js.map +1 -1
  6. package/dist/cli/kraken/completionProofAttestation.js +157 -0
  7. package/dist/cli/kraken/completionProofAttestation.js.map +1 -0
  8. package/dist/cli/kraken/completionProofPersist.js +219 -0
  9. package/dist/cli/kraken/completionProofPersist.js.map +1 -0
  10. package/dist/cli/kraken/completionProofProbe.js +135 -0
  11. package/dist/cli/kraken/completionProofProbe.js.map +1 -0
  12. package/dist/cli/kraken/contractCompiler.js +142 -0
  13. package/dist/cli/kraken/contractCompiler.js.map +1 -0
  14. package/dist/cli/kraken/delegationPolicy.js +40 -3
  15. package/dist/cli/kraken/delegationPolicy.js.map +1 -1
  16. package/dist/cli/kraken/executor.js +462 -9
  17. package/dist/cli/kraken/executor.js.map +1 -1
  18. package/dist/cli/kraken/fileOwnership.js +190 -0
  19. package/dist/cli/kraken/fileOwnership.js.map +1 -0
  20. package/dist/cli/kraken/modelReputation.js +208 -0
  21. package/dist/cli/kraken/modelReputation.js.map +1 -0
  22. package/dist/cli/kraken/nativeVerification.js +53 -11
  23. package/dist/cli/kraken/nativeVerification.js.map +1 -1
  24. package/dist/cli/kraken/planner.js +2 -0
  25. package/dist/cli/kraken/planner.js.map +1 -1
  26. package/dist/cli/kraken/reputationStore.js +139 -0
  27. package/dist/cli/kraken/reputationStore.js.map +1 -0
  28. package/dist/cli/kraken/semanticOwnership.js +184 -0
  29. package/dist/cli/kraken/semanticOwnership.js.map +1 -0
  30. package/dist/cli/kraken/spawnRoi.js +209 -0
  31. package/dist/cli/kraken/spawnRoi.js.map +1 -0
  32. package/dist/cli/kraken/transactional.js +104 -0
  33. package/dist/cli/kraken/transactional.js.map +1 -0
  34. package/dist/cli/kraken/verificationAdapters/dotnet.js +49 -0
  35. package/dist/cli/kraken/verificationAdapters/dotnet.js.map +1 -0
  36. package/dist/cli/kraken/verificationAdapters/go.js +36 -0
  37. package/dist/cli/kraken/verificationAdapters/go.js.map +1 -0
  38. package/dist/cli/kraken/verificationAdapters/index.js +47 -0
  39. package/dist/cli/kraken/verificationAdapters/index.js.map +1 -0
  40. package/dist/cli/kraken/verificationAdapters/java.js +102 -0
  41. package/dist/cli/kraken/verificationAdapters/java.js.map +1 -0
  42. package/dist/cli/kraken/verificationAdapters/node.js +90 -0
  43. package/dist/cli/kraken/verificationAdapters/node.js.map +1 -0
  44. package/dist/cli/kraken/verificationAdapters/python.js +86 -0
  45. package/dist/cli/kraken/verificationAdapters/python.js.map +1 -0
  46. package/dist/cli/kraken/verificationAdapters/rust.js +39 -0
  47. package/dist/cli/kraken/verificationAdapters/rust.js.map +1 -0
  48. package/dist/cli/kraken/verificationAdapters/types.js +2 -0
  49. package/dist/cli/kraken/verificationAdapters/types.js.map +1 -0
  50. package/dist/cli/kraken/verificationBridge.js +40 -3
  51. package/dist/cli/kraken/verificationBridge.js.map +1 -1
  52. package/dist/cli/kraken/verifierLifecycle.js +51 -36
  53. package/dist/cli/kraken/verifierLifecycle.js.map +1 -1
  54. package/dist/cli/kraken/verifierRouting.js +264 -0
  55. package/dist/cli/kraken/verifierRouting.js.map +1 -0
  56. package/dist/cli/kraken/worktreeScheduling.js +201 -0
  57. package/dist/cli/kraken/worktreeScheduling.js.map +1 -0
  58. package/dist/cli/main.bundled.js +3880 -889
  59. package/dist/cli/main.bundled.js.map +4 -4
  60. package/dist/cli/orchestration/facts.js +127 -0
  61. package/dist/cli/orchestration/facts.js.map +1 -0
  62. package/dist/cli/orchestration/policy.js +128 -75
  63. package/dist/cli/orchestration/policy.js.map +1 -1
  64. package/dist/cli/orchestration/signals.js +96 -0
  65. package/dist/cli/orchestration/signals.js.map +1 -0
  66. package/dist/cli/runHeadless.js +92 -19
  67. package/dist/cli/runHeadless.js.map +1 -1
  68. package/dist/cli/safety/policyEngine.js +159 -20
  69. package/dist/cli/safety/policyEngine.js.map +1 -1
  70. package/dist/cli/safety/policyLoadMode.js +59 -0
  71. package/dist/cli/safety/policyLoadMode.js.map +1 -0
  72. package/dist/cli/safety/resourceClaims.js +373 -0
  73. package/dist/cli/safety/resourceClaims.js.map +1 -0
  74. package/dist/cli/safety/sandboxPath.js +160 -17
  75. package/dist/cli/safety/sandboxPath.js.map +1 -1
  76. package/dist/cli/toolRegistry.js +98 -8
  77. package/dist/cli/toolRegistry.js.map +1 -1
  78. package/dist/cli/tools/execProcess.js +121 -0
  79. package/dist/cli/tools/execProcess.js.map +1 -0
  80. package/dist/cli/tools/krakenRadio.js +9 -0
  81. package/dist/cli/tools/krakenRadio.js.map +1 -1
  82. package/dist/cli/tools/taskTool.js +8 -3
  83. package/dist/cli/tools/taskTool.js.map +1 -1
  84. package/package.json +2 -2
@@ -38,12 +38,39 @@ import { getReadyNodes, isSettled, isConverged, failedNodeIds, countByStatus, ca
38
38
  import { runTentacle, TASK_TOOL_TIMEOUT_MS, } from './tentacle.js';
39
39
  import { mergeKrakenWorktree, } from '../tools/krakenWorktree.js';
40
40
  import { appendKrakenRadio } from '../tools/krakenRadio.js';
41
+ import { runTransactional } from './transactional.js';
41
42
  import { runBacktest } from '../workspace/worldModel.js';
42
43
  import { startKrakenGraphLive, updateKrakenGraphLive, endKrakenGraphLive, } from './graphStatus.js';
43
44
  import { existsSync } from 'node:fs';
44
45
  import path from 'node:path';
45
46
  import { saveGraphSnapshot, toGraphSnapshot } from './graphMemory.js';
46
47
  import { WorkbenchWriter } from './workbench.js';
48
+ import { arbitrateAdmission, caseInsensitiveFs, hasWriteOverlap } from './fileOwnership.js';
49
+ import { isWorktreeCapableKind, resolveWorktreeMode, worktreeSchedulingDecision, } from './worktreeScheduling.js';
50
+ import { semanticConflictDecision, } from './semanticOwnership.js';
51
+ import { isAstSupported, parseFileSymbolsDiag } from '../ast/engine.js';
52
+ // t29 (§15–16): fail-open reputation recording + routing source refresh.
53
+ import { calculateCost } from '../modelPricing.js';
54
+ import { aggregate, REPUTATION_MIN_SAMPLE, reputationRecordFromNodeRun, } from './modelReputation.js';
55
+ // t30 (§17): spawn-ROI gate — deterministic score, threshold parsing and the
56
+ // duplication-risk heuristic; the veto itself lives in this class (fail-open).
57
+ import { computeSpawnScore, duplicationRiskFor, parseRoiThreshold, shouldSpawn, } from './spawnRoi.js';
58
+ import { appendRecord, DEFAULT_MAX_RECORDS, loadRecords, pruneStore, resolveReputationStorePath, } from './reputationStore.js';
59
+ import { setReputationSource } from './verifierRouting.js';
60
+ /**
61
+ * P2.B default symbol extractor behind semantic ownership arbitration: the
62
+ * real AST outline via ast/engine. Lazy (the TS compiler API loads inside
63
+ * `parseFileSymbolsDiag` on first use) and fail-closed — an unsupported file
64
+ * or any parse failure is null, which the decision reports as a conflict so
65
+ * the pair defers to the sequential path instead of trusting a claim it
66
+ * could not verify.
67
+ */
68
+ async function defaultSymbolExtractor(file) {
69
+ if (!isAstSupported(file))
70
+ return null;
71
+ const r = await parseFileSymbolsDiag(file);
72
+ return r.status === 'ok' ? r.symbols.map((s) => s.name) : null;
73
+ }
47
74
  /** Default cap on concurrently-running tentacles across the whole graph. */
48
75
  export const DEFAULT_MAX_PARALLEL = 12;
49
76
  /** Default number of `fix` nodes the executor may spawn across one graph run. */
@@ -55,6 +82,17 @@ export function resolveMaxParallel(env = process.env) {
55
82
  const n = Number.parseInt(raw, 10);
56
83
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PARALLEL;
57
84
  }
85
+ /**
86
+ * Transactional writer execution (P2.D), default OFF: a rollback discards
87
+ * partial work the existing fix/rework flow might have reused, and the
88
+ * whole-tree restore can revert a concurrent in-place sibling's writes.
89
+ * Opt in per run (`transactional: true`) or via env.
90
+ */
91
+ export const DEFAULT_TRANSACTIONAL = false;
92
+ /** `ZELARI_KRAKEN_TRANSACTIONAL === '1'` turns transactional writers on. */
93
+ export function resolveTransactional(env = process.env) {
94
+ return env.ZELARI_KRAKEN_TRANSACTIONAL === '1';
95
+ }
58
96
  /**
59
97
  * How many `fix` nodes this run may spawn.
60
98
  *
@@ -273,12 +311,26 @@ function firstLine(text, maxChars = 160) {
273
311
  function agentForNode(node) {
274
312
  return node.kind === 'explore' || node.kind === 'verify' ? node.kind : 'general';
275
313
  }
314
+ /** P2.F: fold a graph node kind onto the ROI task-kind taxonomy. */
315
+ function roiTaskKindOf(kind) {
316
+ if (kind === 'explore')
317
+ return 'explore';
318
+ if (kind === 'verify')
319
+ return 'verify';
320
+ if (kind === 'spec' || kind === 'conformance')
321
+ return 'review';
322
+ return 'implement'; // general/fix/merge and unknown kinds all mutate the tree
323
+ }
276
324
  export class KrakenGraphExecutor {
277
325
  deps;
278
326
  parentCwd;
279
327
  sessionId;
280
328
  goal;
281
329
  maxParallel;
330
+ /** Fold case in ownership scope comparisons (win32/darwin FSes). */
331
+ ownershipCaseFolding;
332
+ /** P2.B: symbol extractor for semantic ownership (default: ast/engine). */
333
+ symbolExtractor;
282
334
  /** Explicit all-kinds override; when undefined the budget is per-kind. */
283
335
  nodeTimeoutMs;
284
336
  cancelGraceMs;
@@ -288,9 +340,15 @@ export class KrakenGraphExecutor {
288
340
  maxReviewRounds;
289
341
  graphTimeoutMs;
290
342
  worldModelGateOverride;
343
+ /** P2.D: wrap general (writer) tentacles in checkpoint→run→rollback. */
344
+ transactional;
291
345
  runTentacleFn;
292
346
  mergeFn;
293
347
  backtestFn;
348
+ /** P2.F: pure score seam for the ROI gate (default: computeSpawnScore). */
349
+ roiScoreFn;
350
+ /** P2.F: ROI reputation source; null until resolved once per run. */
351
+ roiReputation;
294
352
  /** Live workbench writer for this run (created once the graph starts). */
295
353
  wb = null;
296
354
  nodeRunState = new Map();
@@ -331,6 +389,9 @@ export class KrakenGraphExecutor {
331
389
  this.sessionId = opts.sessionId;
332
390
  this.goal = opts.goal;
333
391
  this.maxParallel = opts.maxParallel ?? resolveMaxParallel();
392
+ this.ownershipCaseFolding =
393
+ opts.ownershipCaseFolding ?? caseInsensitiveFs(process.platform);
394
+ this.symbolExtractor = opts.symbolExtractor ?? defaultSymbolExtractor;
334
395
  this.nodeTimeoutMs = opts.nodeTimeoutMs;
335
396
  this.cancelGraceMs = opts.cancelGraceMs;
336
397
  this.fixBudgetOption = opts.fixBudget;
@@ -339,10 +400,13 @@ export class KrakenGraphExecutor {
339
400
  this.maxReviewRounds = opts.maxReviewRounds ?? resolveMaxReviewRounds();
340
401
  this.graphTimeoutMs = opts.graphTimeoutMs ?? resolveGraphTimeoutMs();
341
402
  this.worldModelGateOverride = opts.worldModelGate;
403
+ this.transactional = opts.transactional ?? resolveTransactional();
342
404
  this.signal = opts.signal;
343
405
  this.runTentacleFn = opts.runTentacleFn ?? runTentacle;
344
406
  this.mergeFn = opts.mergeFn ?? mergeKrakenWorktree;
345
407
  this.backtestFn = opts.backtestFn ?? runBacktest;
408
+ this.roiScoreFn = opts.roiScoreFn ?? computeSpawnScore;
409
+ this.roiReputation = opts.reputationRecords ?? null;
346
410
  }
347
411
  /** Map a graph node onto the workbench's node shape. */
348
412
  toWorkbenchNode(n) {
@@ -386,6 +450,9 @@ export class KrakenGraphExecutor {
386
450
  }
387
451
  /** Execute the graph in place (mutates node statuses) until it settles. */
388
452
  async execute(graph) {
453
+ // t29: refresh the reputation source (fail-open, fire-and-forget) so
454
+ // verifier routing consulted later in this process sees real history.
455
+ void this.refreshReputationSource();
389
456
  // Now that the graph is known, size the repair budget to it (unless the
390
457
  // caller pinned one). Done here rather than in the constructor because the
391
458
  // node count is the whole input to the decision.
@@ -452,7 +519,7 @@ export class KrakenGraphExecutor {
452
519
  break;
453
520
  // Admit everything that can start alongside what is already running,
454
521
  // up to the concurrency cap. A cancelled run admits nothing.
455
- const admitted = this.aborted ? [] : this.admit(graph, inFlight);
522
+ const admitted = this.aborted ? [] : await this.admit(graph, inFlight);
456
523
  if (admitted.length > 0) {
457
524
  // Mark and publish the whole admission BEFORE starting any of it: an
458
525
  // async function body runs synchronously up to its first await, so
@@ -595,8 +662,19 @@ export class KrakenGraphExecutor {
595
662
  * Unlike a wave-at-a-time scheduler this is called on every completion, so a
596
663
  * node becomes eligible the moment its blocker settles instead of waiting
597
664
  * for the slowest member of some earlier batch.
665
+ *
666
+ * The surviving list then goes through file-ownership arbitration (P2.A):
667
+ * a writer whose scope overlaps a writer already admitted this round or
668
+ * already in flight is DEFERRED, never failed — it stays READY and is
669
+ * re-offered on the next completion, so overlapping writers serialize via
670
+ * the ordinary settle-one-at-a-time loop.
671
+ *
672
+ * P2.C exception: with ZELARI_KRAKEN_WORKTREE=auto a deferred writer whose
673
+ * overlap is merely partial (score < 0.75, `classifyOverlap`) is re-admitted
674
+ * immediately under git worktree isolation instead of idling behind its
675
+ * racing writer.
598
676
  */
599
- admit(graph, inFlight) {
677
+ async admit(graph, inFlight) {
600
678
  const capacity = this.maxParallel - inFlight.size;
601
679
  if (capacity <= 0)
602
680
  return [];
@@ -606,16 +684,275 @@ export class KrakenGraphExecutor {
606
684
  if (n)
607
685
  running.push(n);
608
686
  }
609
- const admitted = [];
687
+ const candidates = [];
610
688
  for (const node of getReadyNodes(graph)) {
611
- if (admitted.length >= capacity)
689
+ if (candidates.length >= capacity)
612
690
  break;
613
691
  const safe = running.every((r) => canRunParallel(r, node)) &&
614
- admitted.every((a) => canRunParallel(a, node));
692
+ candidates.every((a) => canRunParallel(a, node));
615
693
  if (safe)
616
- admitted.push(node);
694
+ candidates.push(node);
695
+ }
696
+ // P2.A: two writers with overlapping scopes must never share the tree.
697
+ // Arbitration can only shrink the candidate list, so this is pure
698
+ // defense-in-depth on top of canRunParallel — plus case folding on
699
+ // win32/darwin and a workbench/radio trail for every deferral.
700
+ const { admitted, deferred } = arbitrateAdmission(candidates, running, {
701
+ caseInsensitive: this.ownershipCaseFolding,
702
+ });
703
+ // P2.C: with ZELARI_KRAKEN_WORKTREE=auto, a writer arbitration would defer
704
+ // against another writer can still start NOW when the overlap is merely
705
+ // partial/containment — it runs in its own git worktree and tentacle
706
+ // merges stay sequential (Correction 4), so the race is merge-safe by
707
+ // construction and a cheap merge beats idling. Identical-grain overlap
708
+ // (same scope, wildcard claims folded to a match) still defers: that
709
+ // merge would cost more than the serialization. Any other mode keeps the
710
+ // deferral exactly as P2.A left it.
711
+ const worktreeMode = resolveWorktreeMode(process.env.ZELARI_KRAKEN_WORKTREE);
712
+ const rescued = [];
713
+ const held = [];
714
+ if (worktreeMode === 'auto' && deferred.length > 0) {
715
+ // The race set to score against: everything in flight PLUS everything
716
+ // admitted this round — those are running the moment admit() returns,
717
+ // and a first-round clash would otherwise never be rescuable (running
718
+ // is still empty then).
719
+ const racing = [...running, ...admitted];
720
+ for (const node of deferred) {
721
+ const decision = isWorktreeCapableKind(node.kind) &&
722
+ !this.reworks.has(node.id) && // a rework edits an EXISTING worktree
723
+ this.deps.allowWorktree !== false
724
+ ? worktreeSchedulingDecision(node, racing, process.env, {
725
+ caseInsensitive: this.ownershipCaseFolding,
726
+ })
727
+ : undefined;
728
+ if (decision && decision.mode === 'parallel-worktree') {
729
+ rescued.push(node);
730
+ this.radio('node_worktree_scheduled', {
731
+ description: node.label,
732
+ agent: node.kind,
733
+ detail: `worktree-isolated parallel admission: overlap=${decision.overlapScore.toFixed(2)} (${decision.rationaleCode})`,
734
+ ok: true,
735
+ nodeId: node.id,
736
+ overlapScore: decision.overlapScore,
737
+ rationaleCode: decision.rationaleCode,
738
+ ...(decision.bestMatchId !== undefined
739
+ ? { runningNode: decision.bestMatchId }
740
+ : {}),
741
+ });
742
+ this.wb?.logEvent(`worktree-scheduled ${node.id} "${node.label}" — overlap ${decision.overlapScore.toFixed(2)} (${decision.rationaleCode}) vs ${decision.bestMatchId ?? 'writer'}; runs isolated, merge stays sequential`);
743
+ }
744
+ else {
745
+ held.push(node);
746
+ }
747
+ }
748
+ }
749
+ else {
750
+ held.push(...deferred);
751
+ }
752
+ // P2.B: semantic rescue — a writer the arbitration still holds whose
753
+ // deferral is pure same-file grain can run alongside its racer when BOTH
754
+ // sides declare `ownedSymbols` and every contested pair verifies as
755
+ // symbol-disjoint. One undecided or conflicting pair keeps the t25
756
+ // deferral intact (fail-closed: undeclared claims, malformed specs and
757
+ // AST extraction failures all defer). When worktree scheduling is `auto`
758
+ // and the node is isolatable it reuses the t27 worktree path (same-file
759
+ // different-symbol is still a git merge risk); otherwise it is admitted
760
+ // plainly, with telemetry saying exactly that.
761
+ const semAdmitted = [];
762
+ if (held.length > 0) {
763
+ // Everything the node would race: in flight, admitted this round, or
764
+ // already rescued above (worktree-isolated but still concurrent).
765
+ const racing = [...running, ...admitted, ...rescued];
766
+ const ctx = {
767
+ extractSymbols: this.symbolExtractor,
768
+ astSupported: isAstSupported,
769
+ };
770
+ for (const node of held) {
771
+ const verdict = await this.semanticRescueDecision(node, racing, ctx);
772
+ if (!verdict)
773
+ continue;
774
+ const isolatable = worktreeMode === 'auto' &&
775
+ isWorktreeCapableKind(node.kind) &&
776
+ !this.reworks.has(node.id) && // a rework edits an EXISTING worktree
777
+ this.deps.allowWorktree !== false;
778
+ this.radio('node_semantic_admitted', {
779
+ description: node.label,
780
+ agent: node.kind,
781
+ detail: `semantic admission: disjoint symbol claims on ${verdict.contestedFile} vs ${verdict.racerId} (${verdict.reasonCode}) — ${isolatable ? 'worktree-isolated' : 'plain parallel; same-file merge risk'}`,
782
+ ok: true,
783
+ nodeId: node.id,
784
+ rationaleCode: isolatable ? 'semantic-disjoint-worktree' : 'semantic-disjoint-plain',
785
+ runningNode: verdict.racerId,
786
+ contestedFile: verdict.contestedFile,
787
+ symbolsA: verdict.symbolsA,
788
+ symbolsB: verdict.symbolsB,
789
+ });
790
+ this.wb?.logEvent(`semantic-admitted ${node.id} "${node.label}" — disjoint claims on ${verdict.contestedFile} vs ${verdict.racerId} (${verdict.reasonCode}); ${isolatable ? 'runs worktree-isolated' : 'runs plainly alongside'}`);
791
+ if (isolatable)
792
+ rescued.push(node);
793
+ else {
794
+ semAdmitted.push(node);
795
+ racing.push(node); // later held nodes must be arbitrated against it too
796
+ }
797
+ }
617
798
  }
618
- return admitted;
799
+ for (const node of held) {
800
+ if (rescued.includes(node) || semAdmitted.includes(node))
801
+ continue;
802
+ const scopes = node.scope && node.scope.length > 0 ? node.scope.join(', ') : '**';
803
+ this.radio('node_deferred', {
804
+ description: node.label,
805
+ agent: node.kind,
806
+ detail: `deferred: write scope (${scopes}) overlaps a running writer — stays READY`,
807
+ ok: true,
808
+ });
809
+ this.wb?.logEvent(`deferred ${node.id} "${node.label}" — write scope (${scopes}) overlaps a running writer; stays ready for the next round`);
810
+ }
811
+ // P2.F: spawn-ROI gate — every tentacle spawn must have positive expected
812
+ // value. Runs AFTER ownership arbitration and both rescues, so it can only
813
+ // shrink the final spawn list; a node scoring below the threshold goes
814
+ // back the deferred path (stays READY, re-offered next round — never
815
+ // failed) with a `node_roi_vetoed` radio trail. Fail-open: any error
816
+ // inside the gate spawns the batch (see roiGate).
817
+ return (await this.roiGate([...admitted, ...rescued, ...semAdmitted], running)).spawn;
818
+ }
819
+ /**
820
+ * P2.F: resolve the ROI gate's reputation source once per run — the
821
+ * injected fixture when given, else the repo's t29 store. Fail-open: a
822
+ * missing/corrupt store already degrades to [] inside loadRecords, and an
823
+ * unexpected error is swallowed the same way ("no history").
824
+ */
825
+ async roiReputationRecords() {
826
+ if (this.roiReputation !== null)
827
+ return this.roiReputation;
828
+ try {
829
+ this.roiReputation = await loadRecords(resolveReputationStorePath(this.parentCwd));
830
+ }
831
+ catch {
832
+ this.roiReputation = [];
833
+ }
834
+ return this.roiReputation;
835
+ }
836
+ /**
837
+ * P2.F: build the ROI input for one node. Reputation comes from the t29
838
+ * (repo, host-agent-role) bucket and is trusted only at/above
839
+ * REPUTATION_MIN_SAMPLE — below that the rate/repair/latency fields stay
840
+ * null so the score falls back to its sane defaults (unknown ⇒ spawn).
841
+ * Token and per-token price estimates do not exist pre-run in v1
842
+ * (documented): both stay null. `now` is taken by the gate, never inside
843
+ * the pure module.
844
+ */
845
+ roiInputFor(node, racing, records, now) {
846
+ let verifiedRate = null;
847
+ let avgRepairs = null;
848
+ let latencyMs = null;
849
+ let sample = 0;
850
+ try {
851
+ const summary = aggregate(records, { repo: path.basename(this.parentCwd), role: agentForNode(node) }, now);
852
+ sample = summary.sample;
853
+ if (summary.sample >= REPUTATION_MIN_SAMPLE) {
854
+ verifiedRate = summary.verifiedRate;
855
+ avgRepairs = summary.avgRepairs;
856
+ latencyMs = summary.avgLatencyMs;
857
+ }
858
+ }
859
+ catch {
860
+ /* fail-open: treat as no history */
861
+ }
862
+ return {
863
+ reputationSample: sample,
864
+ verifiedRate,
865
+ historicalAvgRepairs: avgRepairs,
866
+ estimatedTokens: null,
867
+ costUsdPer1k: null,
868
+ latencyMsEstimate: latencyMs,
869
+ duplicationRisk: duplicationRiskFor(node, racing),
870
+ taskKind: roiTaskKindOf(node.kind),
871
+ };
872
+ }
873
+ /**
874
+ * P2.F: the veto itself. Scores every node about to spawn against
875
+ * ZELARI_KRAKEN_ROI_THRESHOLD (raw env string parsed per admit — invalid ⇒
876
+ * default) and returns the survivors. Vetoed nodes are NOT failed: they are
877
+ * simply not returned, so they stay READY and are re-offered next round,
878
+ * each with a `node_roi_vetoed` radio trail. Fail-open twice over: an error
879
+ * while scoring ONE node spawns that node, and an error that escapes the
880
+ * loop spawns the whole batch — the gate must never break a run.
881
+ */
882
+ async roiGate(spawnList, running) {
883
+ const spawn = [];
884
+ const vetoed = [];
885
+ if (spawnList.length === 0)
886
+ return { spawn, vetoed };
887
+ try {
888
+ const threshold = parseRoiThreshold(process.env.ZELARI_KRAKEN_ROI_THRESHOLD);
889
+ const records = await this.roiReputationRecords();
890
+ const now = Date.now();
891
+ const racing = [...running, ...spawnList];
892
+ for (const node of spawnList) {
893
+ try {
894
+ const input = this.roiInputFor(node, racing.filter((r) => r.id !== node.id), records, now);
895
+ const score = this.roiScoreFn(input);
896
+ if (shouldSpawn(score, threshold)) {
897
+ spawn.push(node);
898
+ continue;
899
+ }
900
+ vetoed.push(node);
901
+ const pinned = Number(score.score.toFixed(4));
902
+ this.radio('node_roi_vetoed', {
903
+ description: node.label,
904
+ agent: node.kind,
905
+ detail: `roi gate: spawnScore ${pinned} < threshold ${threshold} (${score.rationaleCode}) — stays READY`,
906
+ ok: true,
907
+ nodeId: node.id,
908
+ spawnScore: pinned,
909
+ threshold,
910
+ rationaleCode: score.rationaleCode,
911
+ });
912
+ this.wb?.logEvent(`roi-vetoed ${node.id} "${node.label}" — spawnScore ${pinned} < ${threshold} (${score.rationaleCode}); deferred, not failed`);
913
+ }
914
+ catch {
915
+ spawn.push(node); // per-node fail-open
916
+ }
917
+ }
918
+ }
919
+ catch {
920
+ return { spawn: [...spawnList], vetoed: [] }; // whole-gate fail-open
921
+ }
922
+ return { spawn, vetoed };
923
+ }
924
+ /**
925
+ * P2.B: may `node` (held by P2.A/P2.C arbitration) run alongside the racing
926
+ * writers because BOTH sides declare disjoint symbol ownership? Only a pair
927
+ * that actually shares a contested file qualifies; EVERY overlapping racer
928
+ * must verify disjoint — one undecided or conflicting pair keeps the node
929
+ * held. Returns the telemetry anchor of the last verified pair, or null to
930
+ * defer exactly as today.
931
+ */
932
+ async semanticRescueDecision(node, racing, ctx) {
933
+ if (!node.ownedSymbols || node.ownedSymbols.length === 0)
934
+ return null;
935
+ let verified = null;
936
+ for (const racer of racing) {
937
+ const overlaps = hasWriteOverlap(racer, node, {
938
+ caseInsensitive: this.ownershipCaseFolding,
939
+ });
940
+ if (!overlaps)
941
+ continue;
942
+ const verdict = await semanticConflictDecision(racer, node, ctx);
943
+ // No shared contested file ⇒ the claims cannot anchor a same-file
944
+ // rescue; treat as undecided (defer) rather than widening on spec faith.
945
+ if (verdict.conflict || verdict.contestedFile === undefined)
946
+ return null;
947
+ verified = {
948
+ racerId: racer.id,
949
+ contestedFile: verdict.contestedFile,
950
+ reasonCode: verdict.reasonCode,
951
+ symbolsA: [...(racer.ownedSymbols ?? [])],
952
+ symbolsB: [...node.ownedSymbols],
953
+ };
954
+ }
955
+ return verified;
619
956
  }
620
957
  /**
621
958
  * Run one node, tagging the result with its id and converting an unexpected
@@ -679,7 +1016,7 @@ export class KrakenGraphExecutor {
679
1016
  const inheritedCwd = node.kind === 'verify' || isRework
680
1017
  ? this.inheritedWorktreeCwdFor(node, graph)
681
1018
  : undefined;
682
- const res = await this.withNodeTimeout(this.runTentacleFn({
1019
+ const runOpts = {
683
1020
  // `allowWorktree: false` is what actually stops a rework from opening
684
1021
  // its own worktree: creation is driven by the agent kind ('general')
685
1022
  // inside runTentacle, not by anything the executor passes per-call.
@@ -701,7 +1038,46 @@ export class KrakenGraphExecutor {
701
1038
  graphId: graph.id,
702
1039
  nodeId: node.id,
703
1040
  signal: controller.signal,
704
- }), agent, controller);
1041
+ };
1042
+ const runOnce = () => this.withNodeTimeout(this.runTentacleFn(runOpts), agent, controller);
1043
+ // P2.D: transactional writers (opt-in) — checkpoint the parent tree
1044
+ // before the run, roll it back if the node fails, keep the checkpoint as
1045
+ // a recovery point (correlated to graph/node) on success. Explore,
1046
+ // verify, merge and rework paths are never wrapped.
1047
+ if (!this.transactional || node.kind !== 'general') {
1048
+ const res = await runOnce();
1049
+ if (res.ok && usesWorktree) {
1050
+ this.nodeRunState.set(node.id, { worktreeHandle: res.worktreeHandle });
1051
+ }
1052
+ return res;
1053
+ }
1054
+ const tx = await runTransactional({ cwd: this.parentCwd, taskId: graph.id, nodeId: node.id, label: node.label }, async () => {
1055
+ const res = await runOnce();
1056
+ // A failed tentacle RESOLVES with ok:false — map it to a throw so
1057
+ // runTransactional sees a transaction failure and rolls back.
1058
+ if (!res.ok)
1059
+ throw new Error(res.error);
1060
+ return res;
1061
+ });
1062
+ if (tx.outcome === 'rolledback') {
1063
+ this.radio('node_rolled_back', {
1064
+ description: node.label,
1065
+ agent: node.kind,
1066
+ detail: tx.error ?? 'node failed; workspace rolled back',
1067
+ ok: false,
1068
+ });
1069
+ this.wb?.logEvent(`rolled back ${node.id} "${node.label}" — ${tx.error ?? 'node failed'}`);
1070
+ // The ordinary failure path: retry (if budgeted) re-runs from the clean
1071
+ // checkpoint state. No `cancelled` field — the tentacle unwound, so a
1072
+ // re-run is safe.
1073
+ return { ok: false, agent, error: tx.error ?? `node "${node.label}" failed` };
1074
+ }
1075
+ const res = tx.value;
1076
+ if (!res) {
1077
+ // Contractually unreachable (success/passthrough carry the run's
1078
+ // result); an honest failure beats returning undefined.
1079
+ return { ok: false, agent, error: tx.note ?? 'transactional run produced no result' };
1080
+ }
705
1081
  if (res.ok && usesWorktree) {
706
1082
  this.nodeRunState.set(node.id, { worktreeHandle: res.worktreeHandle });
707
1083
  }
@@ -885,6 +1261,10 @@ export class KrakenGraphExecutor {
885
1261
  }
886
1262
  /** Apply a tentacle result to its node: success, retry, fix-spawn, or terminal failure. */
887
1263
  applyResult(graph, node, res) {
1264
+ this.applyResultInner(graph, node, res);
1265
+ this.recordNodeReputation(node, res);
1266
+ }
1267
+ applyResultInner(graph, node, res) {
888
1268
  if (res.ok) {
889
1269
  if (res.memoryId)
890
1270
  this.memoryIds.set(node.id, res.memoryId);
@@ -994,6 +1374,70 @@ export class KrakenGraphExecutor {
994
1374
  durationMs: this.durationsMs.get(node.id),
995
1375
  });
996
1376
  }
1377
+ /**
1378
+ * t29 (§15): append ONE reputation record for a settled node run — the
1379
+ * minimal recorder hook. Only terminal states are recorded (success, or a
1380
+ * failure that consumed the retry budget / got its fix node): retries leave
1381
+ * the node `pending` and cancelled/unconfirmed runs are skipped, because
1382
+ * cancellation and re-entry are not model signals. Repo = basename of the
1383
+ * parent cwd; role = host agent kind (agentForNode); model when the result
1384
+ * carries one ('n/a' merge pseudo-model ⇒ null); provider is NOT carried by
1385
+ * TentacleResult so it records as null (documented v1 limitation). FAILOPEN:
1386
+ * every failure path is swallowed — reputation must never break a run.
1387
+ */
1388
+ recordNodeReputation(node, res) {
1389
+ try {
1390
+ if (!res.ok) {
1391
+ if (this.aborted || res.cancelled === true || res.cancelled === false)
1392
+ return;
1393
+ // Retry re-queues the node ('pending'): its final run records the
1394
+ // whole story via retryCount, so intermediate attempts are not rows.
1395
+ if (node.status === 'pending')
1396
+ return;
1397
+ }
1398
+ const model = res.ok && res.model && res.model !== 'n/a' ? res.model : null;
1399
+ const reviewerVerdict = res.ok && this.isReviewerKind(node.kind) && typeof node.result === 'string' && node.result.length > 0
1400
+ ? parseVerifyVerdict(node.result).verdict
1401
+ : null;
1402
+ const record = reputationRecordFromNodeRun({
1403
+ repo: path.basename(this.parentCwd),
1404
+ role: agentForNode(node),
1405
+ kind: node.kind,
1406
+ ok: res.ok,
1407
+ reviewerVerdict,
1408
+ repairCount: node.retryCount,
1409
+ model,
1410
+ provider: null, // TentacleResult carries no provider identity (t29 v1).
1411
+ costUsd: res.ok && model && res.usage
1412
+ ? calculateCost(model, res.usage.promptTokens, res.usage.completionTokens, res.usage.cachedPromptTokens ?? 0)
1413
+ : null,
1414
+ latencyMs: this.durationsMs.get(node.id) ?? null,
1415
+ });
1416
+ const storePath = resolveReputationStorePath(this.parentCwd);
1417
+ void appendRecord(storePath, record)
1418
+ .then(() => pruneStore(storePath, DEFAULT_MAX_RECORDS))
1419
+ .catch(() => {
1420
+ /* fail-open */
1421
+ });
1422
+ }
1423
+ catch {
1424
+ /* fail-open: reputation must never break a run */
1425
+ }
1426
+ }
1427
+ /**
1428
+ * t29 (§16): load the repo's reputation store once per run and publish it
1429
+ * as the verifier-routing source, so the NEXT advisory review inside this
1430
+ * process consults real history. Passive + fail-open: a missing/corrupt
1431
+ * store yields an empty list, which keeps t21 heuristics verbatim.
1432
+ */
1433
+ async refreshReputationSource() {
1434
+ try {
1435
+ setReputationSource(await loadRecords(resolveReputationStorePath(this.parentCwd)));
1436
+ }
1437
+ catch {
1438
+ /* fail-open */
1439
+ }
1440
+ }
997
1441
  async linkMemoryGraph(graph, converged) {
998
1442
  const memory = this.deps.memoryService;
999
1443
  if (!memory || this.deps.memoryAutoWrite === false)
@@ -1337,6 +1781,15 @@ export class KrakenGraphExecutor {
1337
1781
  description: fields.description,
1338
1782
  ...(fields.detail !== undefined ? { detail: fields.detail } : {}),
1339
1783
  ...(fields.ok !== undefined ? { ok: fields.ok } : {}),
1784
+ ...(fields.nodeId !== undefined ? { nodeId: fields.nodeId } : {}),
1785
+ ...(fields.overlapScore !== undefined ? { overlapScore: fields.overlapScore } : {}),
1786
+ ...(fields.rationaleCode !== undefined ? { rationaleCode: fields.rationaleCode } : {}),
1787
+ ...(fields.runningNode !== undefined ? { runningNode: fields.runningNode } : {}),
1788
+ ...(fields.contestedFile !== undefined ? { contestedFile: fields.contestedFile } : {}),
1789
+ ...(fields.spawnScore !== undefined ? { spawnScore: fields.spawnScore } : {}),
1790
+ ...(fields.threshold !== undefined ? { threshold: fields.threshold } : {}),
1791
+ ...(fields.symbolsA !== undefined ? { symbolsA: fields.symbolsA } : {}),
1792
+ ...(fields.symbolsB !== undefined ? { symbolsB: fields.symbolsB } : {}),
1340
1793
  });
1341
1794
  }
1342
1795
  /**