kenaz 0.3.10 → 0.3.12

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 (131) hide show
  1. package/core/.claude/skills/code_reviewer/SKILL.md +0 -1
  2. package/core/.claude/skills/code_reviewer/patterns.yaml +0 -8
  3. package/core/.claude/skills/dev_agent/SKILL.md +3 -6
  4. package/core/.claude/skills/heimdall/SKILL.md +5 -6
  5. package/core/.claude/skills/huginn/SKILL.md +11 -38
  6. package/core/.collaboration/core/task-2288-followups.test.js +2 -37
  7. package/core/.collaboration/core/task-2421-replica.test.js +229 -0
  8. package/core/.collaboration/core/task-api.js +223 -230
  9. package/core/.collaboration/core/task-api.test.js +1 -138
  10. package/core/.collaboration/core/task-replica.js +189 -0
  11. package/core/.formation/.merge-lock +1 -0
  12. package/core/.formation/bifrost/codex-doctor.js +12 -2
  13. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  14. package/core/.formation/bifrost/launch.js +2 -2
  15. package/core/.formation/bifrost/launch.test.js +2 -2
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/bifrost/shared/ansuz-boot-policy.js +3 -3
  18. package/core/.formation/bifrost/shared/codex-injection.js +3 -17
  19. package/core/.formation/bifrost/shared/consolidate.js +1 -51
  20. package/core/.formation/bifrost/sync.js +4 -5
  21. package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +1 -1
  22. package/core/.formation/tools/dispatch.js +2 -2
  23. package/core/.formation/tools/kenaz-stats.js +1 -1
  24. package/core/.formation/tools/mcp-allowlist.test.js +5 -3
  25. package/core/.formation/tools/post-complete/bounded-http.js +1 -40
  26. package/core/.formation/tools/post-complete/bounded-http.test.js +2 -89
  27. package/core/.formation/tools/post-complete/cognitive.js +2 -119
  28. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  29. package/core/.formation/tools/post-complete/testing.js +3 -21
  30. package/core/.formation/tools/post-complete.js +131 -436
  31. package/core/.formation/tools/post-complete.test.js +0 -135
  32. package/core/.formation/tools/route-audit.test.js +8 -20
  33. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  34. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  35. package/core/.rule/INDEX.yaml +3 -3
  36. package/core/.rule/general/ddx-synapse-stack.yaml +11 -11
  37. package/core/.rule/general/specialist-identity-contract.yaml +5 -5
  38. package/core/.rule/general/synapse-code-intelligence.yaml +25 -111
  39. package/core/.rule/general/worktree-isolation.yaml +2 -2
  40. package/core/.rule/pipeline/task-execution.yaml +6 -9
  41. package/core/.specialist/Amelia/README.md +5 -5
  42. package/core/.specialist/Amelia/capability.yaml +10 -18
  43. package/core/.specialist/Amelia/knowledge.yaml +15 -66
  44. package/core/.specialist/Amelia/workflow.yaml +12 -21
  45. package/core/.specialist/Ansuz/capability.yaml +12 -87
  46. package/core/.specialist/Ansuz/concepts/autonomous_agenda.yaml +1 -1
  47. package/core/.specialist/Ansuz/concepts/autonomy_contract.yaml +2 -3
  48. package/core/.specialist/Ansuz/concepts/capability_pointers.yaml +1 -3
  49. package/core/.specialist/Ansuz/concepts/cognitive_fluency_contract.yaml +8 -21
  50. package/core/.specialist/Ansuz/concepts/cognitive_systems_overview.yaml +0 -4
  51. package/core/.specialist/Ansuz/concepts/cognitive_systems_turn_context.yaml +1 -1
  52. package/core/.specialist/Ansuz/concepts/completion_tracking.yaml +0 -1
  53. package/core/.specialist/Ansuz/concepts/deep_knowledge_pointers.yaml +0 -3
  54. package/core/.specialist/Ansuz/concepts/evolvable_guardrails.yaml +3 -3
  55. package/core/.specialist/Ansuz/concepts/fatigue_handoff.yaml +1 -7
  56. package/core/.specialist/Ansuz/concepts/guardrails_immutable.yaml +3 -4
  57. package/core/.specialist/Ansuz/concepts/identity.yaml +1 -2
  58. package/core/.specialist/Ansuz/concepts/phase9_verification.yaml +0 -1
  59. package/core/.specialist/Ansuz/concepts/phase_protocol.yaml +3 -4
  60. package/core/.specialist/Ansuz/core.yaml +3 -6
  61. package/core/.specialist/Ansuz/elixir.yaml +6 -6
  62. package/core/.specialist/Ansuz/exo_cortex_tools.yaml +5 -9
  63. package/core/.specialist/Ansuz/guards/blind_spot_guards.yaml +2 -2
  64. package/core/.specialist/Ansuz/guards/lesson_guards.yaml +6 -19
  65. package/core/.specialist/Ansuz/identity_contract.yaml +4 -4
  66. package/core/.specialist/Ansuz/knowledge.yaml +13 -40
  67. package/core/.specialist/Ansuz/modules/ddx.yaml +2 -2
  68. package/core/.specialist/Ansuz/modules/prophecy.yaml +19 -19
  69. package/core/.specialist/Ansuz/modules/pulse_empty.yaml +6 -17
  70. package/core/.specialist/Ansuz/tools/ansuz-handoff-snapshot.js +3 -102
  71. package/core/.specialist/Ansuz/tools/ansuz-sdk-runner.js +2 -44
  72. package/core/.specialist/Ansuz/workflow.yaml +32 -76
  73. package/core/.specialist/Forseti/core.yaml +2 -7
  74. package/core/.specialist/Forseti/rule.yaml +1 -1
  75. package/core/.specialist/Heimdall/SKILL.md +2 -3
  76. package/core/.specialist/Heimdall/capability.yaml +22 -45
  77. package/core/.specialist/Heimdall/core.yaml +24 -126
  78. package/core/.specialist/Heimdall/principles.yaml +8 -11
  79. package/core/.specialist/Heimdall/rule.yaml +2 -2
  80. package/core/.specialist/Huginn/capability.yaml +31 -52
  81. package/core/.specialist/Huginn/core.yaml +43 -123
  82. package/core/.specialist/Huginn/principles.yaml +5 -5
  83. package/core/.specialist/Huginn/rule.yaml +3 -9
  84. package/core/.specialist/REFLECTION_TO_CONTROL.md +3 -4
  85. package/core/.specialist/shared/guard_schema.json +1 -1
  86. package/core/.specialist/shared/principles.yaml +6 -6
  87. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  88. package/core/.specialist/tools/phase-gate.js +10 -230
  89. package/core/.specialist/tools/phase-gate.test.js +2 -2
  90. package/core/.specialist/tools/prompt-files.test.js +1 -1
  91. package/core/.specialist/tools/rea-detect.js +6 -2
  92. package/core/.specialist/tools/session.js +2 -89
  93. package/core/.synapse/memory-shared.json.sync +1 -0
  94. package/core/.system/memory/README.md +3 -0
  95. package/core/CLAUDE.md +6 -7
  96. package/core/KENAZ_CORE_VERSION +1 -1
  97. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -83
  98. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  99. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  100. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  101. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  102. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  103. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  104. package/core/plugins/kenaz/INVENTORY.md +1 -1
  105. package/core/plugins/kenaz/scripts/kenaz.test.js +70 -1
  106. package/core/plugins/kenaz/scripts/ygg.js +45 -4
  107. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +0 -1
  108. package/core/plugins/kenaz/skills/code_reviewer/patterns.yaml +0 -8
  109. package/core/plugins/kenaz/skills/dev_agent/SKILL.md +3 -6
  110. package/lib/cli.js +17 -9
  111. package/lib/cli.test.js +10 -0
  112. package/lib/dashboard.test.js +3 -3
  113. package/package.json +6 -6
  114. package/vendor/install-kenaz-cli.js +167 -16
  115. package/core/.claude/hooks/handoff-snapshot-on-decision.js +0 -188
  116. package/core/.claude/skills/synapse_maintain/SKILL.md +0 -84
  117. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
  118. package/core/.formation/tools/alaya-gate.js +0 -198
  119. package/core/.formation/tools/post-complete/health.js +0 -464
  120. package/core/.formation/tools/post-complete/task2038.test.js +0 -246
  121. package/core/.formation/tools/post-complete/task2341.test.js +0 -74
  122. package/core/.rule/general/cortex-tools.yaml +0 -181
  123. package/core/.rule/general/memory-language.yaml +0 -67
  124. package/core/.rule/general/memory-source-priority.yaml +0 -207
  125. package/core/.rule/general/synapse-cognitive-fusion.yaml +0 -301
  126. package/core/.specialist/Ansuz/concepts/cognitive_systems_hippocampus.yaml +0 -14
  127. package/core/.specialist/Ansuz/concepts/write_verification.yaml +0 -26
  128. package/core/.specialist/Ansuz/modules/recall.yaml +0 -45
  129. package/core/.specialist/tools/MEMORY_RECALL_TRIGGERS.md +0 -156
  130. package/core/.specialist/tools/context-injection.js +0 -395
  131. package/core/dev/scripts/probe/hippocampus-concurrency.js +0 -247
