@cohortapp/agent-sdk 2.4.0 → 2.5.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 (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -52,6 +52,7 @@ import { recordDecision } from "../mandate/audit.mjs";
52
52
  import {
53
53
  readSeries,
54
54
  recordMeasurement,
55
+ recordIntervention,
55
56
  readInterventions,
56
57
  measureKpi,
57
58
  gapFor,
@@ -133,13 +134,56 @@ export function appendBacklogItems(agentRoot, items, deps = {}) {
133
134
  }
134
135
  }
135
136
 
136
- /** Open items already on an objective, from the goals queue. Never throws. */
137
+ /**
138
+ * The queue the EXECUTION LADDER writes into when it decides a directed event is
139
+ * work rather than conversation (`lib/execution/effects.scheduleToQueue`).
140
+ *
141
+ * It is read here, and not only in the backlog sweep, because reactive work and
142
+ * self-directed work advance the SAME objectives and must be scored the same
143
+ * way. Reading only `goals.yaml` meant a board item the ladder queued could
144
+ * never be counted as an intervention: `journalRealizedDeltas` iterates
145
+ * `openItemsFor`, so an item outside that view never reached
146
+ * `lib/kpi.recordIntervention` and the agent could not learn from anything it
147
+ * did REACTIVELY — only from what it planned for itself.
148
+ */
149
+ export const INBOUND_QUEUE_REL = join("state", "queues", "inbound.yaml");
150
+
151
+ /**
152
+ * Open items already on an objective, from BOTH work queues (goal-steward and
153
+ * execution-ladder). Never throws.
154
+ *
155
+ * Rows are selected by their `advances: [...]` list, so a queue row that names
156
+ * no objective is correctly invisible here whichever file it came from.
157
+ */
137
158
  export function openItemsFor(agentRoot, objectiveKey) {
138
- const path = join(resolveAgentRoot(agentRoot), GOALS_QUEUE_REL);
139
- if (!existsSync(path)) return [];
140
- let body;
141
- try { body = readFileSync(path, "utf-8"); } catch { return []; }
159
+ const root = resolveAgentRoot(agentRoot);
142
160
  const out = [];
161
+ for (const rel of [GOALS_QUEUE_REL, INBOUND_QUEUE_REL]) {
162
+ collectOpenItems(join(root, rel), objectiveKey, out);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Strip a YAML scalar's surrounding quotes.
169
+ *
170
+ * The two writers that now feed this reader disagree: `renderQueueItems` above
171
+ * emits bare scalars (`id: inb-x`), `lib/execution/effects.scheduleToQueue`
172
+ * emits quoted ones (`id: "inb-x"`) because its ids contain `#` and `.`. The
173
+ * `(\S+)` captures include the quotes, and the id is used as the intervention's
174
+ * `taskId` AND as half of the `${taskId}::${window}` dedupe key — so an unstripped
175
+ * quote would both corrupt the ledger row and defeat the dedupe.
176
+ */
177
+ function unquote(v) {
178
+ const s = String(v == null ? "" : v).trim();
179
+ return s.replace(/^["']/, "").replace(/["']$/, "");
180
+ }
181
+
182
+ /** Parse one queue file's open items into `out`. Never throws. */
183
+ function collectOpenItems(path, objectiveKey, out) {
184
+ if (!existsSync(path)) return;
185
+ let body;
186
+ try { body = readFileSync(path, "utf-8"); } catch { return; }
143
187
  for (const block of body.split(/^\s*-\s+/m).slice(1)) {
144
188
  const status = block.match(/^\s*status:\s*(\S+)/m);
145
189
  if (!status || !["open", "in_progress"].includes(status[1])) continue;
@@ -148,9 +192,98 @@ export function openItemsFor(agentRoot, objectiveKey) {
148
192
  if (objectiveKey && !list.includes(objectiveKey)) continue;
149
193
  const title = block.match(/^\s*title:\s*"?(.+?)"?\s*$/m);
150
194
  const id = block.match(/^\s*id:\s*(\S+)/m);
151
- out.push({ id: id ? id[1] : null, title: title ? title[1] : "", advances: list, status: status[1] });
195
+ // `expected_delta` and `obligation_key` are not decoration: they are what
196
+ // `journalRealizedDeltas` scores the intervention AGAINST. Dropping them here
197
+ // is why the intervention ledger stayed empty — the loop could see that an
198
+ // item was open but not what it had promised to move.
199
+ const expected = block.match(/^\s*expected_delta:\s*(\S+)/m);
200
+ const obligation = block.match(/^\s*obligation_key:\s*(\S+)/m);
201
+ out.push({
202
+ id: id ? unquote(id[1]) : null,
203
+ title: title ? title[1] : "",
204
+ advances: list.map(unquote),
205
+ status: status[1],
206
+ expectedDelta: expected && expected[1] !== "null" ? Number(expected[1]) : null,
207
+ obligationKey: obligation && obligation[1] !== "null" ? unquote(obligation[1]) : null,
208
+ });
152
209
  }
153
- return out;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // The learning signal
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Score every in-flight intervention against the sample that just landed.
218
+ *
219
+ * `lib/kpi.recordIntervention` had ZERO callers, so `readInterventions` (which
220
+ * step 4 already consults, and which `defaultProposeCandidates` counts prior
221
+ * failures from) always read an empty file. The loop could plan but never learn:
222
+ * an intervention that made a number WORSE was proposed again, identically,
223
+ * forever.
224
+ *
225
+ * The delta is measured on the GAP, not the raw value, so `direction` is
226
+ * honoured: positive `realizedDelta` = the gap closed, whatever way the metric
227
+ * points. Idempotent on `(taskId, window)` — a second steward pass inside the
228
+ * same measurement window re-scores nothing.
229
+ *
230
+ * @param {object} o - { agentRoot, objective, seriesBefore, seriesAfter, window, openItems }
231
+ * @param {object} [deps] - { now, log }
232
+ * @returns {{recorded:number, skipped:number, reason:string, rows:object[]}}
233
+ */
234
+ export function journalRealizedDeltas(o = {}, deps = {}) {
235
+ const log = logOf(deps);
236
+ const { agentRoot, objective, seriesBefore, seriesAfter, window } = o;
237
+ const openItems = Array.isArray(o.openItems) ? o.openItems : [];
238
+ if (openItems.length === 0) {
239
+ return { recorded: 0, skipped: 0, reason: "no in-flight interventions to score", rows: [] };
240
+ }
241
+
242
+ const before = gapFor(objective, seriesBefore || [], deps);
243
+ const after = gapFor(objective, seriesAfter || [], deps);
244
+ if (before.gap == null || after.gap == null) {
245
+ log(
246
+ "info",
247
+ `[goals] "${objective.key}": a sample landed for ${window} but there is no PRIOR measured gap — the in-flight work cannot be scored yet (this is the first data point, not a failure)`
248
+ );
249
+ return { recorded: 0, skipped: openItems.length, reason: "no prior gap", rows: [] };
250
+ }
251
+ if (before.window === after.window) {
252
+ return { recorded: 0, skipped: openItems.length, reason: "same window — nothing new to score", rows: [] };
253
+ }
254
+
255
+ const realizedDelta = +(before.gap - after.gap).toFixed(6);
256
+ const priors = readInterventions(agentRoot, objective.key, 0);
257
+ const already = new Set(priors.map((p) => `${p.taskId}::${p.window}`));
258
+
259
+ const rows = [];
260
+ let skipped = 0;
261
+ for (const item of openItems) {
262
+ if (already.has(`${item.id}::${window}`)) { skipped += 1; continue; }
263
+ const res = recordIntervention(
264
+ agentRoot,
265
+ {
266
+ objectiveKey: objective.key,
267
+ taskId: item.id,
268
+ obligationKey: item.obligationKey || `outcome.${objective.key}`,
269
+ expectedDelta: item.expectedDelta,
270
+ realizedDelta,
271
+ measuredAt: after.window,
272
+ window,
273
+ basis: `gap ${before.gap} (${before.window}) → ${after.gap} (${after.window})`,
274
+ },
275
+ deps
276
+ );
277
+ if (res.recorded) rows.push(res.record);
278
+ }
279
+ if (rows.length > 0) {
280
+ const verdict = realizedDelta > 0 ? "closed" : realizedDelta < 0 ? "WIDENED" : "did not move";
281
+ log(
282
+ "info",
283
+ `[goals] "${objective.key}": ${rows.length} in-flight intervention(s) scored — the gap ${verdict} by ${Math.abs(realizedDelta)} between ${before.window} and ${after.window}`
284
+ );
285
+ }
286
+ return { recorded: rows.length, skipped, reason: "scored", rows };
154
287
  }
155
288
 
156
289
  // ---------------------------------------------------------------------------
@@ -241,6 +374,7 @@ export async function runGoalSteward(deps = {}) {
241
374
  staleness: null,
242
375
  validation: null,
243
376
  measured: [],
377
+ interventions: [],
244
378
  gaps: [],
245
379
  decisions: [],
246
380
  candidates: 0,
@@ -303,6 +437,9 @@ export async function runGoalSteward(deps = {}) {
303
437
  const proposedRows = [];
304
438
 
305
439
  for (const objective of objectives) {
440
+ // Hoisted above MEASURE: the in-flight items are what a new sample SCORES.
441
+ const openItems = openItemsFor(agentRoot, objective.key);
442
+
306
443
  // --- 1. MEASURE --------------------------------------------------------
307
444
  let series = readSeries(agentRoot, objective.key);
308
445
  const due = isDue(objective, series, deps);
@@ -320,7 +457,19 @@ export async function runGoalSteward(deps = {}) {
320
457
  try { await deps.mirrorSample({ objective, value: m.value, window: due.window, source: m.source, evidence: m.evidence }); }
321
458
  catch (err) { log("warn", `[goal-steward] mandate.sample mirror failed for "${objective.key}": ${err && err.message ? err.message : err}`); }
322
459
  }
460
+ const seriesBefore = series;
323
461
  series = readSeries(agentRoot, objective.key);
462
+ // --- 1b. LEARN: score the in-flight work against the new number ----
463
+ const scored = journalRealizedDeltas(
464
+ { agentRoot, objective, seriesBefore, seriesAfter: series, window: due.window, openItems },
465
+ { now: deps.now, log }
466
+ );
467
+ if (scored.recorded > 0) {
468
+ report.interventions.push({ objectiveKey: objective.key, window: due.window, ...scored, rows: undefined });
469
+ for (const row of scored.rows) {
470
+ audit({ decision: "intervention_scored", objectiveKey: objective.key, obligationKey: row.obligationKey, detail: row });
471
+ }
472
+ }
324
473
  } else {
325
474
  report.measured.push({ objectiveKey: objective.key, ok: false, reason: m.reason });
326
475
  report.drift.push({ kind: m.reason === "unreachable-capability" ? "unreachable_capability" : "stale_sensor", key: objective.key, reason: m.reason });
@@ -334,7 +483,6 @@ export async function runGoalSteward(deps = {}) {
334
483
  audit({ decision: "gap", objectiveKey: objective.key, detail: gap });
335
484
 
336
485
  // --- 3. DECIDE ---------------------------------------------------------
337
- const openItems = openItemsFor(agentRoot, objective.key);
338
486
  const decision = decideForGap(gap, { coverage: openItems.length });
339
487
  report.decisions.push({ objectiveKey: objective.key, ...decision, coverage: openItems.length });
340
488
 
@@ -356,7 +504,9 @@ export async function runGoalSteward(deps = {}) {
356
504
  ];
357
505
  audit({ decision: "candidate_rejected", objectiveKey: objective.key, detail: { gate: "trend", reason: decision.reason, escalating: true } });
358
506
  if (enforcement === "active") {
359
- const esc = await raiseEscalation({ objectiveKey: objective.key, question, options, gap }, deps);
507
+ // `agentRoot` is load-bearing, not decoration: it is how the escalation
508
+ // primitive resolves this seat's org credential (collaborate.orgConn).
509
+ const esc = await raiseEscalation({ objectiveKey: objective.key, question, options, gap }, { ...deps, agentRoot, memberId, log });
360
510
  report.escalations.push({ objectiveKey: objective.key, ...esc });
361
511
  audit({ decision: "routed", objectiveKey: objective.key, detail: { primitive: esc.method || "escalation.create", ok: esc.ok, reason: esc.reason } });
362
512
  } else {
@@ -532,6 +682,7 @@ export default {
532
682
  renderQueueItems,
533
683
  appendBacklogItems,
534
684
  openItemsFor,
685
+ journalRealizedDeltas,
535
686
  defaultProposeCandidates,
536
687
  runGoalSteward,
537
688
  };
@@ -29,11 +29,13 @@ import {
29
29
  renderQueueItems,
30
30
  appendBacklogItems,
31
31
  openItemsFor,
32
+ journalRealizedDeltas,
32
33
  defaultProposeCandidates,
33
34
  GOALS_QUEUE_REL,
34
35
  PROPOSED_REL,
35
36
  } from "./loop.mjs";
36
37
  import { parseQueueItems } from "../backlog.mjs";
38
+ import { readInterventions } from "../kpi.mjs";
37
39
 
38
40
  // ---------------------------------------------------------------------------
39
41
  // Fixtures
@@ -342,10 +344,14 @@ test("enforcement:active turns a measured gap into a routed, provenance-linked b
342
344
  assert.equal(calls.board.length, 1);
343
345
  const why = calls.board[0].why;
344
346
  assert.equal(why.reason, "kpi-gap");
345
- assert.equal(why.objectiveKey, "pipeline-coverage");
346
347
  assert.equal(why.clause, "cs_12");
347
- assert.equal(why.obligationKey, "outcome.pipeline-coverage");
348
- assert.equal(why.adoptedBy, BOSS);
348
+ // hq's `why` block is CLOSED ({reason, options, clause, wouldChange}); the
349
+ // keys it has no column for ride in `detail` rather than being sent and
350
+ // silently dropped — or, as before this fix, rejected BAD_REQUEST wholesale.
351
+ const detail = calls.board[0].detail;
352
+ assert.match(detail, /Advances objective: pipeline-coverage/);
353
+ assert.match(detail, /Obligation: outcome\.pipeline-coverage/);
354
+ assert.equal(calls.board[0].assigneeId, SELF);
349
355
 
350
356
  // And the local queue item the backlog-executor will sweep.
351
357
  const parsed = parseQueueItems(readFileSync(join(root, GOALS_QUEUE_REL), "utf-8"), "goals.yaml");
@@ -717,3 +723,123 @@ test("WIP cap: a second run does not pile more work onto the same objective", as
717
723
  assert.notEqual(second.decisions[0].action, "create");
718
724
  } finally { cleanup(root); }
719
725
  });
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // THE LEARNING SIGNAL — recordIntervention had zero callers
729
+ // ---------------------------------------------------------------------------
730
+
731
+ test("openItemsFor carries the expected delta and obligation an intervention is scored against", () => {
732
+ const root = makeRoot();
733
+ try {
734
+ appendBacklogItems(root, [{
735
+ id: "goal-1", title: "Close the gap", status: "open", priority: "high",
736
+ next_action: "Close the gap", advances: ["pipeline-coverage"],
737
+ expected_delta: 0.75, obligation_key: "outcome.pipeline-coverage",
738
+ disposition: "SELF", rung: 3, why: { reason: "kpi-gap" }, created: "2026-08-11",
739
+ }]);
740
+ const [item] = openItemsFor(root, "pipeline-coverage");
741
+ assert.equal(item.id, "goal-1");
742
+ assert.equal(item.expectedDelta, 0.75);
743
+ assert.equal(item.obligationKey, "outcome.pipeline-coverage");
744
+ } finally { cleanup(root); }
745
+ });
746
+
747
+ test("a new sample SCORES the work in flight — the realized delta lands in the ledger", () => {
748
+ const root = makeRoot();
749
+ try {
750
+ const objective = { key: "pipeline-coverage", target: 3, direction: "up", tolerance: 0.2, cadence: "weekly" };
751
+ const before = [{ window: "2026-W32", value: 1.5, source: "method", at: "2026-08-04T09:00:00.000Z" }];
752
+ const after = [...before, { window: "2026-W33", value: 2.5, source: "method", at: "2026-08-11T09:00:00.000Z" }];
753
+ const openItems = [{ id: "goal-1", expectedDelta: 0.75, obligationKey: "outcome.pipeline-coverage" }];
754
+
755
+ const r = journalRealizedDeltas(
756
+ { agentRoot: root, objective, seriesBefore: before, seriesAfter: after, window: "2026-W33", openItems },
757
+ { now: () => NOW },
758
+ );
759
+ assert.equal(r.recorded, 1);
760
+ // gap 1.5 → 0.5: the gap CLOSED by 1.0, whatever way the metric points.
761
+ assert.equal(r.rows[0].realizedDelta, 1);
762
+ assert.equal(r.rows[0].expectedDelta, 0.75);
763
+ assert.equal(r.rows[0].taskId, "goal-1");
764
+ assert.equal(r.rows[0].window, "2026-W33");
765
+
766
+ // Idempotent on (taskId, window): a second pass in the same window re-scores nothing.
767
+ const again = journalRealizedDeltas(
768
+ { agentRoot: root, objective, seriesBefore: before, seriesAfter: after, window: "2026-W33", openItems },
769
+ { now: () => NOW },
770
+ );
771
+ assert.equal(again.recorded, 0);
772
+ assert.equal(readInterventions(root, "pipeline-coverage", 0).length, 1);
773
+ } finally { cleanup(root); }
774
+ });
775
+
776
+ test("an intervention that made the number WORSE is recorded as a negative delta", () => {
777
+ const root = makeRoot();
778
+ try {
779
+ const objective = { key: "pipeline-coverage", target: 3, direction: "up", tolerance: 0.2, cadence: "weekly" };
780
+ const r = journalRealizedDeltas(
781
+ {
782
+ agentRoot: root, objective,
783
+ seriesBefore: [{ window: "2026-W32", value: 2.5, source: "method", at: "2026-08-04T09:00:00.000Z" }],
784
+ seriesAfter: [{ window: "2026-W32", value: 2.5, source: "method", at: "2026-08-04T09:00:00.000Z" },
785
+ { window: "2026-W33", value: 1.5, source: "method", at: "2026-08-11T09:00:00.000Z" }],
786
+ window: "2026-W33",
787
+ openItems: [{ id: "goal-1", expectedDelta: 0.75 }],
788
+ },
789
+ { now: () => NOW },
790
+ );
791
+ assert.equal(r.rows[0].realizedDelta, -1);
792
+ // …and the planner sees it, which is the whole point of the ledger.
793
+ const priors = readInterventions(root, "pipeline-coverage", 3);
794
+ const candidates = defaultProposeCandidates({ objective, gap: { gap: 1.5, trend: "worsening", value: 1.5, target: 3 }, interventions: priors });
795
+ assert.equal(candidates[0].why.priorFailedInterventions, 1);
796
+ assert.equal(candidates[0].need.openEnded, true, "a repeat failure must stop being planned as a one-shot");
797
+ } finally { cleanup(root); }
798
+ });
799
+
800
+ test("the FIRST data point scores nothing — there is no prior gap, and that is not a failure", () => {
801
+ const root = makeRoot();
802
+ const logs = [];
803
+ try {
804
+ const r = journalRealizedDeltas(
805
+ {
806
+ agentRoot: root,
807
+ objective: { key: "pipeline-coverage", target: 3, direction: "up", cadence: "weekly" },
808
+ seriesBefore: [],
809
+ seriesAfter: [{ window: "2026-W33", value: 1.5, source: "method", at: "2026-08-11T09:00:00.000Z" }],
810
+ window: "2026-W33",
811
+ openItems: [{ id: "goal-1", expectedDelta: 0.75 }],
812
+ },
813
+ { now: () => NOW, log: (l, m) => logs.push(`${l}:${m}`) },
814
+ );
815
+ assert.equal(r.recorded, 0);
816
+ assert.equal(r.reason, "no prior gap");
817
+ assert.ok(logs.some((l) => /first data point, not a failure/.test(l)), "the skip must be logged, not silent");
818
+ assert.equal(readInterventions(root, "pipeline-coverage", 0).length, 0);
819
+ } finally { cleanup(root); }
820
+ });
821
+
822
+ test("the loop journals interventions on its own — end to end through runGoalSteward", async () => {
823
+ const root = makeRoot();
824
+ try {
825
+ seedSeries(root, "pipeline-coverage", [
826
+ { window: "2026-W32", value: 1.5, source: "method", at: "2026-08-04T09:00:00.000Z" },
827
+ ]);
828
+ appendBacklogItems(root, [{
829
+ id: "goal-prior", title: "A prior intervention", status: "open", priority: "normal",
830
+ next_action: "…", advances: ["pipeline-coverage"], expected_delta: 0.75,
831
+ obligation_key: "outcome.pipeline-coverage", disposition: "SELF", rung: 3,
832
+ why: { reason: "kpi-gap" }, created: "2026-08-04",
833
+ }]);
834
+
835
+ const { deps } = spyDeps({ sensors: sensors(2.5) });
836
+ const r = await runGoalSteward({ ...deps, agentRoot: root, cacheRecord: mandate(), enforcement: "active" });
837
+
838
+ assert.equal(r.interventions.length, 1);
839
+ assert.equal(r.interventions[0].recorded, 1);
840
+ const rows = readInterventions(root, "pipeline-coverage", 0);
841
+ assert.equal(rows.length, 1);
842
+ assert.equal(rows[0].taskId, "goal-prior");
843
+ assert.equal(rows[0].realizedDelta, 1);
844
+ } finally { cleanup(root); }
845
+ });