@ijfw/memory-server 1.4.3 → 1.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 (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
@@ -0,0 +1,350 @@
1
+ /**
2
+ * merge-block-aware.js — v1.5.0 T8: in-process port of the block-aware
3
+ * AGENTS.md merger (formerly `claude/skills/ijfw-agents-md/scripts/
4
+ * merge-block-aware.sh`).
5
+ *
6
+ * RATIONALE — why this lives in mcp-server/src/orchestrator and not in the
7
+ * skill scripts dir:
8
+ * The shell script was invoked under `withFsLock(.AGENTS.md.lock)` via
9
+ * `execFile('bash', …)`. Holding an `fs-lock` across a subprocess spawn is a
10
+ * STATE-SDK-CONTRACT §3 violation:
11
+ * "No lock is held across a subprocess spawn. `merge-block-aware.sh` is
12
+ * ported to in-process JS (T8) …"
13
+ * This module is that port. The shell script remains on disk for parity
14
+ * testing in this commit; T14 (SDK grep-gate sweep) decides its long-term
15
+ * fate.
16
+ *
17
+ * SCOPE
18
+ * - Pure-JS replacement for the byte-stable marker-block replacement that
19
+ * the shell script did with a here-doc `node -e '…'` payload. Same
20
+ * marker pattern (`<!-- IJFW-<BLOCK>-START -->` / `…-END -->`), same
21
+ * append-on-missing-pair fallback, same content-wrapping (`\n…\n`).
22
+ * - Atomic write via `writeAtomic` (lib/atomic-io.js) — tmp + rename — so
23
+ * `mergeFile()` can be called inside a `withFsLock` critical section.
24
+ * - Template seeding (copies AGENTS.md.tmpl when the target is absent).
25
+ * - Backup retention (last 3 per project-hash under
26
+ * `~/.ijfw/state/agents-md/backups/<hash>/`), matching shell parity.
27
+ *
28
+ * NON-GOALS
29
+ * - Frontmatter handling — the shell script delegated to
30
+ * `hoist-frontmatter.sh`. Marker-block merge ONLY (parity).
31
+ * - YAML schema validation — owned by the ijfw-agents-md skill's ajv path.
32
+ * - Multi-tier lock acquisition — callers acquire the §3 #8 AGENTS.md lock
33
+ * before calling `mergeFile`. This module never holds a lock itself.
34
+ *
35
+ * RESERVED BLOCK NAMES
36
+ * Matches the shell script verbatim:
37
+ * MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER
38
+ *
39
+ * ESM, Node ≥18, zero new prod deps.
40
+ */
41
+
42
+ import {
43
+ readFileSync, existsSync, copyFileSync, mkdirSync, statSync, readdirSync,
44
+ unlinkSync,
45
+ } from 'node:fs';
46
+ import { join, dirname, resolve as pathResolve } from 'node:path';
47
+ import { homedir } from 'node:os';
48
+ import { createHash } from 'node:crypto';
49
+ import { fileURLToPath } from 'node:url';
50
+
51
+ import { writeAtomic } from '../lib/atomic-io.js';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Constants
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * The frozen set of block names the merger accepts. Matches the shell
59
+ * script's `is_pair_form` + arg-validation case statements. An unknown block
60
+ * name yields `ERR_BAD_BLOCK` (the same exit-2 the shell script returns).
61
+ */
62
+ export const RESERVED_BLOCKS = Object.freeze([
63
+ 'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER',
64
+ ]);
65
+
66
+ const RESERVED_BLOCK_SET = new Set(RESERVED_BLOCKS);
67
+
68
+ /** Retention cap on backup files per project-hash (shell parity: keep 3). */
69
+ const BACKUP_RETAIN = 3;
70
+
71
+ /**
72
+ * Default template path (relative to this module). Used when `mergeFile` is
73
+ * called without `templatePath` and the target file does not exist.
74
+ */
75
+ const __filename = fileURLToPath(import.meta.url);
76
+ const __dirname = dirname(__filename);
77
+ const DEFAULT_TEMPLATE = pathResolve(
78
+ __dirname,
79
+ '..', '..', '..', 'claude', 'skills', 'ijfw-agents-md', 'templates',
80
+ 'AGENTS.md.tmpl',
81
+ );
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Error class
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Typed error so callers can distinguish bad-input refusal from an IO error.
89
+ * `code` mirrors the shell script's exit-code semantics:
90
+ * - 'ERR_BAD_BLOCK' — block name not in RESERVED_BLOCKS (sh: exit 2)
91
+ * - 'ERR_TEMPLATE_MISSING' — target absent + no template available (sh: 3)
92
+ * - 'ERR_BAD_PAYLOAD' — pairs argument malformed (sh: 2)
93
+ */
94
+ export class MergeBlockAwareError extends Error {
95
+ constructor(code, message) {
96
+ super(message);
97
+ this.name = 'MergeBlockAwareError';
98
+ this.code = code;
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Core merge — pure string → string. The hot path that the shell script's
104
+ // here-doc `node -e '…'` payload performed verbatim. Held under no lock.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Apply each `{block, content}` pair to `src`, producing the new file body.
109
+ *
110
+ * Semantics (verbatim port of merge-block-aware.sh's node here-doc):
111
+ * - If `<!-- IJFW-<block>-START -->` and `…-END -->` both exist AND the
112
+ * end-marker appears after the start-marker, replace the bytes BETWEEN
113
+ * them with `\n<content>\n` (or just `\n` when content is empty/null).
114
+ * Marker-positioning rules are identical to the shell port: `indexOf`
115
+ * for both markers, `endIdx > startIdx` to accept.
116
+ * - Otherwise (markers absent or out of order), APPEND a fresh marker pair
117
+ * containing the new content to the end of `src`. A leading newline is
118
+ * inserted if `src` does not already end with one (parity with the
119
+ * `sep = src.endsWith("\n") ? "" : "\n"` ternary).
120
+ * - Pairs are applied in order; the result of pair N becomes input to N+1.
121
+ * (Matches the shell loop `for (let i = 0; i < count; i++) …`.)
122
+ *
123
+ * Pure function: no I/O, no spawn, no locks. Idempotent on the same input.
124
+ *
125
+ * @param {string} src the current file contents (utf-8)
126
+ * @param {Array<{block: string, content: string}>} pairs
127
+ * @returns {string} the new file contents
128
+ * @throws {MergeBlockAwareError} on an unknown block name
129
+ */
130
+ export function mergeBlocks(src, pairs) {
131
+ if (typeof src !== 'string') {
132
+ throw new MergeBlockAwareError(
133
+ 'ERR_BAD_PAYLOAD', `mergeBlocks: src must be a string (got ${typeof src})`,
134
+ );
135
+ }
136
+ if (!Array.isArray(pairs)) {
137
+ throw new MergeBlockAwareError(
138
+ 'ERR_BAD_PAYLOAD', 'mergeBlocks: pairs must be an array',
139
+ );
140
+ }
141
+ let out = src;
142
+ for (const pair of pairs) {
143
+ if (!pair || typeof pair !== 'object') {
144
+ throw new MergeBlockAwareError(
145
+ 'ERR_BAD_PAYLOAD', 'mergeBlocks: each pair must be an object',
146
+ );
147
+ }
148
+ const block = pair.block;
149
+ if (typeof block !== 'string' || !RESERVED_BLOCK_SET.has(block)) {
150
+ throw new MergeBlockAwareError(
151
+ 'ERR_BAD_BLOCK',
152
+ `mergeBlocks: block name reserved set: ${RESERVED_BLOCKS.join(' ')} (got ${JSON.stringify(block)})`,
153
+ );
154
+ }
155
+ const content = (pair.content === undefined || pair.content === null)
156
+ ? ''
157
+ : String(pair.content);
158
+
159
+ const startM = `<!-- IJFW-${block}-START -->`;
160
+ const endM = `<!-- IJFW-${block}-END -->`;
161
+ const startIdx = out.indexOf(startM);
162
+ const endIdx = out.indexOf(endM);
163
+
164
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
165
+ const before = out.slice(0, startIdx + startM.length);
166
+ const after = out.slice(endIdx);
167
+ const inner = content && content.length ? `\n${content}\n` : '\n';
168
+ out = before + inner + after;
169
+ } else {
170
+ const sep = out.endsWith('\n') ? '' : '\n';
171
+ const inner = content && content.length ? `\n${content}\n` : '\n';
172
+ out = `${out}${sep}\n${startM}${inner}${endM}\n`;
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Backup retention — parity with the shell `node -e '…'` block that the script
180
+ // invokes alongside `cp`. Pure, deterministic, swallows io errors (best-effort
181
+ // in parity with the shell `2>/dev/null || true`).
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** sha256(targetDir).slice(0, 12) — matches the shell script's project-hash. */
185
+ export function projectHash(targetDir) {
186
+ return createHash('sha256').update(String(targetDir)).digest('hex').slice(0, 12);
187
+ }
188
+
189
+ /**
190
+ * Compute the canonical backup directory for `targetAbsPath`.
191
+ *
192
+ * Shell parity: `~/.ijfw/state/agents-md/backups/<projectHash(targetDir)>/`
193
+ * where `<targetDir>` is the realpath of `dirname(targetAbsPath)` and
194
+ * `<hash>` is sha256 hex (12 chars).
195
+ */
196
+ export function backupDirFor(targetAbsPath, opts = {}) {
197
+ const targetDir = dirname(pathResolve(targetAbsPath));
198
+ const home = opts.homeDir || homedir();
199
+ return join(home, '.ijfw', 'state', 'agents-md', 'backups', projectHash(targetDir));
200
+ }
201
+
202
+ /**
203
+ * Take a millisecond-timestamped backup of `targetAbsPath` into the canonical
204
+ * backup directory, then prune older entries past `retain` (newest-first).
205
+ *
206
+ * Mirrors the shell script's `cp` + `node -e '…sort by mtime…'` block. Best-
207
+ * effort: any IO failure is swallowed so a backup hiccup never blocks the
208
+ * merge (the shell script does `2>/dev/null || true`).
209
+ *
210
+ * @param {string} targetAbsPath absolute path of the target being merged
211
+ * @param {{homeDir?: string, retain?: number, now?: () => number}} [opts]
212
+ * @returns {{taken: boolean, path?: string, pruned: number}}
213
+ */
214
+ export function rotateBackups(targetAbsPath, opts = {}) {
215
+ const retain = typeof opts.retain === 'number' ? opts.retain : BACKUP_RETAIN;
216
+ const now = typeof opts.now === 'function' ? opts.now : Date.now;
217
+ try {
218
+ if (!existsSync(targetAbsPath)) return { taken: false, pruned: 0 };
219
+ const st = statSync(targetAbsPath);
220
+ if (!st || !st.size) return { taken: false, pruned: 0 };
221
+ const dir = backupDirFor(targetAbsPath, opts);
222
+ try { mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
223
+ const backupName = `AGENTS.md.bak.${String(now())}`;
224
+ const backupPath = join(dir, backupName);
225
+ try { copyFileSync(targetAbsPath, backupPath); } catch { return { taken: false, pruned: 0 }; }
226
+
227
+ // Retention — keep newest `retain`, unlink the rest. mtime-sorted to
228
+ // match the shell node payload's `sort((a,b) => b.mt - a.mt)`.
229
+ let pruned = 0;
230
+ try {
231
+ const entries = readdirSync(dir)
232
+ .filter((n) => n.startsWith('AGENTS.md.bak.'))
233
+ .map((n) => {
234
+ try { return { n, mt: statSync(join(dir, n)).mtimeMs }; }
235
+ catch { return null; }
236
+ })
237
+ .filter(Boolean)
238
+ .sort((a, b) => b.mt - a.mt);
239
+ for (const e of entries.slice(retain)) {
240
+ try { unlinkSync(join(dir, e.n)); pruned += 1; } catch { /* best-effort */ }
241
+ }
242
+ } catch { /* best-effort */ }
243
+
244
+ return { taken: true, path: backupPath, pruned };
245
+ } catch {
246
+ return { taken: false, pruned: 0 };
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // mergeFile — the high-level call. Reads, merges, writes-atomically.
252
+ // Held under no lock itself; the caller is expected to wrap it in
253
+ // `withFsLock` if concurrent writers are possible. (`agents-md-blackboard.js`
254
+ // does exactly that, under the §3 #8 AGENTS.md lock.)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Apply `pairs` to the file at `targetAbsPath` and rewrite atomically.
259
+ *
260
+ * Semantics (verbatim parity with merge-block-aware.sh):
261
+ * - If the target is absent, seed from `opts.templatePath` (default:
262
+ * `claude/skills/ijfw-agents-md/templates/AGENTS.md.tmpl` resolved
263
+ * relative to this module). A missing template throws
264
+ * ERR_TEMPLATE_MISSING.
265
+ * - Take a backup + prune older entries (best-effort).
266
+ * - Apply `mergeBlocks` to the current contents.
267
+ * - Write atomically via `writeAtomic` (tmp + rename).
268
+ *
269
+ * No subprocess at any point — the shell script's `cp` + `node -e '…'`
270
+ * pipeline is fully expressed in JS here.
271
+ *
272
+ * @param {string} targetAbsPath absolute path to the merge target
273
+ * @param {Array<{block: string, content: string}>} pairs
274
+ * @param {{
275
+ * templatePath?: string,
276
+ * homeDir?: string,
277
+ * retain?: number,
278
+ * now?: () => number,
279
+ * backups?: boolean,
280
+ * }} [opts]
281
+ * @returns {{ ok: true, path: string, bytes: number, backup?: string, seeded: boolean }}
282
+ */
283
+ export function mergeFile(targetAbsPath, pairs, opts = {}) {
284
+ if (typeof targetAbsPath !== 'string' || !targetAbsPath) {
285
+ throw new MergeBlockAwareError(
286
+ 'ERR_BAD_PAYLOAD', 'mergeFile: targetAbsPath must be a non-empty string',
287
+ );
288
+ }
289
+ if (!Array.isArray(pairs) || pairs.length === 0) {
290
+ throw new MergeBlockAwareError(
291
+ 'ERR_BAD_PAYLOAD', 'mergeFile: pairs must be a non-empty array',
292
+ );
293
+ }
294
+ const abs = pathResolve(targetAbsPath);
295
+
296
+ // Seed from template if absent. Shell script behaviour: `cp <template> <target>`
297
+ // on missing target.
298
+ let seeded = false;
299
+ if (!existsSync(abs)) {
300
+ const templatePath = opts.templatePath || DEFAULT_TEMPLATE;
301
+ if (!existsSync(templatePath)) {
302
+ throw new MergeBlockAwareError(
303
+ 'ERR_TEMPLATE_MISSING',
304
+ `mergeFile: template missing at ${templatePath}`,
305
+ );
306
+ }
307
+ const dir = dirname(abs);
308
+ try { mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
309
+ copyFileSync(templatePath, abs);
310
+ seeded = true;
311
+ }
312
+
313
+ // Backup + retention (best-effort, defaults on). `opts.backups === false`
314
+ // suppresses (used by some tests to keep tmp clean).
315
+ let backup;
316
+ if (opts.backups !== false) {
317
+ const rot = rotateBackups(abs, opts);
318
+ if (rot.taken && rot.path) backup = rot.path;
319
+ }
320
+
321
+ const src = readFileSync(abs, 'utf8');
322
+ const next = mergeBlocks(src, pairs);
323
+ const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
324
+ return {
325
+ ok: true, path: res.path, bytes: res.bytes, backup, seeded,
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Convenience: extract the inner content of a marker block from a string.
331
+ // Used by tests + parity checks; not part of the write path.
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /**
335
+ * Extract the inner content of `block` from `src`, exclusive of marker bytes.
336
+ * Returns null when the markers are absent or in the wrong order.
337
+ *
338
+ * @param {string} src
339
+ * @param {string} block one of RESERVED_BLOCKS
340
+ * @returns {string | null}
341
+ */
342
+ export function readBlock(src, block) {
343
+ if (!RESERVED_BLOCK_SET.has(block)) return null;
344
+ const startM = `<!-- IJFW-${block}-START -->`;
345
+ const endM = `<!-- IJFW-${block}-END -->`;
346
+ const s = src.indexOf(startM);
347
+ const e = src.indexOf(endM);
348
+ if (s === -1 || e === -1 || e <= s) return null;
349
+ return src.slice(s + startM.length, e);
350
+ }