@groundnuty/macf 0.2.35 → 0.2.37

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 (105) hide show
  1. package/dist/.build-info.json +2 -2
  2. package/dist/cli/claude-sh.d.ts +12 -10
  3. package/dist/cli/claude-sh.d.ts.map +1 -1
  4. package/dist/cli/claude-sh.js +26 -13
  5. package/dist/cli/claude-sh.js.map +1 -1
  6. package/dist/cli/commands/certs.js +3 -3
  7. package/dist/cli/commands/certs.js.map +1 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +10 -0
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/monitor.d.ts +16 -0
  12. package/dist/cli/commands/monitor.d.ts.map +1 -0
  13. package/dist/cli/commands/monitor.js +96 -0
  14. package/dist/cli/commands/monitor.js.map +1 -0
  15. package/dist/cli/commands/propose.d.ts +21 -0
  16. package/dist/cli/commands/propose.d.ts.map +1 -0
  17. package/dist/cli/commands/propose.js +128 -0
  18. package/dist/cli/commands/propose.js.map +1 -0
  19. package/dist/cli/commands/rules-refresh.d.ts +1 -0
  20. package/dist/cli/commands/rules-refresh.d.ts.map +1 -1
  21. package/dist/cli/commands/rules-refresh.js +22 -1
  22. package/dist/cli/commands/rules-refresh.js.map +1 -1
  23. package/dist/cli/commands/update.d.ts.map +1 -1
  24. package/dist/cli/commands/update.js +23 -2
  25. package/dist/cli/commands/update.js.map +1 -1
  26. package/dist/cli/env-files-update.d.ts.map +1 -1
  27. package/dist/cli/env-files-update.js +5 -1
  28. package/dist/cli/env-files-update.js.map +1 -1
  29. package/dist/cli/env-files.d.ts +38 -13
  30. package/dist/cli/env-files.d.ts.map +1 -1
  31. package/dist/cli/env-files.js +73 -14
  32. package/dist/cli/env-files.js.map +1 -1
  33. package/dist/cli/index.js +109 -0
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/cli/monitor/digest.d.ts +89 -0
  36. package/dist/cli/monitor/digest.d.ts.map +1 -0
  37. package/dist/cli/monitor/digest.js +232 -0
  38. package/dist/cli/monitor/digest.js.map +1 -0
  39. package/dist/cli/monitor/github-reader.d.ts +38 -0
  40. package/dist/cli/monitor/github-reader.d.ts.map +1 -0
  41. package/dist/cli/monitor/github-reader.js +65 -0
  42. package/dist/cli/monitor/github-reader.js.map +1 -0
  43. package/dist/cli/monitor/reflections.d.ts +18 -0
  44. package/dist/cli/monitor/reflections.d.ts.map +1 -0
  45. package/dist/cli/monitor/reflections.js +72 -0
  46. package/dist/cli/monitor/reflections.js.map +1 -0
  47. package/dist/cli/monitor/run.d.ts +30 -0
  48. package/dist/cli/monitor/run.d.ts.map +1 -0
  49. package/dist/cli/monitor/run.js +67 -0
  50. package/dist/cli/monitor/run.js.map +1 -0
  51. package/dist/cli/project-rules.d.ts +105 -0
  52. package/dist/cli/project-rules.d.ts.map +1 -0
  53. package/dist/cli/project-rules.js +305 -0
  54. package/dist/cli/project-rules.js.map +1 -0
  55. package/dist/cli/propose/candidates.d.ts +95 -0
  56. package/dist/cli/propose/candidates.d.ts.map +1 -0
  57. package/dist/cli/propose/candidates.js +117 -0
  58. package/dist/cli/propose/candidates.js.map +1 -0
  59. package/dist/cli/propose/invariants.d.ts +49 -0
  60. package/dist/cli/propose/invariants.d.ts.map +1 -0
  61. package/dist/cli/propose/invariants.js +154 -0
  62. package/dist/cli/propose/invariants.js.map +1 -0
  63. package/dist/cli/propose/proposal-writer.d.ts +33 -0
  64. package/dist/cli/propose/proposal-writer.d.ts.map +1 -0
  65. package/dist/cli/propose/proposal-writer.js +53 -0
  66. package/dist/cli/propose/proposal-writer.js.map +1 -0
  67. package/dist/cli/propose/report.d.ts +49 -0
  68. package/dist/cli/propose/report.d.ts.map +1 -0
  69. package/dist/cli/propose/report.js +227 -0
  70. package/dist/cli/propose/report.js.map +1 -0
  71. package/dist/cli/propose/run.d.ts +41 -0
  72. package/dist/cli/propose/run.d.ts.map +1 -0
  73. package/dist/cli/propose/run.js +62 -0
  74. package/dist/cli/propose/run.js.map +1 -0
  75. package/dist/cli/settings-writer.d.ts +87 -6
  76. package/dist/cli/settings-writer.d.ts.map +1 -1
  77. package/dist/cli/settings-writer.js +141 -6
  78. package/dist/cli/settings-writer.js.map +1 -1
  79. package/dist/reconciler/parse-delivered.d.ts +32 -0
  80. package/dist/reconciler/parse-delivered.d.ts.map +1 -0
  81. package/dist/reconciler/parse-delivered.js +18 -0
  82. package/dist/reconciler/parse-delivered.js.map +1 -0
  83. package/dist/reconciler/parse-processed.d.ts +57 -0
  84. package/dist/reconciler/parse-processed.d.ts.map +1 -0
  85. package/dist/reconciler/parse-processed.js +41 -0
  86. package/dist/reconciler/parse-processed.js.map +1 -0
  87. package/dist/reconciler/reconcile.d.ts +130 -0
  88. package/dist/reconciler/reconcile.d.ts.map +1 -0
  89. package/dist/reconciler/reconcile.js +119 -0
  90. package/dist/reconciler/reconcile.js.map +1 -0
  91. package/dist/reconciler/run.d.ts +23 -0
  92. package/dist/reconciler/run.d.ts.map +1 -0
  93. package/dist/reconciler/run.js +273 -0
  94. package/dist/reconciler/run.js.map +1 -0
  95. package/package.json +2 -2
  96. package/plugin/rules/coordination.md +22 -13
  97. package/plugin/rules/gh-token-attribution-traps.md +4 -0
  98. package/plugin/rules/mention-routing-hygiene.md +2 -0
  99. package/plugin/rules/observability-wiring.md +3 -3
  100. package/plugin/rules/reflection-staging.md +65 -0
  101. package/plugin/rules/silent-fallback-hazards.md +64 -8
  102. package/scripts/check-auditor-never-acts.sh +167 -0
  103. package/scripts/check-gh-attribution.sh +230 -0
  104. package/scripts/emit-turn-receipt.sh +81 -0
  105. package/scripts/harvest-reflection.sh +125 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Route-receipt reconciler — pure drop-detection logic (groundnuty/macf#444
3
+ * Option D, piece 4).
4
+ *
5
+ * Closes the structural gap: a `turn_processed` span nobody queries is
6
+ * *forensic*, not *surfacing*. This is the consumer that turns the ABSENCE of
7
+ * a receipt span into a structural signal.
8
+ *
9
+ * It joins two sets on `(runId, agent)`:
10
+ * - DELIVERED — the routes the macf-actions router LOGGED as delivered
11
+ * (`Routed … to <AGENT> via helper|inline`), each with the route run's
12
+ * timestamp. (Source: GitHub run logs — see sources.ts.)
13
+ * - PROCESSED — the `turn_processed` spans the substrate UserPromptSubmit
14
+ * hook emitted when the marked prompt became a turn. (Source: Tempo
15
+ * TraceQL `{name="turn_processed" && resource.service.namespace="macf"}`.)
16
+ *
17
+ * A DELIVERED route is a **drop** iff it has no matching PROCESSED span AND it
18
+ * is older than the open-threshold. The threshold is load-bearing: a
19
+ * *legitimately busy* agent processes the ping late (the #437 co-verify showed
20
+ * ~4 min legit latency), so a recent unmatched delivery is "still in flight",
21
+ * NOT a drop. The threshold must exceed observed busy-turn latency (≈15–20 min,
22
+ * tunable). This + the per-run recompute gives the open-on-absence /
23
+ * self-close-on-appearance behaviour (e2e.yml #166/#163 shape): while drops
24
+ * exist the workflow holds an incident open; once a late span lands (or none
25
+ * remain) the next run reports zero drops and the workflow self-closes.
26
+ *
27
+ * The join key is the **marker-parsed** `(runId, agent)` — both sides derive
28
+ * from the router's `[macf-route:${GITHUB_RUN_ID}:${AGENT_NAME}]` marker /
29
+ * `Routed … to <AGENT>` log line, so they match by construction. (NOT the
30
+ * receiving agent's `MACF_AGENT_NAME`, which is unset on substrate — see #444
31
+ * discussion + the #451 hook.)
32
+ *
33
+ * This module is intentionally PURE (no I/O) so the load-bearing logic is fully
34
+ * unit-testable; the GitHub/Tempo fetchers + the incident open/self-close live
35
+ * in sources.ts / run.ts / the scheduled workflow.
36
+ */
37
+ /** Canonical join key for a `(runId, agent)` pair. */
38
+ export function receiptKey(p) {
39
+ return `${p.runId}:${p.agent}`;
40
+ }
41
+ /**
42
+ * macf#479 coalesced-turn gate. For a would-be drop, find a *different*
43
+ * delivery to the same agent within ±proximityMs that WAS receipted. Its
44
+ * existence means the agent was alive + processing routed turns around the
45
+ * delivery, so this one benignly coalesced/clobbered into a sibling's turn.
46
+ *
47
+ * Crucially it counts only ROUTED receipts (the `processed` set), so an
48
+ * RC-bound agent — alive on the RC-SDK but silently dropping ALL its tmux
49
+ * pings, none of which receipt — has NO receipted sibling near a real drop and
50
+ * is correctly NOT suppressed. (Returns null ⇒ no benign sibling ⇒ real drop.)
51
+ */
52
+ function findReceiptedSibling(route, inScope, processedKeys, proximityMs) {
53
+ let best = null;
54
+ for (const sib of inScope) {
55
+ if (sib.runId === route.runId)
56
+ continue; // not itself
57
+ if (sib.agent !== route.agent)
58
+ continue; // same agent only
59
+ const deltaMs = sib.deliveredAtMs - route.deliveredAtMs;
60
+ if (Math.abs(deltaMs) > proximityMs)
61
+ continue; // outside the window
62
+ if (!processedKeys.has(receiptKey(sib)))
63
+ continue; // sibling must be receipted
64
+ // Prefer the temporally-nearest receipted sibling for the log.
65
+ if (best === null || Math.abs(deltaMs) < Math.abs(best.deltaMs)) {
66
+ best = { route, siblingRunId: sib.runId, deltaMs };
67
+ }
68
+ }
69
+ return best;
70
+ }
71
+ /**
72
+ * Pure reconciliation: which delivered routes are drops (no receipt span +
73
+ * older than the open-threshold) vs. still-in-flight (no receipt yet but
74
+ * within the threshold) vs. processed (have a receipt).
75
+ */
76
+ export function reconcile(delivered, processed, opts) {
77
+ const processedKeys = new Set(processed.map(receiptKey));
78
+ // Deployment-boundary guard (groundnuty/macf#444): drop routes delivered
79
+ // before the receipt mechanism went live from scope entirely — those prompts
80
+ // had no marker and hit hookless sessions, so a missing receipt is EXPECTED,
81
+ // not a drop. Else the first reconcile after enabling would false-alarm on
82
+ // every pre-deployment route in the lookback window.
83
+ const sinceMs = opts.sinceMs;
84
+ const inScope = sinceMs ? delivered.filter((r) => r.deliveredAtMs >= sinceMs) : delivered;
85
+ const proximityMs = opts.proximityMs ?? 0;
86
+ const drops = [];
87
+ const inFlight = [];
88
+ const suppressed = [];
89
+ for (const route of inScope) {
90
+ if (processedKeys.has(receiptKey(route)))
91
+ continue; // receipt landed — fine
92
+ const ageMs = opts.nowMs - route.deliveredAtMs;
93
+ if (ageMs <= opts.openThresholdMs) {
94
+ inFlight.push(route); // unmatched but young → busy agent may process late
95
+ continue;
96
+ }
97
+ // Unmatched + past the threshold → a would-be drop. macf#479: suppress it
98
+ // (benign coalesce) iff a receipted sibling delivery to the same agent sits
99
+ // within the proximity window — the agent was demonstrably processing routed
100
+ // turns then. No receipted sibling (lone / offline / RC-bound) ⇒ real drop.
101
+ const sibling = proximityMs > 0
102
+ ? findReceiptedSibling(route, inScope, processedKeys, proximityMs)
103
+ : null;
104
+ if (sibling !== null) {
105
+ suppressed.push(sibling);
106
+ }
107
+ else {
108
+ drops.push(route);
109
+ }
110
+ }
111
+ return {
112
+ drops,
113
+ inFlight,
114
+ suppressed,
115
+ deliveredCount: inScope.length,
116
+ processedCount: processedKeys.size,
117
+ };
118
+ }
119
+ //# sourceMappingURL=reconcile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconcile.js","sourceRoot":"","sources":["../../src/reconciler/reconcile.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /** DELIVERED set: parse the router runs' `Routed … to <AGENT>` success lines.
3
+ * `truncated` is true when `gh run list` hit its page cap — the set is then
4
+ * UNKNOWABLE (older routes fell off the page), so the caller must NOT flag
5
+ * drops this run. Symmetric to the Tempo `tempo_ok` guard, on the delivered
6
+ * side (Pattern A): silently bounding coverage would let a real drop hide. */
7
+ /**
8
+ * Window-aware DELIVERED truncation (macf#477). The set is UNKNOWABLE only when
9
+ * the run-list page is full AND its OLDEST run is still inside the lookback
10
+ * window — i.e. older in-window runs were pushed off the page. When the oldest
11
+ * run on the page predates the window start, the window is fully covered and
12
+ * the page is NOT truncated (the older entries are simply out-of-window).
13
+ *
14
+ * The previous check (`runs.length >= LIMIT`) measured LIFETIME page-fullness,
15
+ * so it went dark — `delivered_ok=false` every run — in any repo with >LIMIT
16
+ * lifetime router runs regardless of in-window count, making the reconciler a
17
+ * permanent no-op (and blocking #462 self-close). `gh run list` returns
18
+ * newest-first, so `runs.at(-1)` is the oldest on the page.
19
+ */
20
+ export declare function isDeliveredTruncated(runs: ReadonlyArray<{
21
+ createdAt: string;
22
+ }>, nowMs: number, lookbackMs: number, limit: number): boolean;
23
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/reconciler/run.ts"],"names":[],"mappings":";AAkGA;;;;+EAI+E;AAC/E;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,EAC1C,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,GACZ,OAAO,CAOT"}
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Route-receipt reconciler entrypoint (groundnuty/macf#444 Option D, piece 4).
4
+ *
5
+ * Invoked by the scheduled `route-reconciler.yml` workflow. Thin I/O glue over
6
+ * the unit-tested pure pieces (reconcile / parse-delivered / parse-processed):
7
+ *
8
+ * 1. DELIVERED — `gh run list` the `agent-router` runs (success, recent) +
9
+ * `gh run view --log` each → parse the `Routed … to <AGENT>` lines.
10
+ * 2. PROCESSED — Tempo `/api/search` for `turn_processed` spans → parse the
11
+ * `(routed_run_id, agent)` attrs.
12
+ * 3. reconcile() → drops (delivered, no receipt, older than the open-threshold).
13
+ * 4. Emit `tempo_ok` + `drops_count` / `drops_json` to `$GITHUB_OUTPUT` for
14
+ * the workflow's open-on-absence / self-close-on-appearance incident steps.
15
+ *
16
+ * Pattern-A safety (false-drop avoidance): any Tempo problem — unreachable,
17
+ * HTTP error, OR a result hitting the `limit` (truncation) — makes the
18
+ * PROCESSED set UNKNOWABLE, which is NOT the same as "zero drops". Treating it
19
+ * as empty would false-OPEN incidents (a truncated set reads as missing
20
+ * receipts) and treating it as `drops_count=0` would false-CLOSE a real
21
+ * incident on a transient outage. So all three paths emit `tempo_ok=false`;
22
+ * the workflow gates BOTH the open AND the self-close steps on
23
+ * `tempo_ok == 'true'`, making a Tempo problem a TRUE no-op (incidents left
24
+ * exactly as they are). Only a verified-complete query opens (real drops) or
25
+ * closes (verified zero).
26
+ *
27
+ * Config (env, set by the workflow):
28
+ * RECONCILER_REPO owner/repo (default $GITHUB_REPOSITORY)
29
+ * ROUTER_WORKFLOW router workflow file (default agent-router.yml)
30
+ * TEMPO_QUERY_ENDPOINT Tempo query base; default is the dedicated
31
+ * monitoring VM over Tailscale on the OTel-native
32
+ * port (no +10000 offset):
33
+ * http://orzech-dev-agents-monitoring.tail491af.ts.net:3200
34
+ * (macf#516 — the old k3d loopback default is DEAD)
35
+ * OPEN_THRESHOLD_MIN drop threshold, must exceed busy-turn latency (default 15)
36
+ * PROXIMITY_MIN macf#479 coalesced-turn gate: suppress a would-be drop
37
+ * when a receipted sibling delivery to the same agent is
38
+ * within ±this (default 5; must be < OPEN_THRESHOLD_MIN)
39
+ * LOOKBACK_MIN how far back to scan runs + Tempo (default 120). The
40
+ * DELIVERED query is scoped to this window via
41
+ * `gh run list --created`, so truncation means
42
+ * ">RUN_LIST_LIMIT router runs INSIDE the window"
43
+ * (genuine high volume), not lifetime page-fullness (macf#477)
44
+ * TEMPO_LIMIT Tempo search result cap (default 1000)
45
+ */
46
+ import { execFileSync } from 'node:child_process';
47
+ import { appendFileSync, realpathSync } from 'node:fs';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { reconcile } from './reconcile.js';
50
+ import { parseDeliveredFromLog } from './parse-delivered.js';
51
+ import { parseProcessedFromTempo, receiptsIfComplete } from './parse-processed.js';
52
+ const MIN = 60_000;
53
+ /** `gh run list` page cap. If the router bursts past this in the lookback
54
+ * window the DELIVERED set truncates silently (Pattern A on the delivered
55
+ * side: a missed delivery → its receipt is never looked for → a real drop is
56
+ * silently NOT flagged). We WARN when the cap is hit so the symmetry is
57
+ * observable, but don't abort: delivered-side truncation under-reports drops
58
+ * (false negative), which is far less harmful than the processed-side
59
+ * truncation that would over-report them (false incident). */
60
+ const RUN_LIST_LIMIT = 100;
61
+ const envStr = (name, def) => {
62
+ const v = process.env[name];
63
+ return v !== undefined && v.length > 0 ? v : def;
64
+ };
65
+ const REPO = envStr('RECONCILER_REPO', process.env['GITHUB_REPOSITORY'] ?? '');
66
+ const ROUTER_WORKFLOW = envStr('ROUTER_WORKFLOW', 'agent-router.yml');
67
+ const TEMPO = envStr('TEMPO_QUERY_ENDPOINT', 'http://orzech-dev-agents-monitoring.tail491af.ts.net:3200').replace(/\/+$/, '');
68
+ const OPEN_THRESHOLD_MS = Number(envStr('OPEN_THRESHOLD_MIN', '15')) * MIN;
69
+ const LOOKBACK_MS = Number(envStr('LOOKBACK_MIN', '120')) * MIN;
70
+ // macf#479 coalesced-turn gate: suppress a would-be drop when a receipted
71
+ // sibling delivery to the same agent is within ±this. Must be > the turn-batching
72
+ // window (~couple min) and < OPEN_THRESHOLD_MIN (so real lone/offline/RC-bound
73
+ // drops still flag). 0 ⇒ disabled.
74
+ const PROXIMITY_MS = Number(envStr('PROXIMITY_MIN', '5')) * MIN;
75
+ const TEMPO_LIMIT = Number(envStr('TEMPO_LIMIT', '1000'));
76
+ /** Deployment-boundary cutoff (groundnuty/macf#444): ignore routes delivered
77
+ * before the receipt mechanism went live. Set `RECONCILER_SINCE` to the
78
+ * hook-go-live time (ISO-8601 e.g. `2026-06-06T08:00:00Z`, or epoch ms) at the
79
+ * first `RECONCILER_ENABLED` flip — pre-deployment routes had no marker + hit
80
+ * hookless sessions, so their missing receipt is EXPECTED, not a drop. Without
81
+ * it, run 1 would false-alarm on every pre-deployment route in the lookback.
82
+ * Self-obsoletes once LOOKBACK_MIN slides fully past the cutoff; unset ⇒ no cutoff. */
83
+ const SINCE_MS = (() => {
84
+ const raw = process.env['RECONCILER_SINCE'];
85
+ if (!raw)
86
+ return undefined;
87
+ const ms = /^\d+$/.test(raw) ? Number(raw) : Date.parse(raw);
88
+ if (!Number.isFinite(ms)) {
89
+ console.error(`WARN: RECONCILER_SINCE="${raw}" not parseable (want ISO-8601 or epoch ms) — ignoring cutoff.`);
90
+ return undefined;
91
+ }
92
+ return ms;
93
+ })();
94
+ /** DELIVERED set: parse the router runs' `Routed … to <AGENT>` success lines.
95
+ * `truncated` is true when `gh run list` hit its page cap — the set is then
96
+ * UNKNOWABLE (older routes fell off the page), so the caller must NOT flag
97
+ * drops this run. Symmetric to the Tempo `tempo_ok` guard, on the delivered
98
+ * side (Pattern A): silently bounding coverage would let a real drop hide. */
99
+ /**
100
+ * Window-aware DELIVERED truncation (macf#477). The set is UNKNOWABLE only when
101
+ * the run-list page is full AND its OLDEST run is still inside the lookback
102
+ * window — i.e. older in-window runs were pushed off the page. When the oldest
103
+ * run on the page predates the window start, the window is fully covered and
104
+ * the page is NOT truncated (the older entries are simply out-of-window).
105
+ *
106
+ * The previous check (`runs.length >= LIMIT`) measured LIFETIME page-fullness,
107
+ * so it went dark — `delivered_ok=false` every run — in any repo with >LIMIT
108
+ * lifetime router runs regardless of in-window count, making the reconciler a
109
+ * permanent no-op (and blocking #462 self-close). `gh run list` returns
110
+ * newest-first, so `runs.at(-1)` is the oldest on the page.
111
+ */
112
+ export function isDeliveredTruncated(runs, nowMs, lookbackMs, limit) {
113
+ if (runs.length < limit)
114
+ return false; // page not full → window fully covered
115
+ const oldest = runs[runs.length - 1];
116
+ if (oldest === undefined)
117
+ return false;
118
+ const oldestMs = Date.parse(oldest.createdAt);
119
+ if (!Number.isFinite(oldestMs))
120
+ return true; // unparseable oldest → conservative: unknowable
121
+ return oldestMs > nowMs - lookbackMs; // oldest still in-window ⇒ older runs fell off ⇒ truncated
122
+ }
123
+ function fetchDelivered(nowMs) {
124
+ // Scope the query to the lookback window SERVER-SIDE (macf#477): the page then
125
+ // holds only in-window runs, so a full page genuinely means ">LIMIT deliveries
126
+ // INSIDE the window" — and we fetch logs only for in-window runs instead of all
127
+ // RUN_LIST_LIMIT (incl. out-of-window ones the old code fetched then discarded).
128
+ const windowStartIso = new Date(nowMs - LOOKBACK_MS).toISOString();
129
+ const listJson = execFileSync('gh', ['run', 'list', '--repo', REPO, '--workflow', ROUTER_WORKFLOW,
130
+ '--status', 'success', '--created', `>=${windowStartIso}`,
131
+ '--limit', String(RUN_LIST_LIMIT), '--json', 'databaseId,createdAt'], { encoding: 'utf-8' });
132
+ const runs = JSON.parse(listJson);
133
+ const truncated = isDeliveredTruncated(runs, nowMs, LOOKBACK_MS, RUN_LIST_LIMIT);
134
+ if (truncated) {
135
+ console.error(`WARN: >${RUN_LIST_LIMIT} router runs INSIDE the ${LOOKBACK_MS / MIN}-min window — DELIVERED set UNKNOWABLE (older in-window routes fell off the ${RUN_LIST_LIMIT}-run page). Emitting delivered_ok=false (no drops this run); narrow LOOKBACK_MIN or raise the cap.`);
136
+ }
137
+ const out = [];
138
+ for (const run of runs) {
139
+ const createdMs = Date.parse(run.createdAt);
140
+ if (!Number.isFinite(createdMs) || nowMs - createdMs > LOOKBACK_MS)
141
+ continue;
142
+ let log;
143
+ try {
144
+ log = execFileSync('gh', ['run', 'view', String(run.databaseId), '--repo', REPO, '--log'], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
145
+ }
146
+ catch {
147
+ continue; // log unavailable (expired/in-progress) — skip, not a drop signal
148
+ }
149
+ out.push(...parseDeliveredFromLog(log, String(run.databaseId), createdMs));
150
+ }
151
+ return { routes: out, truncated };
152
+ }
153
+ /** PROCESSED set: Tempo `turn_processed` spans → receipts. Aborts (no drops)
154
+ * on a Tempo failure to avoid false alarms (Pattern A). */
155
+ async function fetchProcessed(nowMs) {
156
+ const startSec = Math.floor((nowMs - LOOKBACK_MS) / 1000);
157
+ const endSec = Math.floor(nowMs / 1000);
158
+ const q = '{name="turn_processed" && resource.service.namespace="macf"} | select(span.routed_run_id, span.agent)';
159
+ const url = `${TEMPO}/api/search?q=${encodeURIComponent(q)}&start=${startSec}&end=${endSec}&limit=${TEMPO_LIMIT}`;
160
+ let res;
161
+ try {
162
+ res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
163
+ }
164
+ catch (err) {
165
+ // `fetch failed` is opaque — the real cause is in err.cause (undici wraps
166
+ // the network error). Surface its code + message so an unreachable Tempo
167
+ // tells us WHICH failure: ENOTFOUND (MagicDNS not resolving the host),
168
+ // ECONNREFUSED (nothing listening / not `tailscale serve`d), ETIMEDOUT
169
+ // (ACL/firewall dropping the connection), or AbortError (15s timeout).
170
+ const e = err;
171
+ const detail = e.cause?.code ?? e.cause?.message ?? e.name;
172
+ console.error(`WARN: Tempo query unreachable [${url}] — ${e.message}: ${detail} — PROCESSED set unknowable; NOT flagging drops this run (avoids false alarms).`);
173
+ return null;
174
+ }
175
+ if (!res.ok) {
176
+ console.error(`WARN: Tempo query HTTP ${res.status} — PROCESSED set unknowable; treating as Tempo-unknown (true no-op this run).`);
177
+ return null;
178
+ }
179
+ const parsed = parseProcessedFromTempo(await res.json());
180
+ // Truncation is a Tempo problem too: an incomplete PROCESSED set reads as
181
+ // missing receipts → false drops. receiptsIfComplete() returns null when the
182
+ // result hit the limit, so it's handled identically to unreachable/HTTP-error.
183
+ const receipts = receiptsIfComplete(parsed, TEMPO_LIMIT);
184
+ if (receipts === null) {
185
+ console.error(`WARN: Tempo returned ${parsed.traceCount} >= limit ${TEMPO_LIMIT} — PROCESSED set may be truncated (Pattern A: a truncated set reads as missing receipts → false drops). Treating as Tempo-unknown (true no-op this run); narrow LOOKBACK_MIN or raise TEMPO_LIMIT.`);
186
+ }
187
+ return receipts;
188
+ }
189
+ async function main() {
190
+ const nowMs = Date.now();
191
+ const processed = await fetchProcessed(nowMs);
192
+ if (processed === null) {
193
+ // Tempo problem (unreachable / HTTP error / truncated) — the PROCESSED set
194
+ // is UNKNOWABLE, NOT "zero drops". Emit tempo_ok=false; the workflow gates
195
+ // BOTH its open AND its self-close steps on tempo_ok==true, so this is a
196
+ // TRUE no-op: incidents are left exactly as they are (never false-OPEN on a
197
+ // truncated set, never false-CLOSE a real incident on a transient outage).
198
+ console.error('WARN: PROCESSED set unknowable this run — emitting tempo_ok=false (no open, no close).');
199
+ emit({ tempoOk: false, deliveredOk: true, dropsCount: 0, inFlightCount: 0, dropsJson: '[]' });
200
+ return;
201
+ }
202
+ const { routes: delivered, truncated } = fetchDelivered(nowMs);
203
+ if (truncated) {
204
+ // DELIVERED set unknowable (truncated) — like a Tempo problem, NOT "zero
205
+ // drops". Emit delivered_ok=false so the workflow's open AND self-close
206
+ // steps both no-op this run (each gated on tempo_ok && delivered_ok).
207
+ emit({ tempoOk: true, deliveredOk: false, dropsCount: 0, inFlightCount: 0, dropsJson: '[]' });
208
+ return;
209
+ }
210
+ const result = reconcile(delivered, processed, {
211
+ nowMs, openThresholdMs: OPEN_THRESHOLD_MS, sinceMs: SINCE_MS, proximityMs: PROXIMITY_MS,
212
+ });
213
+ const dropsJson = JSON.stringify(result.drops);
214
+ console.error(`reconcile: delivered=${result.deliveredCount} processed=${result.processedCount} ` +
215
+ `drops=${result.drops.length} in_flight=${result.inFlight.length} suppressed=${result.suppressed.length}` +
216
+ (SINCE_MS ? ` (since=${new Date(SINCE_MS).toISOString()} — pre-deployment routes excluded)` : ''));
217
+ // macf#479: coalesced-turn suppressions are LOUD + counted, NEVER silent. Each
218
+ // is a delivery whose marker never receipted but whose agent demonstrably
219
+ // processed a sibling ROUTED turn within PROXIMITY_MS (benign coalesce/clobber,
220
+ // not a real send≠receipt drop). Logging the rate keeps the underlying
221
+ // C-u-clobber visible (its source-side fix is tracked separately); a spike here
222
+ // is itself a signal. RC-bound real-drops are NOT suppressed (no receipted
223
+ // sibling — their tmux pings all drop), so the detector keeps its #437 cause-3
224
+ // teeth. Known residual (rare, same loud-log captures the rate): a content-loss
225
+ // clobber (the clobbering sibling DID receipt) is suppressed — and likewise a
226
+ // genuinely-independent transport loss of A that happens to fall within ±T of an
227
+ // UNRELATED receipted sibling. Both are closed at the root by the source-side
228
+ // C-u-clobber fix; until then the suppressed-rate signal keeps them visible.
229
+ for (const s of result.suppressed) {
230
+ console.error(`suppressed_coalesced_drop: run=${s.route.runId} agent=${s.route.agent} ` +
231
+ `sibling=${s.siblingRunId} delta_ms=${s.deltaMs} ` +
232
+ `(benign: receipted sibling within ${PROXIMITY_MS / MIN}min — macf#479)`);
233
+ }
234
+ if (result.drops.length > 0)
235
+ console.error(`DROPS: ${dropsJson}`);
236
+ emit({
237
+ tempoOk: true, deliveredOk: true, dropsCount: result.drops.length,
238
+ inFlightCount: result.inFlight.length, dropsJson, suppressedCount: result.suppressed.length,
239
+ });
240
+ }
241
+ function emit(o) {
242
+ const out = process.env['GITHUB_OUTPUT'];
243
+ if (!out)
244
+ return;
245
+ appendFileSync(out, `tempo_ok=${o.tempoOk}\ndelivered_ok=${o.deliveredOk}\ndrops_count=${o.dropsCount}\n` +
246
+ `in_flight_count=${o.inFlightCount}\nsuppressed_count=${o.suppressedCount ?? 0}\ndrops_json=${o.dropsJson}\n`);
247
+ }
248
+ /**
249
+ * Run main() ONLY when invoked as the CLI entrypoint (the route-reconciler.yml
250
+ * workflow runs `node dist/reconciler/run.js`). Guarded so the unit test can
251
+ * `import { isDeliveredTruncated }` from this module WITHOUT triggering main():
252
+ * an unguarded top-level main() rejects under the test's no-network env and
253
+ * calls process.exit(1), which vitest surfaces as an unhandled error + leaves a
254
+ * pending fetch keeping the worker alive (macf#477 test imported it; macf#479).
255
+ */
256
+ function isCliEntrypoint() {
257
+ const argv1 = process.argv[1];
258
+ if (argv1 === undefined)
259
+ return false;
260
+ try {
261
+ return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
262
+ }
263
+ catch {
264
+ return false; // argv1 unresolvable (e.g. under a test runner) — not the entrypoint
265
+ }
266
+ }
267
+ if (isCliEntrypoint()) {
268
+ main().catch((err) => {
269
+ console.error(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
270
+ process.exit(1);
271
+ });
272
+ }
273
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/reconciler/run.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundnuty/macf",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "Multi-Agent Coordination Framework CLI — coordinate Claude Code agents via GitHub. Installs as `macf` binary; use `macf init` to set up an agent workspace, `macf update` to refresh rules + version pins.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "test:watch": "vitest"
36
36
  },
37
37
  "dependencies": {
38
- "@groundnuty/macf-core": "0.2.35",
38
+ "@groundnuty/macf-core": "0.2.37",
39
39
  "commander": "^14.0.3",
40
40
  "reflect-metadata": "^0.2.2",
41
41
  "zod": "^4.0.0"
@@ -115,6 +115,27 @@ The rules here are topology-agnostic: they work whether the project uses a scien
115
115
 
116
116
  4. **Concise comments** — 1-3 sentences unless detail is needed.
117
117
 
118
+ 5. **Inbound review requests are action asks that block the requester — not status FYIs.** Issue Lifecycle rule 2 covers the implementer's OUTBOUND queue ("work through your assigned-label queue without prompting"). This rule covers the symmetric REVIEWER-side INBOUND obligation, which is just as load-bearing and far easier to strand.
119
+
120
+ **(a) Treat "PR ready: #N" / "ready for review" as a request that blocks the peer's next move.** When a peer posts a review-request comment on a thread, that is not an informational status update you can note-and-defer — it is an action ask directed at you. The LGTM/merge-gate makes this structural: per `pr-discipline.md`, a peer's PR cannot merge without a reviewer's **formal approval** (`gh pr review --approve`), so until you review, the peer is blocked. Not slowed — blocked, and silently, because nothing on their side surfaces "my reviewer never saw this." A stranded review request stalls the peer indefinitely. Pick it up, review honestly, and submit a formal approve / request-changes (not a plain comment — see `pr-discipline.md §"How to submit LGTM"`; only the formal state-change fires the reviewer-notification routing).
121
+
122
+ **(b) Run an INBOUND review-sweep before declaring idle — especially after surfacing from a long single-threaded task.** Before you say "caught up" / "nothing pending" / "your call" / go idle, sweep your repos for review requests addressed to you. This matters most right after you surface from a deep, single-threaded arc (a long workflow, a multi-step investigation): while you were heads-down on one thread, a review request can have arrived on another and been easy to miss.
123
+
124
+ # Peer-authored PRs awaiting YOUR review (review not yet given):
125
+ for r in <your-repos>; do
126
+ gh pr list --repo "$r" --state open \
127
+ --json number,author,reviewDecision,title \
128
+ --jq '.[] | select(.reviewDecision == "REVIEW_REQUIRED" or .reviewDecision == null)
129
+ | select(.author.login != "<your-login>")'
130
+ done
131
+ # Plus: open threads where you were @mentioned and haven't replied.
132
+
133
+ (The query intentionally surfaces *any* peer-authored PR lacking a review, not only those that formally requested you by name — on a small fleet that breadth is a feature: it catches a peer blocked on review even when the request didn't route to you. Narrow it with a requested-reviewer filter on a larger fleet.)
134
+
135
+ **Why the sweep is necessary — and why it's mechanism-agnostic.** Routing delivers each notification as a **discrete event at the moment it occurs** — a push to your channel-server, an A2A message — not as a standing inbox you can re-open and re-read later. Once that event has been delivered (or has arrived while you were mid-turn on a different thread), it is not re-presented to you on its own. So a review-request ping that landed while you were deep in another task is gone from the live stream and only recoverable by querying GitHub state directly. This is the **general silent-fallback shape** (see `silent-fallback-hazards.md`): the routing layer reports the ping delivered, but "delivered" does not guarantee "processed by the recipient," and nothing surfaces the gap until the blocked peer escalates. The sweep is the result-invariant check at the reviewer boundary — assert against GitHub state ("are there peer PRs awaiting my review?") rather than trusting that you'd remember every ping that flowed past.
136
+
137
+ **Verified motivation:** three operator-surfaced stalls where a peer's review request sat idle (42 min in one case; ~2.5 h in another) because the reviewer went idle without sweeping — the ping had arrived during a long single-threaded task and was never picked back up. In each case the peer's PR was blocked the entire time on a formal approval that never came.
138
+
118
139
  ---
119
140
 
120
141
  ## When You're Stuck — Escalation
@@ -160,19 +181,7 @@ The goal is correctness through dialogue, not compliance.
160
181
 
161
182
  ---
162
183
 
163
- ## Submitting a Prompt to a Claude Code TUI (tmux)
164
-
165
- When a hook or script needs to programmatically submit a prompt to a Claude Code TUI running in tmux, **always use the canonical helper**:
166
-
167
- .claude/scripts/tmux-send-to-claude.sh <session-or-empty> "<prompt text>"
168
-
169
- Pass `""` for the session to target the current pane.
170
-
171
- **Never** call `tmux send-keys "<prompt>" Enter` inline. Claude Code's TUI is in multi-line input mode by default, so a single Enter inserts a newline instead of submitting — the prompt sits in the buffer unsubmitted. The helper handles the submit-quirk correctly: clear existing input with `C-u`, send the text with a first Enter, sleep 1 second (load-bearing — without it tmux batches both Enters and Claude processes them atomically as "newline + newline"), then send a second Enter that actually submits.
172
-
173
- The helper is distributed to every agent workspace by `macf init` and refreshed by `macf update` (same mechanism as this rules file). If you're writing a new hook or automation that needs to prompt Claude, use the helper — do not re-implement the pattern.
174
-
175
- ### Canonical tmux launch pattern
184
+ ## Canonical tmux launch pattern
176
185
 
177
186
  **One session per agent, named `<project>@<agent>`.** Post-v0.2.10, `claude.sh` self-wraps in tmux with this naming structurally — bare `./claude.sh` produces the canonical session. Pre-v0.2.10 consumers (and operators wanting manual launch) use the explicit form:
178
187
 
@@ -50,6 +50,10 @@ case "$TOKEN" in ghs_*) ;; *) echo "FATAL: bad token prefix" >&2; exit 1 ;; esac
50
50
  export GH_TOKEN="$TOKEN"
51
51
  ```
52
52
 
53
+ **Why the bare `TOKEN=$(...)` + separate `export`, never `export GH_TOKEN=$(...)`:** `export` is a builtin whose own exit status (`0`) *replaces* the command-substitution's, so `export GH_TOKEN=$(helper) || exit 1` and `export GH_TOKEN=$(helper) && gh ...` both proceed even when the helper failed (ShellCheck SC2155). The failure only propagates from a **bare** assignment (`TOKEN=$(helper) || …`). This is load-bearing under GitHub's intermittent token-endpoint 401s: a refresh that can yield an empty token *without aborting* the dependent `gh` ops re-creates mode #3 (witnessed 2026-06-12 — `cv-architect` posted 4 comments as the operator under a transient 401).
54
+
55
+ **The PreToolUse hook does NOT cover this.** `check-gh-token.sh` validates the *ambient* `GH_TOKEN` before the command runs, so an inline `export GH_TOKEN=$(...) && gh ...` (refresh-chain *or* file-cache read) reassigns the token *after* the hook has already passed — the hook is structurally blind to it (silent-fallback **Instance 12**, `silent-fallback-hazards.md`). Refresh in a **separate step** (to a pre-validated file, or a bare-assigned var with the checks above), never inline in the `gh` command. The durable structural cover is a result-invariant **PostToolUse** `author`-check (macf#489), not the PreToolUse precondition.
56
+
53
57
  ### 4. Wrong `gh auth` on the VM providing fallback
54
58
 
55
59
  Having `gh auth login` configured as a user account creates the fallback surface in #3. Even a "good" setup where the script is correct can hide a broken bot token because `gh` quietly uses the user auth.
@@ -117,6 +117,8 @@ Per `groundnuty/macf#244` + `#272`, this rule is enforced by a Claude Code PreTo
117
117
 
118
118
  The hook is the same shape as `check-gh-token.sh` (#140 attribution-trap defense) — bash command-type hook distributed via `macf init` / `macf update` / `macf rules refresh` to every workspace's `.claude/scripts/check-mention-routing.sh` with the entry registered in `.claude/settings.json` `hooks.PreToolUse`. Substrate workspaces, tester agents, CV consumers, and future MACF-consumer projects all get the protection uniformly.
119
119
 
120
+ Because the hook is registered as a path-invocation (`"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/check-mention-routing.sh"`), Claude Code execs the script fresh on every event, so a change to the **script body** goes live on the very next event as soon as the file is synced on disk — a `macf update` that updates the script is immediately in force for consumers with no session relaunch and no relaunch-coordination needed. Only a change to the hook **registration** in `.claude/settings.json` (or to the launch environment) requires a relaunch to take effect.
121
+
120
122
  **Heuristic** (subject to refinement; documented for transparency):
121
123
 
122
124
  - Already wrapped in backticks (`` `@<bot>[bot]` ``) → routing-suppressed; allowed (canonical describing form §5); does NOT count toward Check A
@@ -13,7 +13,7 @@ Every MACF agent's `claude.sh` launcher exports the Claude Code native OpenTelem
13
13
  | `OTEL_TRACES_EXPORTER` | `otlp` | Emit traces. Without it, no spans even if master gate is on |
14
14
  | `OTEL_METRICS_EXPORTER` | `otlp` | Emit metrics. **Per-signal** — separate from traces |
15
15
  | `OTEL_LOGS_EXPORTER` | `otlp` | Emit logs. **Per-signal** — separate from traces |
16
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` (default) | OTLP HTTP receiver. Override via `MACF_OTEL_ENDPOINT` |
16
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:14318` (macf canonical — the bare OTLP default `:4318` is overridden by `macf init`/`update`) | OTLP HTTP receiver. Override via `MACF_OTEL_ENDPOINT` |
17
17
  | `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` | Wire protocol |
18
18
  | `OTEL_SERVICE_NAME` | `macf-agent-<name>` | All MACF agents grouped under one service.name family |
19
19
  | `OTEL_RESOURCE_ATTRIBUTES` | `gen_ai.agent.name=<name>,gen_ai.agent.role=<role>,service.namespace=macf` | Semconv-compliant resource attrs |
@@ -32,10 +32,10 @@ Per-workspace, two env knobs read at `macf init` / `macf update` time:
32
32
  - Deployment has no OTLP receiver running (no observability stack)
33
33
  - You want zero retry-spam from the exporter
34
34
  - Agent runs offline / on a host that can't reach the collector
35
- - `MACF_OTEL_ENDPOINT=http://central-host:4318` — overrides the default `http://localhost:4318`. Use when:
35
+ - `MACF_OTEL_ENDPOINT=http://central-host:14318` — overrides the default `http://localhost:14318`. Use when:
36
36
  - The observability stack is on a different host (Tailscale tailnet, reverse proxy, central collector)
37
37
  - You're running multiple MACF deployments and want them all reporting to one collector
38
- - The default port `4318` collides with another service on the agent's host (e.g., devops-toolkit's compose stack uses `:14318` for this reason)
38
+ - **Note:** the canonical macf-devops-toolkit k3d stack serves OTLP HTTP on `:14318` (the bare-SDK default `:4318` was retired 2026-04-25 after it collided with the prior compose stack and caused 34 min of zero-telemetry on CV agents — macf#282/#283). Use `:14318`, not `:4318`.
39
39
 
40
40
  To apply a knob: set the env var BEFORE running `macf update`. The launcher re-renders with the new state.
41
41
 
@@ -0,0 +1,65 @@
1
+ # Reflection Staging (canonical, shared)
2
+
3
+ **Maintain a staged reflection as you work. At compaction it is harvested automatically.** This file is the single source of truth for the reflection-staging discipline; it is copied into each agent workspace's `.claude/rules/` by `macf init` and refreshed by `macf update` / `macf rules refresh`. Do not edit workspace copies directly — edit the canonical file at `groundnuty/macf:packages/macf/plugin/rules/reflection-staging.md` and re-distribute.
4
+
5
+ Applies to every MACF agent. The mechanism is local + cheap and never blocks compaction (DR-026 F2, groundnuty/macf#500).
6
+
7
+ ---
8
+
9
+ ## What this is
10
+
11
+ You accumulate observations as you work — recurring patterns, canonical-rule breaches you committed or witnessed, signals that a rule should evolve, loose ends. Historically these lived in "remember to synthesize before compaction" / "codify at decision time" disciplines, which depended on you remembering to act at exactly the moment your context was about to be summarized away.
12
+
13
+ This rule gives those disciplines a **structural home**: a single file you keep current as you go. They stop being "remember to do X at compaction" and become "keep `pending.json` current." At compaction, the `harvest-reflection.sh` PreCompact hook reads the staged file, wraps it in the versioned reflection envelope, appends it to a local per-session JSONL ledger, and clears the stage for the next session. You never run the harvest by hand — the hook fires on both auto-compaction and manual `/compact`.
14
+
15
+ ---
16
+
17
+ ## The staged file
18
+
19
+ Maintain `.claude/.macf/reflections/pending.json` incrementally. It holds only the **protocol-signal fields** — the hook adds the envelope (`schema_version`, `kind`, agent identity, project, session id, timestamp, trigger, compaction type) at harvest time.
20
+
21
+ ```jsonc
22
+ {
23
+ // Recurring behaviour / coordination dynamic / workflow shape worth surfacing.
24
+ "observed_patterns": [
25
+ { "pattern": "<short name>", "evidence": "<what you saw / a ref>", "tier_hint": "<where it might belong>" }
26
+ ],
27
+ // Canonical-rule breaches you committed or witnessed this session.
28
+ "breaches": [
29
+ { "rule": "<rule name>", "what": "<the breach>", "ref": "<issue/PR/commit/file:line>" }
30
+ ],
31
+ // Signals that a coordination rule should evolve (proposals for governance).
32
+ "rule_evolution_signals": [
33
+ { "signal": "<what changed/recurred>", "proposed_tier": "<suggested tier>", "rationale": "<why>" }
34
+ ],
35
+ // Loose ends to pick up next session.
36
+ "unresolved": [ "<string>", "<string>" ],
37
+ // One-paragraph free-form synthesis of the session.
38
+ "synthesis": "<prose>"
39
+ }
40
+ ```
41
+
42
+ Every field is optional. An empty `{}` (the cleared state) is valid — the hook still emits a **mechanical-only** record at compaction so the Monitor sees that a compaction happened. Arrays default to `[]`, `synthesis` to `""`.
43
+
44
+ `observed_patterns[]` and `rule_evolution_signals[]` may carry an optional `key` field — reserved for deterministic cross-agent dedup later (additive; leave it out for now unless you have a stable key).
45
+
46
+ ## How to keep it current
47
+
48
+ - **Append, don't wait.** When you notice a pattern, breach, or evolution signal mid-session, add it to the relevant array right then. Don't batch it for "later" — later is compaction, and the point is that the staging already happened.
49
+ - **Edit the file directly** (`Read` then `Edit`/`Write`). It is plain JSON in your workspace; no command or tool call is needed.
50
+ - **Keep `synthesis` a living summary.** Overwrite it as your understanding of the session firms up.
51
+ - **Don't clear it yourself.** The harvest hook clears the stage after appending. If you clear it, the next compaction emits a mechanical-only record and your observations are lost.
52
+
53
+ ## Where it lands
54
+
55
+ - Ledger: `.claude/.macf/reflections/<session_id>.jsonl` (one JSON object per line, schema-validated by `@groundnuty/macf-core` `ReflectionRecordSchema`).
56
+ - The ledger is **local + observational** — DR-023 §UC-3 posture. The harvest never blocks compaction; an internal error fails open (`exit 0`).
57
+ - Opt out of harvesting for a session with `MACF_SKIP_REFLECTION_HARVEST=1`.
58
+
59
+ ---
60
+
61
+ ## When to read vs. modify
62
+
63
+ - **Read:** every session start. This rule defines how to stage your reflection.
64
+ - **Modify:** never directly in workspace copies. Edit the canonical file and re-distribute via `macf update`.
65
+ - **Disagree with a rule?** Open an issue on `groundnuty/macf` proposing the change, with rationale. Peer review applies.