@@ -1,464 +0,0 @@
1
- /**
2
- // D.4 runtime validation ??TASK_1214
3
- * HEALTH_AND_GUARDIAN ??extracted from post-complete.js (D.4 refactor)
4
- *
5
- * Loop config reading, Synapse health measurement, health snapshot
6
- * persistence (trend display only; TASK_2341: never a revert/gate), and
7
- * Guardian periodic health check triggering.
8
- */
9
-
10
- const path = require('path');
11
- const fs = require('fs');
12
- const { spawnSync } = require('child_process');
13
- const { ROOT, COLLABORATION_API } = require('../utils');
14
-
15
- // Constants: natural home is this module (previously misplaced in cognitive.js)
16
- const GUARDIAN_COUNTER_FILE = path.join(__dirname, '..', '..', '.synapse', 'guardian_counter.json');
17
- const HEALTH_SNAPSHOTS_DIR_NAME = path.join('.kenaz', 'health_snapshots');
18
- const DEFAULT_REVERT_THRESHOLD = -2;
19
- // TASK_2341: tag for snapshots of the memory-only health score. Graph-era snapshots have no
20
- // score_version, so they are never compared with these (see computeHealthVerdict).
21
- const MEMORY_SCORE_VERSION = 'memory-v1';
22
-
23
- // TASK_1726: Producer-side heartbeat observability.
24
- const CHANNEL_HB_RELPATH = require('path').join('.kenaz', 'channel_heartbeats.jsonl');
25
- const CHANNEL_HB_CAP = 5000; // lines; rotate to .bak when exceeded
26
-
27
- /**
28
- * Append one heartbeat line to .kenaz/channel_heartbeats.jsonl.
29
- * Bounded: rotates to .bak when CHANNEL_HB_CAP lines exceeded.
30
- * Never throws -- heartbeat must never break the calling path.
31
- * @param {string} root project root
32
- * @param {string} channel logical channel name
33
- * @param {boolean} ok true=write succeeded, false=write failed
34
- * @param {string} [note] optional context
35
- */
36
- function appendChannelHeartbeat(root, channel, ok, note) {
37
- try {
38
- const hbPath = require('path').join(root, CHANNEL_HB_RELPATH);
39
- const dir = require('path').dirname(hbPath);
40
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
41
- if (fs.existsSync(hbPath)) {
42
- const count = fs.readFileSync(hbPath, 'utf8').split('\n').filter(l => l.trim()).length;
43
- if (count >= CHANNEL_HB_CAP) fs.renameSync(hbPath, hbPath + '.bak');
44
- }
45
- const line = JSON.stringify({ channel, ts: new Date().toISOString(), ok, ...(note ? { note } : {}) });
46
- fs.appendFileSync(hbPath, line + '\n', 'utf8');
47
- } catch { /* never throw */ }
48
- }
49
-
50
- /**
51
- * TASK_2038: resolve the projectPath every MCP/HTTP payload sent by the
52
- * post-complete module family must carry EXPLICITLY. Local, file-based reads
53
- * in this same file already do `projectPath || ROOT` (e.g.
54
- * readPreTaskHealthSnapshot, saveTaskHealthSnapshot) -- but every MCP/HTTP
55
- * payload instead spread `...(projectPath ? { projectPath } : {})`, which
56
- * OMITS the key entirely for a core KenazAI run (projectPath is undefined
57
- * there -- see post-complete.js ~L4016, only set when --project-path is
58
- * passed). synapse_health and friends then fall back server-side to whatever
59
- * project the Dashboard currently has selected, not this run's actual
60
- * project -- confirmed live: 4 of 5 recent KEEP/REVERT verdicts were scored
61
- * against a different repo (rune_ec9ec6c1). One helper instead of repeating
62
- * `|| ROOT` at each MCP call site (GEN-008: ROOT is post-complete's existing
63
- * dynamically-resolved constant, not a hardcoded path).
64
- * @param {string} [projectPath]
65
- * @returns {string}
66
- */
67
- function resolveMcpProjectPath(projectPath) {
68
- return projectPath || ROOT;
69
- }
70
-
71
- /**
72
- * TASK_1861 introduced the KENAZ_DASHBOARD_PORT env override so tests can
73
- * point measureHealthScoreSync at a stub server instead of the real
74
- * Dashboard. TASK_2038 needs the identical override in
75
- * triggerGuardianHealthCheck (which used to hardcode port 18080) so its own
76
- * negative-control test can run against the same stub -- one resolver
77
- * instead of two copies of the parse/validate logic drifting apart.
78
- * @returns {number}
79
- */
80
- function resolveMcpDashboardPort() {
81
- const envPort = parseInt(process.env.KENAZ_DASHBOARD_PORT || '', 10);
82
- return Number.isInteger(envPort) && envPort > 0 && envPort <= 65535 ? envPort : 18080;
83
- }
84
-
85
- /**
86
- * Read loop configuration from .kenaz/loop/loop.json.
87
- * Determines if KEEP/REVERT logic runs (auto mode only).
88
- *
89
- * TASK_1885: the file is written exclusively by loop_config.rs, whose
90
- * LoopConfig struct carries #[serde(rename_all = "camelCase")] -- so the
91
- * on-disk key is `revertThreshold`, not `revert_threshold`. There has never
92
- * been a snake_case writer (loop.js's own readLoopConfig/default and the
93
- * Rust struct both agree on camelCase), so this reads the single real key
94
- * with no dual-format acceptance window -- a snake_case branch here would
95
- * be dead code with no producer. The returned object keeps `revert_threshold`
96
- * as the JS-internal field name; that shape is unrelated to the on-disk key
97
- * and is what every consumer in this codebase already destructures.
98
- * @param {string} [projectPath]
99
- * @returns {{ mode: string, revert_threshold: number }}
100
- */
101
- function readLoopConfig(projectPath) {
102
- const root = projectPath || ROOT;
103
- const loopFile = path.join(root, '.kenaz', 'loop', 'loop.json');
104
- try {
105
- if (fs.existsSync(loopFile)) {
106
- const data = JSON.parse(fs.readFileSync(loopFile, 'utf8'));
107
- return {
108
- mode: data.mode || 'manual',
109
- revert_threshold: typeof data.revertThreshold === 'number'
110
- ? data.revertThreshold
111
- : DEFAULT_REVERT_THRESHOLD,
112
- };
113
- }
114
- } catch { /* corrupted ??use defaults */ }
115
- return { mode: 'manual', revert_threshold: DEFAULT_REVERT_THRESHOLD };
116
- }
117
-
118
- /**
119
- * Synchronously measure Synapse health score via MCP HTTP endpoint.
120
- * Spawns a Node.js subprocess to work around Node's async-only HTTP.
121
- *
122
- * TASK_1893: the inline script also preserves the raw `metrics` object
123
- * verbatim as `metrics_raw`, alongside (not replacing) the existing flat
124
- * `sub_metrics: {name: number}` projection. This is the general fix for
125
- * a producer/consumer disconnect that had already recurred twice on this
126
- * exact line: TASK_1890 patched metric_trust + saturated_default through
127
- * by name; three weeks and one field later (pain_path_count.metric_version,
128
- * TASK_1889) the same projection dropped the new field again, because
129
- * naming fields one at a time doesn't generalize. metrics_raw closes the
130
- * hole for good -- any future per-metric field health.rs adds is preserved
131
- * automatically, with no fourth card required. sub_metrics/
132
- * sub_metrics_saturated are NOT widened to carry it, because their flat
133
- * `{name: number}` / `{name: boolean}` shape is a load-bearing contract
134
- * consumed in four places by `typeof === 'number'`/truthiness
135
- * (computeSubMetricDeltas, pairedSubMetricDeltas, deriveSideTrust, and the
136
- * 191 on-disk _after.json snapshots) -- widening it would break all of
137
- * them silently.
138
- * @param {string} [projectPath]
139
- * @returns {{ score: number, grade: string, sub_metrics?: object, sub_metrics_saturated?: object, metric_trust?: object|null, metrics_raw?: object } | null} null if dashboard not running
140
- */
141
- function measureHealthScoreSync(projectPath) {
142
- // TASK_2038: resolve BEFORE building the inline script so `pp` is always a
143
- // real path (never omitted) -- see resolveMcpProjectPath's JSDoc.
144
- const ppArg = JSON.stringify(resolveMcpProjectPath(projectPath));
145
- // TASK_1861: KENAZ_DASHBOARD_PORT env override (test injection / non-default port).
146
- // Invalid or absent values fall back to the production default 18080.
147
- const port = resolveMcpDashboardPort();
148
- // Inline node script: makes async HTTP request, writes JSON result to stdout
149
- const script = [
150
- "const http = require('http');",
151
- `const pp = ${ppArg};`,
152
- "const payload = JSON.stringify({ jsonrpc: '2.0', id: 'hc-sync', method: 'tools/call',",
153
- " params: { name: 'synapse_health',",
154
- " arguments: { mode: 'score', save_snapshot: true, projectPath: pp } } });",
155
- `const req = http.request({ hostname: '127.0.0.1', port: ${port}, path: '/api/mcp',`,
156
- " method: 'POST',",
157
- " headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },",
158
- " timeout: 8000 }, (res) => {",
159
- " let b = '';",
160
- " res.on('data', c => b += c);",
161
- " res.on('end', () => {",
162
- " try {",
163
- " const r = JSON.parse(b);",
164
- " const t = r && r.result && r.result.content && r.result.content[0] && r.result.content[0].text;",
165
- " if (t) { const d = JSON.parse(t);",
166
- " const sm = d.metrics ? Object.fromEntries(Object.entries(d.metrics).map(([k,v]) => [k, typeof v === 'object' ? v.score : v])) : {};",
167
- " const smSat = d.metrics ? Object.fromEntries(Object.entries(d.metrics).map(([k,v]) => [k, !!(v && typeof v === 'object' && v.saturated_default)])) : {};",
168
- " const mt = (d.metric_trust && typeof d.metric_trust === 'object') ? d.metric_trust : null;",
169
- // TASK_1893: preserve the whole metrics object verbatim, alongside (not
170
- // instead of) the flat sm/smSat projections above -- see the JSDoc on
171
- // measureHealthScoreSync for why this is the general fix rather than
172
- // naming another field.
173
- " const metricsRaw = (d.metrics && typeof d.metrics === 'object') ? d.metrics : {};",
174
- " process.stdout.write(JSON.stringify({ score: d.overall_score, grade: d.grade, sub_metrics: sm, sub_metrics_saturated: smSat, metric_trust: mt, metrics_raw: metricsRaw }));",
175
- " }",
176
- " } catch(e) { process.stderr.write(e.message); }",
177
- " });",
178
- "});",
179
- "req.on('error', () => {});",
180
- "req.on('timeout', () => req.destroy());",
181
- "req.write(payload);",
182
- "req.end();",
183
- ].join('\n');
184
-
185
- try {
186
- const result = spawnSync(process.execPath, ['-e', script], {
187
- encoding: 'utf8',
188
- timeout: 12000,
189
- windowsHide: true,
190
- });
191
- if (result.stdout && result.stdout.trim()) {
192
- // TASK_2341: tag so memory-era scores are never compared with graph-era ones.
193
- return { ...JSON.parse(result.stdout.trim()), score_version: MEMORY_SCORE_VERSION };
194
- }
195
- } catch { /* dashboard not running or timeout */ }
196
- return null;
197
- }
198
-
199
- /**
200
- * Read pre-task health snapshot from disk.
201
- * Written by headless.js at dispatch time, keyed by task_id.
202
- * @param {string} taskId
203
- * @param {string} [projectPath]
204
- * @returns {{ score: number, grade: string, timestamp: string } | null}
205
- */
206
- function readPreTaskHealthSnapshot(taskId, projectPath) {
207
- const root = projectPath || ROOT;
208
- const snapshotFile = path.join(root, HEALTH_SNAPSHOTS_DIR_NAME, `${taskId}_before.json`);
209
- try {
210
- if (fs.existsSync(snapshotFile)) {
211
- return JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
212
- }
213
- } catch {}
214
- return null;
215
- }
216
-
217
- /**
218
- * Save a health snapshot for a task (before or after).
219
- * Includes sub_metrics if present in health object (TASK_1455).
220
- * TASK_1890: also persists metric_trust (overall_trust, submetric_trust,
221
- * risk_flags, sampling_info, index_staleness_hours) and per-metric
222
- * saturated_default flags -- without this, a snapshot read back later (e.g.
223
- * as a stand-in "before" reading, or for post-hoc audit) can never recover
224
- * how trustworthy the graph was at measurement time, and any consumer is
225
- * forced back onto the optimistic default this task exists to eliminate.
226
- * TASK_1893: also persists metrics_raw (the unprojected metrics object,
227
- * including per-metric fields like pain_path_count.metric_version) --
228
- * without this a snapshot can never recover which FORMULA a metric was
229
- * computed under, only its score, so a before/after pair spanning a formula
230
- * change reads as an ordinary delta instead of an incomparable one.
231
- * @param {string} taskId
232
- * @param {'before'|'after'} type
233
- * @param {{ score: number, grade: string, sub_metrics?: object, sub_metrics_saturated?: object, metric_trust?: object|null, metrics_raw?: object }} health
234
- * @param {string} [projectPath]
235
- * @returns {object|null} the exact payload written to disk (score, grade,
236
- * sub_metrics, sub_metrics_saturated, metric_trust, metrics_raw, timestamp,
237
- * and on 'after' also delta_from_previous/trend_5tasks), or null on write
238
- * failure.
239
- * TASK_1891: returning this lets callers thread delta_from_previous through
240
- * to computeHealthVerdict for observability -- previously computed here and
241
- * then silently discarded (rune_9d645791: "no verdict path reads it").
242
- */
243
- function saveTaskHealthSnapshot(taskId, type, health, projectPath) {
244
- const root = projectPath || ROOT;
245
- const dir = path.join(root, HEALTH_SNAPSHOTS_DIR_NAME);
246
- let writeOk = false;
247
- let writeNote;
248
- let writtenPayload = null;
249
- try {
250
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
251
- const payload = {
252
- score: health.score,
253
- grade: health.grade,
254
- score_version: health.score_version || MEMORY_SCORE_VERSION,
255
- sub_metrics: health.sub_metrics || {},
256
- sub_metrics_saturated: health.sub_metrics_saturated || {},
257
- metric_trust: health.metric_trust || null,
258
- metrics_raw: health.metrics_raw || {},
259
- timestamp: new Date().toISOString(),
260
- };
261
- // Enrich _after snapshots with delta_from_previous and trend_5tasks (TASK_1431)
262
- // Priors are loaded BEFORE writing current file, so current task is naturally excluded.
263
- if (type === 'after') {
264
- try {
265
- const priors = loadRecentAfterSnapshots(root, 5);
266
- payload.delta_from_previous = priors.length >= 1 && typeof priors[0].score === 'number'
267
- ? parseFloat((health.score - priors[0].score).toFixed(2))
268
- : null;
269
- payload.trend_5tasks = priors.length >= 5 && typeof priors[4].score === 'number'
270
- ? parseFloat((health.score - priors[4].score).toFixed(2))
271
- : null;
272
- } catch { payload.delta_from_previous = null; payload.trend_5tasks = null; }
273
- }
274
- fs.writeFileSync(
275
- path.join(dir, `${taskId}_${type}.json`),
276
- JSON.stringify(payload, null, 2)
277
- );
278
- writeOk = true;
279
- writtenPayload = payload;
280
- } catch (e) {
281
- // TASK_1726: warn-on-silent-skip
282
- console.warn(`[channel:health_snapshot] write failed for ${taskId}_${type}: ${e && e.message || e}`);
283
- writeNote = String(e && e.message || e);
284
- }
285
- // TASK_1726: heartbeat on every call (ok or not)
286
- appendChannelHeartbeat(root, 'health_snapshot', writeOk, writeNote);
287
- return writtenPayload;
288
- }
289
-
290
- /**
291
- * Load the N most recent _after.json snapshots (TASK_1455).
292
- * Used for sub-metric trend analysis in SUBMETRIC_REGRESSION verdict.
293
- * @param {string} [projectPath]
294
- * @param {number} [limit=10]
295
- * @returns {Array<{ taskId: string, score: number, grade: string, sub_metrics: object, timestamp: string }>}
296
- */
297
- function loadRecentAfterSnapshots(projectPath, limit = 10) {
298
- const root = projectPath || ROOT;
299
- const dir = path.join(root, HEALTH_SNAPSHOTS_DIR_NAME);
300
- if (!fs.existsSync(dir)) return [];
301
- try {
302
- // Sort by recorded content timestamp (deterministic), falling back to file mtime
303
- // only when the timestamp is missing/unparseable. mtime alone ties when many
304
- // snapshots are written in the same millisecond, producing unstable ordering.
305
- return fs.readdirSync(dir)
306
- .filter(f => f.endsWith('_after.json'))
307
- .map(f => {
308
- try {
309
- const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
310
- const ts = Date.parse(data.timestamp);
311
- return {
312
- taskId: f.replace('_after.json', ''),
313
- score_version: data.score_version || null,
314
- score: data.score ?? null,
315
- grade: data.grade ?? null,
316
- sub_metrics: data.sub_metrics || {},
317
- timestamp: data.timestamp || null,
318
- _sortKey: Number.isNaN(ts) ? fs.statSync(path.join(dir, f)).mtimeMs : ts,
319
- };
320
- } catch { return null; }
321
- })
322
- .filter(Boolean)
323
- // TASK_2341: only same-version (memory-v1) snapshots form a comparable trend.
324
- .filter(x => x.score_version === MEMORY_SCORE_VERSION)
325
- .sort((a, b) => b._sortKey - a._sortKey) // newest first by recorded timestamp
326
- .slice(0, limit)
327
- .map(({ _sortKey, ...rest }) => rest);
328
- } catch { return []; }
329
- }
330
-
331
- /**
332
- * TASK_2341: memory-health trend record. NEVER a gate.
333
- *
334
- * synapse_health now scores MEMORY (rune_vitality, review_backlog, conflict_load,
335
- * episode_backlog), not code. A code change cannot move that score, so no verdict
336
- * derived from it may revert, block or penalise a task. This function therefore
337
- * always returns verdict 'RECORDED'; `delta` is informational and is only a number
338
- * when the before snapshot carries the same score_version (graph-era snapshots have
339
- * none and are never compared with memory-era ones).
340
- * @param {object|number} preSnapshotOrScore
341
- * @param {{score:number, score_version?:string}} postHealth
342
- * @param {number} [_threshold] ignored (kept for caller compatibility)
343
- * @param {{trendDeltaFromPrevious?: number|null}} [opts]
344
- * @returns {{delta: number|null, score: number, verdict: 'RECORDED', reason: string,
345
- * subMetricRegressions: string[], trust: object, indeterminateReason: null,
346
- * trendDeltaFromPrevious: number|null}}
347
- */
348
- function computeHealthVerdict(preSnapshotOrScore, postHealth, _threshold, opts = {}) {
349
- const preSn = typeof preSnapshotOrScore === 'number' ? { score: preSnapshotOrScore } : (preSnapshotOrScore || {});
350
- const score = postHealth.score;
351
- const version = postHealth.score_version || MEMORY_SCORE_VERSION;
352
- const comparable = typeof preSn.score === 'number' && preSn.score_version === version;
353
- const delta = comparable ? score - preSn.score : null;
354
- const reason = comparable
355
- ? `Memory-health trend ${delta >= 0 ? '+' : ''}${Number(delta).toFixed(1)} pts (${version}) — recorded for display only, never gates`
356
- : `Memory-health ${score}/100 (${version}) recorded; baseline is ${preSn.score_version || 'graph-era'} so not compared`;
357
- return {
358
- delta, score, verdict: 'RECORDED', reason, subMetricRegressions: [],
359
- trust: { status: 'unknown', overall_trust: null, risk_flags: [] },
360
- indeterminateReason: null,
361
- trendDeltaFromPrevious: opts.trendDeltaFromPrevious ?? null,
362
- };
363
- }
364
-
365
-
366
- /**
367
- * Write the RECORDED health note to task YAML via task-api notes field.
368
- * Field format: "post_complete_verdict: RECORDED | delta: N|n/a | reason: ..."
369
- * @param {string} taskId
370
- * @param {string} verdict
371
- * @param {number|null} delta
372
- * @param {string} reason
373
- * @param {string} [projectPath]
374
- */
375
- function writeVerdictToTask(taskId, verdict, delta, reason, projectPath) {
376
- try {
377
- const deltaText = typeof delta === 'number' ? delta.toFixed(2) : 'n/a';
378
- const updateArgs = projectPath ? ['--project-path', projectPath] : [];
379
- const verdictNote = `post_complete_verdict: ${verdict} | delta: ${deltaText} | reason: ${reason}`;
380
- updateArgs.push('update', taskId, '--notes', verdictNote);
381
- spawnSync('node', [COLLABORATION_API, ...updateArgs], {
382
- encoding: 'utf8', timeout: 10000, cwd: ROOT, windowsHide: true,
383
- });
384
- } catch { /* best-effort */ }
385
- }
386
-
387
- function readGuardianCounter() {
388
- try {
389
- if (fs.existsSync(GUARDIAN_COUNTER_FILE)) {
390
- return JSON.parse(fs.readFileSync(GUARDIAN_COUNTER_FILE, 'utf8'));
391
- }
392
- } catch { /* corrupted ??reset */ }
393
- return { completed_since_last_health: 0 };
394
- }
395
-
396
- function writeGuardianCounter(data) {
397
- try {
398
- const dir = path.dirname(GUARDIAN_COUNTER_FILE);
399
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
400
- fs.writeFileSync(GUARDIAN_COUNTER_FILE, JSON.stringify(data, null, 2));
401
- } catch { /* best-effort */ }
402
- }
403
-
404
- /**
405
- * Fire-and-forget: call synapse_health via MCP HTTP endpoint.
406
- * Saves a snapshot for trend tracking. Non-blocking, non-failing.
407
- */
408
- function triggerGuardianHealthCheck(projectPath) {
409
- const http = require('http');
410
- // TASK_2038: explicit projectPath (never omitted) + same KENAZ_DASHBOARD_PORT
411
- // override as measureHealthScoreSync -- see resolveMcpProjectPath/
412
- // resolveMcpDashboardPort JSDoc.
413
- const payload = JSON.stringify({
414
- jsonrpc: '2.0', id: `guardian-${Date.now()}`, method: 'tools/call',
415
- params: {
416
- name: 'synapse_health',
417
- arguments: { mode: 'score', save_snapshot: true, projectPath: resolveMcpProjectPath(projectPath) }
418
- }
419
- });
420
- const req = http.request({
421
- hostname: '127.0.0.1', port: resolveMcpDashboardPort(), path: '/api/mcp',
422
- method: 'POST',
423
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
424
- timeout: 10000
425
- }, (res) => {
426
- let body = '';
427
- res.on('data', (chunk) => { body += chunk; });
428
- res.on('end', () => {
429
- try {
430
- const result = JSON.parse(body);
431
- const content = result?.result?.content?.[0]?.text;
432
- if (content) {
433
- const data = JSON.parse(content);
434
- console.log(` ?儭? Guardian: health=${data.overall_score}/100 (${data.grade}) ??snapshot saved`);
435
- }
436
- } catch { console.log(' ?儭? Guardian: health snapshot saved'); }
437
- });
438
- });
439
- req.on('error', () => { /* Dashboard not running ??skip silently */ });
440
- req.on('timeout', () => { req.destroy(); });
441
- req.write(payload);
442
- req.end();
443
- }
444
-
445
- module.exports = {
446
- resolveMcpProjectPath,
447
- resolveMcpDashboardPort,
448
- readLoopConfig,
449
- measureHealthScoreSync,
450
- readPreTaskHealthSnapshot,
451
- saveTaskHealthSnapshot,
452
- loadRecentAfterSnapshots,
453
- computeHealthVerdict,
454
- writeVerdictToTask,
455
- readGuardianCounter,
456
- writeGuardianCounter,
457
- triggerGuardianHealthCheck,
458
- appendChannelHeartbeat,
459
- GUARDIAN_COUNTER_FILE,
460
- HEALTH_SNAPSHOTS_DIR_NAME,
461
- DEFAULT_REVERT_THRESHOLD,
462
- MEMORY_SCORE_VERSION,
463
- CHANNEL_HB_RELPATH,
464
- };