@intentius/chant 0.49.0 → 0.51.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 (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -29,95 +29,160 @@ const STATE_BRANCH = "chant/lifecycle";
29
29
  * this module that pass a non-env value (like `_builds`) are relying on that
30
30
  * generic behavior, not on any env-specific semantics.
31
31
  */
32
+ /**
33
+ * Bounded retry budget for {@link writeBlobToPath}'s own internal CAS retry
34
+ * (#1959 finding 1). A conflict caused by some *other* env/file entry
35
+ * changing concurrently (the ordinary case — e.g. two operator ticks
36
+ * targeting different envs) is safe to resolve by simply rebuilding the tree
37
+ * against the new tip and retrying; this bounds how many times it does that
38
+ * before giving up and surfacing the conflict.
39
+ */
40
+ const WRITE_BLOB_RETRY_ATTEMPTS = 5;
41
+
32
42
  export async function writeBlobToPath(
33
43
  environment: string,
34
44
  filename: string,
35
45
  content: string,
36
46
  commitMessage: string,
37
- opts?: { cwd?: string },
47
+ opts?: { cwd?: string; expectPriorPathSha?: string | null },
38
48
  ): Promise<string> {
39
49
  const rt = getRuntime();
40
50
  const cwd = opts?.cwd;
41
51
 
42
- // 1. Write blob — hash-object reads from stdin, but spawn() doesn't expose
43
- // a stdin handle, so we run via a shell pipeline (`echo … | git hash-object`).
44
- const blobResult = await rt.spawn(
45
- ["sh", "-c", `echo '${content.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
46
- { cwd },
47
- );
52
+ // 1. Write blob — hash-object reads from stdin. Fed directly via spawn's
53
+ // stdin (no shell involved), so content is written byte-for-byte: no `sh`
54
+ // `echo` reinterpreting backslash-escape sequences (e.g. `\n` inside JSON,
55
+ // as in a serialized kubectl.kubernetes.io/last-applied-configuration
56
+ // annotation) and no shell-quoting dance around embedded single quotes.
57
+ // Content-addressed and idempotent, so this stays outside the retry loop
58
+ // below — nothing about a CAS conflict on the ref ever invalidates it.
59
+ const blobResult = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd, stdin: content });
48
60
  if (blobResult.exitCode !== 0) {
49
61
  throw new Error(`git hash-object failed: ${blobResult.stderr}`);
50
62
  }
51
63
  const blobSha = blobResult.stdout.trim();
52
64
 
53
- // 2. Read existing tree (if branch exists) to preserve other env/file entries
54
- const existingTree = await readTree(cwd);
55
-
56
- // 3. Build new tree entries
57
65
  const path = `${environment}/${filename}`;
58
- const entries = mergeTreeEntry(existingTree, path, blobSha);
59
-
60
- // mktree needs a nested tree structure. Build env subtree first, then root tree.
61
- // Build env subtree
62
- const envEntries = entries
63
- .filter((e) => e.env === environment)
64
- .map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`)
65
- .join("\n");
66
66
 
67
- const envTreeResult = await rt.spawn(
68
- ["sh", "-c", `printf '%s\\n' ${shellQuoteLines(envEntries)} | git mktree`],
69
- { cwd },
70
- );
71
- if (envTreeResult.exitCode !== 0) {
72
- throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
73
- }
74
- const envTreeSha = envTreeResult.stdout.trim();
75
-
76
- // Build root tree: collect env subtrees
77
- const rootEntries: string[] = [];
78
- const envsSeen = new Set<string>();
79
- for (const e of entries) {
80
- if (!envsSeen.has(e.env)) {
81
- envsSeen.add(e.env);
82
- if (e.env === environment) {
83
- rootEntries.push(`040000 tree ${envTreeSha}\t${environment}`);
84
- } else {
85
- rootEntries.push(`040000 tree ${e.envTreeSha!}\t${e.env}`);
86
- }
67
+ // 2-5. Read tree, build the commit, and CAS-update the branch ref — retried
68
+ // on a conflict (#1959 finding 1). `writeBlobToPath` had no retry of its
69
+ // own before this: a conflict from step 5's `updateRefCAS` simply threw,
70
+ // breaking every pre-existing caller (observation-baseline.ts,
71
+ // snapshot.ts, build-ledger-store.ts, and — via `appendReleaseRecordLine`
72
+ // below — release-ledger.ts) the moment any concurrent writer touched the
73
+ // orphan branch, e.g. a live `chant operator` ticking a different env.
74
+ //
75
+ // Safety of a *blind* retry (same `content`/`blobSha`, freshly rebuilt
76
+ // tree) hinges on whether *our own* target path (`environment/filename`)
77
+ // is what actually caused the conflict:
78
+ // - If some OTHER path changed (the ordinary multi-env/multi-file case),
79
+ // our data is unaffected — rebuilding the tree from the new tip and
80
+ // retrying the ref update is always correct, no matter what kind of
81
+ // write the caller is doing (replace or append).
82
+ // - If OUR OWN path changed concurrently, a blind retry using `content`
83
+ // computed before that race would silently clobber the other writer's
84
+ // change — safe only for a caller whose `content` is a self-contained
85
+ // replacement (writeObservationBaseline, writeSnapshot,
86
+ // persistBuildManifest), never for a read-modify-write caller whose
87
+ // `content` already embeds a stale read (an append). Since this
88
+ // function can't tell those apart, it does NOT blind-retry in that
89
+ // case — it re-throws immediately so a read-modify-write caller's own
90
+ // outer retry (appendConvergeRecord, appendGateResolution,
91
+ // appendReleaseRecordLine below) can re-read and recompute `content`
92
+ // fresh before trying again, exactly as they already do.
93
+ //
94
+ // - A read-modify-write caller must pass `expectPriorPathSha`. Without it
95
+ // attempt 1 has no prior sha to compare, so a commit landing between the
96
+ // caller's baseline read and this function's first tree read reads as the
97
+ // ambient starting state rather than a conflict, and is overwritten from
98
+ // the caller's stale content.
99
+ let lastErr: unknown;
100
+ let priorPathSha: string | null | undefined = opts?.expectPriorPathSha;
101
+ for (let attempt = 1; attempt <= WRITE_BLOB_RETRY_ATTEMPTS; attempt++) {
102
+ // 2. Read existing tree (if branch exists) to preserve other env/file
103
+ // entries. `tip` is the commit sha `entries` was read from, and must stay
104
+ // the commit parent and CAS oldValue below. Re-resolving the branch name
105
+ // there instead can observe a newer tip than `entries` reflects, which
106
+ // makes the CAS succeed against a tree built from a stale read.
107
+ const { tip, entries: existingTree } = await readTree(cwd);
108
+ const currentPathSha = existingTree.find((e) => e.env === environment && e.name === filename)?.sha ?? null;
109
+
110
+ if (priorPathSha !== undefined && currentPathSha !== priorPathSha) {
111
+ // Our own path moved since our previous attempt started — a genuine
112
+ // content-level race on the exact file we're writing, not just a CAS
113
+ // conflict from a sibling path. Not safe to paper over here.
114
+ throw lastErr ?? new RefCASConflictError(`refs/heads/${STATE_BRANCH}`, priorPathSha, "target path changed concurrently");
87
115
  }
88
- }
116
+ priorPathSha = currentPathSha;
89
117
 
90
- const rootTreeResult = await rt.spawn(
91
- ["sh", "-c", `printf '%s\\n' ${shellQuoteLines(rootEntries.join("\n"))} | git mktree`],
92
- { cwd },
93
- );
94
- if (rootTreeResult.exitCode !== 0) {
95
- throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
96
- }
97
- const rootTreeSha = rootTreeResult.stdout.trim();
118
+ // 3. Build new tree entries
119
+ const entries = mergeTreeEntry(existingTree, path, blobSha);
98
120
 
99
- // 4. Create commit
100
- const parentRef = await getStateBranchTip(cwd);
101
- const parentArgs = parentRef ? ["-p", parentRef] : [];
102
- const commitResult = await rt.spawn(
103
- ["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
104
- { cwd },
105
- );
106
- if (commitResult.exitCode !== 0) {
107
- throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
108
- }
109
- const commitSha = commitResult.stdout.trim();
121
+ // mktree needs a nested tree structure. Build env subtree first, then root tree.
122
+ // Build env subtree
123
+ const envEntries = entries
124
+ .filter((e) => e.env === environment)
125
+ .map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`)
126
+ .join("\n");
110
127
 
111
- // 5. Update ref
112
- const updateResult = await rt.spawn(
113
- ["git", "update-ref", `refs/heads/${STATE_BRANCH}`, commitSha],
114
- { cwd },
115
- );
116
- if (updateResult.exitCode !== 0) {
117
- throw new Error(`git update-ref failed: ${updateResult.stderr}`);
118
- }
128
+ const envTreeResult = await rt.spawn(["git", "mktree"], { cwd, stdin: `${envEntries}\n` });
129
+ if (envTreeResult.exitCode !== 0) {
130
+ throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
131
+ }
132
+ const envTreeSha = envTreeResult.stdout.trim();
133
+
134
+ // Build root tree: collect env subtrees
135
+ const rootEntries: string[] = [];
136
+ const envsSeen = new Set<string>();
137
+ for (const e of entries) {
138
+ if (!envsSeen.has(e.env)) {
139
+ envsSeen.add(e.env);
140
+ if (e.env === environment) {
141
+ rootEntries.push(`040000 tree ${envTreeSha}\t${environment}`);
142
+ } else {
143
+ rootEntries.push(`040000 tree ${e.envTreeSha!}\t${e.env}`);
144
+ }
145
+ }
146
+ }
119
147
 
120
- return commitSha;
148
+ const rootTreeResult = await rt.spawn(["git", "mktree"], {
149
+ cwd,
150
+ stdin: `${rootEntries.join("\n")}\n`,
151
+ });
152
+ if (rootTreeResult.exitCode !== 0) {
153
+ throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
154
+ }
155
+ const rootTreeSha = rootTreeResult.stdout.trim();
156
+
157
+ // 4. Create commit — parented on `tip`, the exact sha `existingTree` was
158
+ // read from (see the comment on step 2), not a fresh re-resolution of
159
+ // the branch name.
160
+ const parentRef = tip;
161
+ const parentArgs = parentRef ? ["-p", parentRef] : [];
162
+ const commitResult = await rt.spawn(
163
+ ["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
164
+ { cwd },
165
+ );
166
+ if (commitResult.exitCode !== 0) {
167
+ throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
168
+ }
169
+ const commitSha = commitResult.stdout.trim();
170
+
171
+ // 5. Update ref — CAS-guarded against `parentRef` (#1485): closes the
172
+ // local race two concurrent writers used to hit silently (whoever called
173
+ // update-ref last simply overwrote the other's tree, no error). A
174
+ // conflict here throws RefCASConflictError instead of clobbering.
175
+ try {
176
+ await updateRefCAS(`refs/heads/${STATE_BRANCH}`, commitSha, parentRef, { cwd });
177
+ return commitSha;
178
+ } catch (err) {
179
+ if (!(err instanceof RefCASConflictError)) throw err;
180
+ lastErr = err;
181
+ // Loop and rebuild against the new tip — see the safety analysis
182
+ // above; the top of the next iteration decides whether that's safe.
183
+ }
184
+ }
185
+ throw lastErr;
121
186
  }
122
187
 
123
188
  /**
@@ -141,6 +206,27 @@ export async function readBlobFromPath(
141
206
  return result.stdout;
142
207
  }
143
208
 
209
+ /**
210
+ * Read the blob SHA stored at `<environment>/<filename>` on the orphan branch,
211
+ * or `null` if absent. Sibling of `readBlobFromPath` returning the
212
+ * content-address rather than the content. A read-modify-write ledger append
213
+ * pairs this with {@link readBlobBySha} to pin its baseline read to an exact
214
+ * sha, then passes that sha as `writeBlobToPath`'s `expectPriorPathSha`.
215
+ */
216
+ export async function readPathSha(
217
+ environment: string,
218
+ filename: string,
219
+ opts?: { cwd?: string },
220
+ ): Promise<string | null> {
221
+ const rt = getRuntime();
222
+ const result = await rt.spawn(
223
+ ["git", "rev-parse", "--verify", `${STATE_BRANCH}:${environment}/${filename}`],
224
+ { cwd: opts?.cwd },
225
+ );
226
+ if (result.exitCode !== 0) return null;
227
+ return result.stdout.trim() || null;
228
+ }
229
+
144
230
  /**
145
231
  * Storage key for a snapshot on the orphan branch. Single-stack projects key by
146
232
  * lexicon (`<env>/<lexicon>.json`, unchanged). A multi-stack project (see
@@ -216,6 +302,20 @@ export async function readSnapshotAt(
216
302
  * Returns the new orphan-branch commit SHA — the caller still owns pushing
217
303
  * via `pushLifecycle` under the same concurrent-write lease `writeSnapshot`
218
304
  * uses.
305
+ *
306
+ * Retries the whole read-modify-append cycle on `RefCASConflictError`
307
+ * (#1959 finding 1), the same shape `appendConvergeRecord`
308
+ * (./converge-ledger.ts) and `appendGateResolution` (./gate-ledger.ts) use
309
+ * for their own append-only ledgers: `writeBlobToPath`'s own retry only
310
+ * absorbs a conflict caused by some *other* env/file changing — a conflict
311
+ * on this exact `releases.jsonl` (e.g. two deploys to the same env racing)
312
+ * needs `existing` re-read fresh so the appended line list is rebuilt onto
313
+ * whatever the other writer just committed, not silently dropped by
314
+ * retrying with a blob computed from a stale read.
315
+ *
316
+ * The baseline read must be `readPathSha` + `readBlobBySha` rather than
317
+ * `readBlobFromPath`, so the exact sha `existing` came from can be passed as
318
+ * `expectPriorPathSha`. See `writeBlobToPath` for the race that closes.
219
319
  */
220
320
  export async function appendReleaseRecordLine(
221
321
  environment: string,
@@ -223,9 +323,23 @@ export async function appendReleaseRecordLine(
223
323
  opts?: { cwd?: string },
224
324
  ): Promise<string> {
225
325
  const filename = "releases.jsonl";
226
- const existing = await readBlobFromPath(environment, filename, opts);
227
- const content = existing ? `${existing.replace(/\n$/, "")}\n${recordJson}` : recordJson;
228
- return writeBlobToPath(environment, filename, content, "Release record", opts);
326
+
327
+ let lastErr: unknown;
328
+ for (let attempt = 1; attempt <= WRITE_BLOB_RETRY_ATTEMPTS; attempt++) {
329
+ try {
330
+ const priorSha = await readPathSha(environment, filename, opts);
331
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
332
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${recordJson}` : recordJson;
333
+ return await writeBlobToPath(environment, filename, content, "Release record", {
334
+ ...opts,
335
+ expectPriorPathSha: priorSha,
336
+ });
337
+ } catch (err) {
338
+ if (!(err instanceof RefCASConflictError)) throw err;
339
+ lastErr = err;
340
+ }
341
+ }
342
+ throw lastErr;
229
343
  }
230
344
 
231
345
  /**
@@ -432,6 +546,251 @@ export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean>
432
546
  return fetchResult.exitCode === 0;
433
547
  }
434
548
 
549
+ // ── Generic ref CAS (#1485) ──────────────────────────────────────────────────
550
+ //
551
+ // `writeBlobToPath`'s own `update-ref` call (above) had no compare-and-swap
552
+ // until this issue: two concurrent local writers could each build a tree from
553
+ // what they read as "current", and whichever called `update-ref` last simply
554
+ // overwrote the ref with no error — the other writer's change vanished
555
+ // silently. The primitives below give any caller (this module's own
556
+ // `writeBlobToPath`, and `./lease.ts`'s lease ref) a compare-and-swap guard
557
+ // building on what `git update-ref` already supports natively: pass the
558
+ // value you last observed as `<oldvalue>`, and the update only lands if the
559
+ // ref still points there. No lock file — `update-ref` itself is already an
560
+ // atomic local mutex (lockfile-then-rename under the hood), so this is
561
+ // exactly the "extend the ref-write helper" shape, not a second mechanism.
562
+
563
+ /**
564
+ * Thrown by {@link updateRefCAS}/{@link deleteRefCAS} when `ref` no longer
565
+ * points at the `oldValue` the caller last observed — another writer moved
566
+ * it concurrently. Deliberately a distinct type from a generic git failure so
567
+ * callers (a lease acquire, a retried ledger append) can tell "I lost a race"
568
+ * from "git itself failed" and react differently to each.
569
+ */
570
+ export class RefCASConflictError extends Error {
571
+ constructor(
572
+ public readonly ref: string,
573
+ public readonly expected: string | null,
574
+ stderr: string,
575
+ ) {
576
+ super(
577
+ `ref "${ref}" moved concurrently — expected ${expected ?? "(ref must not exist)"}, ` +
578
+ `but another writer updated it first. git stderr: ${stderr.trim()}`,
579
+ );
580
+ this.name = "RefCASConflictError";
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Thrown by {@link updateRefCAS}/{@link deleteRefCAS} when the ref update
586
+ * failed because git found a stale `.lock` file already sitting next to the
587
+ * ref (#1959 finding 2) — what a `chant operator`/`chant approve`/etc.
588
+ * process leaves behind when it is killed (SIGKILL, OOM, `kill -9`) mid-write,
589
+ * *before* `git update-ref`'s own lockfile-then-rename completes. This is
590
+ * exactly the crash this feature must recover from, and it is NOT a CAS
591
+ * conflict: nobody else actually holds the ref (no other writer is racing,
592
+ * the previous one is simply dead), so it must never be misread as "someone
593
+ * else updated it first" — see `./lease.ts`'s `acquireLease`, which used to
594
+ * (before this fix) read this as "lease held by someone else" and quietly
595
+ * back off forever, since the dead process's lock file never goes away on
596
+ * its own.
597
+ */
598
+ export class StaleLockError extends Error {
599
+ constructor(
600
+ public readonly ref: string,
601
+ public readonly lockPath: string,
602
+ stderr: string,
603
+ ) {
604
+ super(
605
+ `ref "${ref}" has a stale lock file at ${lockPath} — a previous git process ` +
606
+ `(e.g. a killed \`chant operator\`) was interrupted mid-write and left it ` +
607
+ `behind. Fix: remove it (\`rm ${lockPath}\`) and retry. git stderr: ${stderr.trim()}`,
608
+ );
609
+ this.name = "StaleLockError";
610
+ }
611
+ }
612
+
613
+ /** Matches git's "Unable to create '<path>.lock': File exists." — the one stable, version-independent phrase every `git update-ref`/`git commit` etc. failure due to a leftover lock file uses, regardless of which ref or which git version. */
614
+ const STALE_LOCK_RE = /Unable to create '([^']+)':\s*File exists/;
615
+
616
+ /** Read the SHA `ref` currently points to, or `null` if it doesn't exist. Works for any ref, not just the lifecycle branch. */
617
+ export async function readRefSha(ref: string, opts?: { cwd?: string }): Promise<string | null> {
618
+ const rt = getRuntime();
619
+ const result = await rt.spawn(["git", "rev-parse", "--verify", ref], { cwd: opts?.cwd });
620
+ if (result.exitCode !== 0) return null;
621
+ return result.stdout.trim() || null;
622
+ }
623
+
624
+ /**
625
+ * Turn a failed `git update-ref`'s stderr into the right error type (#1959
626
+ * finding 2). Real git collapses several distinct failure modes into the
627
+ * same nonzero exit code — a genuine CAS mismatch, a stale lock file left by
628
+ * a killed process, and an outright bad ref name all just say "update_ref
629
+ * failed" — so this doesn't trust the exit code alone:
630
+ *
631
+ * 1. A stale `.lock` file has a stable, distinctive message (see
632
+ * `STALE_LOCK_RE`) — checked first and reported as its own
633
+ * {@link StaleLockError}, never as a conflict.
634
+ * 2. Otherwise, re-read the ref's actual current value and compare it to
635
+ * `oldValue`: if it genuinely differs, this is a real CAS conflict —
636
+ * {@link RefCASConflictError}. This is the authoritative check (not
637
+ * stderr text matching) so it holds across git versions/locales.
638
+ * 3. If the ref's actual value still matches `oldValue` — the CAS itself
639
+ * should have succeeded — the failure is something else entirely (a bad
640
+ * ref name, a permissions problem, disk full, ...) and is surfaced as a
641
+ * plain `Error`, not miscategorized as either of the above.
642
+ */
643
+ async function classifyRefFailure(
644
+ ref: string,
645
+ oldValue: string | null,
646
+ stderr: string,
647
+ opts?: { cwd?: string },
648
+ ): Promise<Error> {
649
+ const lockMatch = stderr.match(STALE_LOCK_RE);
650
+ if (lockMatch) {
651
+ return new StaleLockError(ref, lockMatch[1], stderr);
652
+ }
653
+ const actual = await readRefSha(ref, opts);
654
+ if (actual !== oldValue) {
655
+ return new RefCASConflictError(ref, oldValue, stderr);
656
+ }
657
+ return new Error(`git update-ref failed for ref "${ref}": ${stderr.trim()}`);
658
+ }
659
+
660
+ /**
661
+ * Compare-and-swap update of an arbitrary ref. `oldValue` is the SHA the
662
+ * caller last observed the ref at, or `null` to assert the ref does not yet
663
+ * exist (git's own convention: an empty `<oldvalue>` argument to
664
+ * `update-ref` means "must not exist"). Throws {@link RefCASConflictError}
665
+ * when the ref genuinely moved since `oldValue` was read, {@link
666
+ * StaleLockError} when a leftover lock file from a killed process is
667
+ * blocking the write, or a plain `Error` for anything else — never silently
668
+ * overwrites, and never misclassifies one failure as another (#1959 finding
669
+ * 2; see {@link classifyRefFailure}).
670
+ */
671
+ export async function updateRefCAS(
672
+ ref: string,
673
+ newValue: string,
674
+ oldValue: string | null,
675
+ opts?: { cwd?: string },
676
+ ): Promise<void> {
677
+ const rt = getRuntime();
678
+ const result = await rt.spawn(["git", "update-ref", ref, newValue, oldValue ?? ""], { cwd: opts?.cwd });
679
+ if (result.exitCode !== 0) {
680
+ throw await classifyRefFailure(ref, oldValue, result.stderr ?? "", opts);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Compare-and-swap delete of an arbitrary ref — `oldValue` is required (no
686
+ * "delete unconditionally" escape hatch here) so releasing a lease you no
687
+ * longer hold can never delete someone else's newer one. Same failure
688
+ * classification as {@link updateRefCAS} (#1959 finding 2).
689
+ */
690
+ export async function deleteRefCAS(ref: string, oldValue: string, opts?: { cwd?: string }): Promise<void> {
691
+ const rt = getRuntime();
692
+ const result = await rt.spawn(["git", "update-ref", "-d", ref, oldValue], { cwd: opts?.cwd });
693
+ if (result.exitCode !== 0) {
694
+ throw await classifyRefFailure(ref, oldValue, result.stderr ?? "", opts);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Write arbitrary content as a git blob object — no tree, no commit, no ref
700
+ * update. The building block a CAS ref's value can point at directly: a
701
+ * lease record (`./lease.ts`) has no meaningful "tree of files", so its ref
702
+ * targets a blob SHA rather than a commit the way `writeBlobToPath`'s tree-
703
+ * building pipeline does.
704
+ */
705
+ export async function writeBlob(content: string, opts?: { cwd?: string }): Promise<string> {
706
+ const rt = getRuntime();
707
+ const result = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd: opts?.cwd, stdin: content });
708
+ if (result.exitCode !== 0) throw new Error(`git hash-object failed: ${result.stderr}`);
709
+ return result.stdout.trim();
710
+ }
711
+
712
+ /** Read a blob's raw content by its SHA (whatever object a ref points at directly). Returns `null` when the object doesn't exist locally. */
713
+ export async function readBlobBySha(sha: string, opts?: { cwd?: string }): Promise<string | null> {
714
+ const rt = getRuntime();
715
+ const result = await rt.spawn(["git", "cat-file", "blob", sha], { cwd: opts?.cwd });
716
+ if (result.exitCode !== 0) return null;
717
+ return result.stdout;
718
+ }
719
+
720
+ /**
721
+ * Push one arbitrary ref (e.g. a lease ref) to the remote, guarded the same
722
+ * way {@link pushLifecycle} guards the ledger branch: `--force-with-lease`
723
+ * keyed to the remote SHA last observed locally, so a concurrent push from a
724
+ * second machine is rejected rather than silently clobbered. Plain `--force`
725
+ * underneath that lease — a lease ref's value is a bare blob SHA, not a
726
+ * commit descending from the previous one, so there is no "fast-forward" to
727
+ * preserve, only the CAS the lease guard already provides.
728
+ *
729
+ * Returns `false` (never throws) when no remote is configured — a
730
+ * remote-less project's lease is local-only by construction (see
731
+ * `./lease.ts`'s module doc), or when the push itself is rejected (the
732
+ * caller re-reads and retries; see `acquireLease`).
733
+ */
734
+ export async function pushRef(ref: string, opts?: { cwd?: string }): Promise<boolean> {
735
+ const rt = getRuntime();
736
+ const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
737
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
738
+ const remote = remoteResult.stdout.trim().split("\n")[0];
739
+
740
+ const remoteRef = `refs/remotes/${remote}/${ref.replace(/^refs\//, "")}`;
741
+ const expectedResult = await rt.spawn(["git", "rev-parse", "--verify", remoteRef], { cwd: opts?.cwd });
742
+ const expected = expectedResult.exitCode === 0 ? expectedResult.stdout.trim() : null;
743
+ const lease = `${ref}:${expected ?? ""}`;
744
+
745
+ const pushResult = await rt.spawn(
746
+ ["git", "push", "--force", `--force-with-lease=${lease}`, remote, `${ref}:${ref}`],
747
+ { cwd: opts?.cwd },
748
+ );
749
+ return pushResult.exitCode === 0;
750
+ }
751
+
752
+ /**
753
+ * Fetch one arbitrary remote ref into a local ref of a possibly *different*
754
+ * name (#1959 finding 3). `+` forces the update even when it isn't a
755
+ * fast-forward (a lease ref's new value is rarely a descendant of its old
756
+ * one). Returns `false` (never throws) when no remote is configured.
757
+ *
758
+ * The `localRef !== remoteRef` shape exists so a read path can observe what
759
+ * the remote currently holds without ever touching a local ref another code
760
+ * path treats as CAS-authoritative — see {@link fetchRef}'s doc and
761
+ * `./lease.ts`'s `readLease`, which fetches into a side tracking ref
762
+ * (`refs/chant/lease-remote/<op>`) for exactly this reason.
763
+ */
764
+ export async function fetchRefInto(
765
+ remoteRef: string,
766
+ localRef: string,
767
+ opts?: { cwd?: string },
768
+ ): Promise<boolean> {
769
+ const rt = getRuntime();
770
+ const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
771
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
772
+ const remote = remoteResult.stdout.trim().split("\n")[0];
773
+ const fetchResult = await rt.spawn(["git", "fetch", remote, `+${remoteRef}:${localRef}`], { cwd: opts?.cwd });
774
+ return fetchResult.exitCode === 0;
775
+ }
776
+
777
+ /**
778
+ * Fetch one arbitrary ref from remote into the same local ref name.
779
+ *
780
+ * **Caution for a read path (#1959 finding 3):** this force-overwrites
781
+ * `ref` locally (`+ref:ref`) — safe for a ref only a CAS write path ever
782
+ * mutates locally between fetches, but NOT safe to call from a plain read
783
+ * before every read if some other local writer (in the same clone) might be
784
+ * mid-write: fetching here would force the local ref back to whatever the
785
+ * remote last had, clobbering a just-written, not-yet-pushed local value out
786
+ * from under it. `./lease.ts`'s `readLease` used to do exactly that; it now
787
+ * uses {@link fetchRefInto} against a side tracking ref instead. Prefer
788
+ * `fetchRefInto` for any new read-before-decide path.
789
+ */
790
+ export async function fetchRef(ref: string, opts?: { cwd?: string }): Promise<boolean> {
791
+ return fetchRefInto(ref, ref, opts);
792
+ }
793
+
435
794
  /**
436
795
  * Get the current HEAD commit SHA of the main working branch.
437
796
  */
@@ -465,17 +824,25 @@ async function getStateBranchTip(cwd?: string): Promise<string | null> {
465
824
  return result.stdout.trim();
466
825
  }
467
826
 
468
- async function readTree(cwd?: string): Promise<TreeEntry[]> {
827
+ /**
828
+ * Read the orphan branch's tip and every env/file tree entry under it as one
829
+ * consistent snapshot. Every listing must stay pinned to the resolved `tip`
830
+ * sha, never to `STATE_BRANCH`: the read spans several `ls-tree` calls (root
831
+ * plus one per env subtree) and a branch name re-resolves on each, so a
832
+ * concurrent commit splices entries from two commits into one array
833
+ * undetectably. Callers need the returned `tip` as their commit parent.
834
+ */
835
+ async function readTree(cwd?: string): Promise<{ tip: string | null; entries: TreeEntry[] }> {
469
836
  const rt = getRuntime();
470
837
  const tip = await getStateBranchTip(cwd);
471
- if (!tip) return [];
838
+ if (!tip) return { tip: null, entries: [] };
472
839
 
473
- // List root tree to get env directories
840
+ // List root tree to get env directories — pinned to `tip`, not `STATE_BRANCH`.
474
841
  const rootResult = await rt.spawn(
475
- ["git", "ls-tree", STATE_BRANCH],
842
+ ["git", "ls-tree", tip],
476
843
  { cwd },
477
844
  );
478
- if (rootResult.exitCode !== 0) return [];
845
+ if (rootResult.exitCode !== 0) return { tip, entries: [] };
479
846
 
480
847
  const entries: TreeEntry[] = [];
481
848
  const lines = rootResult.stdout.trim().split("\n").filter(Boolean);
@@ -487,9 +854,9 @@ async function readTree(cwd?: string): Promise<TreeEntry[]> {
487
854
  const [, mode, type, sha, name] = match;
488
855
 
489
856
  if (type === "tree") {
490
- // This is an env directory — list its contents
857
+ // This is an env directory — list its contents, still pinned to `tip`.
491
858
  const envResult = await rt.spawn(
492
- ["git", "ls-tree", `${STATE_BRANCH}:${name}/`],
859
+ ["git", "ls-tree", `${tip}:${name}/`],
493
860
  { cwd },
494
861
  );
495
862
  if (envResult.exitCode !== 0) continue;
@@ -510,7 +877,7 @@ async function readTree(cwd?: string): Promise<TreeEntry[]> {
510
877
  }
511
878
  }
512
879
 
513
- return entries;
880
+ return { tip, entries };
514
881
  }
515
882
 
516
883
  function mergeTreeEntry(
@@ -531,8 +898,3 @@ function mergeTreeEntry(
531
898
  });
532
899
  return entries;
533
900
  }
534
-
535
- function shellQuoteLines(input: string): string {
536
- // Escape for printf in shell
537
- return `'${input.replace(/'/g, "'\\''")}'`;
538
- }
@@ -7,6 +7,7 @@ export * from "./deep-diff";
7
7
  export * from "./deep-observe";
8
8
  export * from "./observation-baseline";
9
9
  export * from "./change-set";
10
+ export * from "./disruption";
10
11
  export * from "./unobserved-gate";
11
12
  export * from "./receipt-plan";
12
13
  export * from "./affected";
@@ -16,3 +17,8 @@ export * from "./build-ledger-store";
16
17
  export * from "./oras-referrer-lookup";
17
18
  export * from "./status";
18
19
  export * from "./teardown";
20
+ export * from "./assert-live";
21
+ export * from "./symptoms";
22
+ export * from "./converge-ledger";
23
+ export * from "./scenario";
24
+ export * from "./scenario-eval";