@open-agent-toolkit/cli 0.2.5 → 0.2.6

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 (103) hide show
  1. package/assets/docs/cli-utilities/configuration.md +40 -0
  2. package/assets/docs/cli-utilities/tool-packs.md +31 -0
  3. package/assets/docs/workflows/projects/artifacts.md +27 -0
  4. package/assets/docs/workflows/skills/explainer-kit.md +125 -0
  5. package/assets/docs/workflows/skills/index.md +4 -0
  6. package/assets/migration/pjm-restructure.md +12 -0
  7. package/assets/public-package-versions.json +4 -4
  8. package/assets/skills/explainer-kit/SKILL.md +95 -0
  9. package/assets/skills/explainer-kit/examples/project-explainer/content.md +31 -0
  10. package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +22 -0
  11. package/assets/skills/explainer-kit/examples/project-recap/content.md +34 -0
  12. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +22 -0
  13. package/assets/skills/explainer-kit/examples/theme-bundle.json +87 -0
  14. package/assets/skills/explainer-kit/palettes/ember.json +37 -0
  15. package/assets/skills/explainer-kit/palettes/forest.json +37 -0
  16. package/assets/skills/explainer-kit/palettes/neutral.json +37 -0
  17. package/assets/skills/explainer-kit/palettes/ocean.json +37 -0
  18. package/assets/skills/explainer-kit/palettes/violet.json +37 -0
  19. package/assets/skills/explainer-kit/profiles/clean.json +42 -0
  20. package/assets/skills/explainer-kit/profiles/editorial.json +42 -0
  21. package/assets/skills/explainer-kit/profiles/technical.json +39 -0
  22. package/assets/skills/explainer-kit/recipes/engineer-tour.json +33 -0
  23. package/assets/skills/explainer-kit/recipes/program-recap.json +34 -0
  24. package/assets/skills/explainer-kit/recipes/project-explainer.json +33 -0
  25. package/assets/skills/explainer-kit/recipes/project-recap.json +34 -0
  26. package/assets/skills/explainer-kit/references/contracts.md +101 -0
  27. package/assets/skills/explainer-kit/references/destination-contract.md +94 -0
  28. package/assets/skills/explainer-kit/references/extension-contract.md +71 -0
  29. package/assets/skills/explainer-kit/references/fact-base-contract.md +119 -0
  30. package/assets/skills/explainer-kit/schemas/build-record.schema.json +92 -0
  31. package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +53 -0
  32. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +129 -0
  33. package/assets/skills/explainer-kit/schemas/manifest.schema.json +193 -0
  34. package/assets/skills/explainer-kit/schemas/publish-receipt.schema.json +97 -0
  35. package/assets/skills/explainer-kit/schemas/publish-request.schema.json +32 -0
  36. package/assets/skills/explainer-kit/schemas/run-request.schema.json +132 -0
  37. package/assets/skills/explainer-kit/schemas/theme.schema.json +217 -0
  38. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +180 -0
  39. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +680 -0
  40. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +538 -0
  41. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +494 -0
  42. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +218 -0
  43. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +512 -0
  44. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +289 -0
  45. package/assets/skills/explainer-kit/scripts/lib/records.mjs +231 -0
  46. package/assets/skills/explainer-kit/scripts/lib/render.mjs +485 -0
  47. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +587 -0
  48. package/assets/skills/explainer-kit/scripts/lib/safe-paths.mjs +96 -0
  49. package/assets/skills/explainer-kit/scripts/lib/theme.mjs +332 -0
  50. package/assets/skills/explainer-kit/scripts/publish.mjs +76 -0
  51. package/assets/skills/explainer-kit/scripts/record-durability.mjs +64 -0
  52. package/assets/skills/explainer-kit/scripts/render-qa.mjs +273 -0
  53. package/assets/skills/explainer-kit/scripts/run.mjs +778 -0
  54. package/assets/skills/explainer-kit/scripts/validate.mjs +59 -0
  55. package/assets/skills/explainer-kit/templates/deck-shell.html +233 -0
  56. package/assets/skills/explainer-kit/templates/diagram-shell.html +219 -0
  57. package/assets/skills/explainer-kit/templates/engineer-tour.html +273 -0
  58. package/assets/skills/explainer-kit/templates/house-style.html +218 -0
  59. package/assets/skills/oat-explainer-kit/SKILL.md +92 -0
  60. package/assets/skills/oat-explainer-kit/references/config-contract.md +67 -0
  61. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +143 -0
  62. package/assets/skills/oat-explainer-kit/references/migration.md +143 -0
  63. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +103 -0
  64. package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +127 -0
  65. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +392 -0
  66. package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +130 -0
  67. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +293 -0
  68. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +209 -0
  69. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +166 -0
  70. package/assets/skills/oat-explainer-kit/scripts/run.mjs +284 -0
  71. package/assets/skills/oat-project-autonomous/SKILL.md +22 -1
  72. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +5 -1
  73. package/assets/skills/oat-project-complete/SKILL.md +166 -28
  74. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +5 -1
  75. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  76. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +17 -0
  77. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +5 -1
  78. package/assets/skills/oat-project-plan/SKILL.md +30 -1
  79. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +5 -1
  80. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +5 -1
  81. package/assets/skills/oat-project-summary/SKILL.md +20 -1
  82. package/dist/commands/config/index.d.ts.map +1 -1
  83. package/dist/commands/config/index.js +232 -1
  84. package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
  85. package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
  86. package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
  87. package/dist/commands/project/archive/archive-utils.d.ts +12 -0
  88. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  89. package/dist/commands/project/archive/archive-utils.js +320 -8
  90. package/dist/commands/project/archive/index.d.ts.map +1 -1
  91. package/dist/commands/project/archive/index.js +1 -0
  92. package/dist/commands/project/archive/push-runner.d.ts +1 -0
  93. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  94. package/dist/commands/project/archive/push-runner.js +9 -2
  95. package/dist/config/oat-config.d.ts +26 -0
  96. package/dist/config/oat-config.d.ts.map +1 -1
  97. package/dist/config/oat-config.js +88 -0
  98. package/dist/config/resolve.d.ts.map +1 -1
  99. package/dist/config/resolve.js +18 -0
  100. package/dist/validation/project-state.d.ts +3 -0
  101. package/dist/validation/project-state.d.ts.map +1 -1
  102. package/dist/validation/project-state.js +91 -0
  103. package/package.json +2 -2
@@ -0,0 +1,392 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { relative, resolve, sep } from 'node:path';
3
+
4
+ const MODES = new Set(['dedicated', 'completion-bookkeeping']);
5
+ const ARTIFACT_COMMIT_TOKEN = '$ARTIFACT_COMMIT';
6
+ const SHA_PATTERN = /^[a-f0-9]{40}$/;
7
+
8
+ export async function planTrackedRunFinalization(request, context = {}) {
9
+ assertRequest(request);
10
+ const repoRoot = await realpathRequired(context.repoRoot, 'repoRoot');
11
+ const runRoot = await realpathRequired(request.runRoot, 'runRoot');
12
+ assertWithin(repoRoot, runRoot, 'runRoot');
13
+
14
+ const manifestPath = await realpath(request.manifestPath);
15
+ if (manifestPath !== resolve(runRoot, 'manifest.json')) {
16
+ throw new Error('manifestPath must identify manifest.json in runRoot.');
17
+ }
18
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
19
+ if (manifest.schemaVersion !== 'explainer-kit.manifest/v1') {
20
+ throw new Error(
21
+ 'Finalization requires an explainer-kit.manifest/v1 record.',
22
+ );
23
+ }
24
+
25
+ const immutablePaths = immutablePackagePaths(manifest).map((path) =>
26
+ toRepoPath(repoRoot, resolveRunPath(runRoot, path)),
27
+ );
28
+ const mutablePaths = [
29
+ toRepoPath(repoRoot, manifestPath),
30
+ toRepoPath(repoRoot, resolveRunPath(runRoot, manifest.buildRecord?.path)),
31
+ ];
32
+ const artifactCommit =
33
+ request.commitMode === 'completion-bookkeeping'
34
+ ? requiredSha(context.artifactCommit)
35
+ : context.artifactCommit
36
+ ? requiredSha(context.artifactCommit)
37
+ : ARTIFACT_COMMIT_TOKEN;
38
+ const evidenceParent = context.currentHead
39
+ ? requiredSha(context.currentHead)
40
+ : artifactCommit;
41
+
42
+ if (hasCommitEvidence(manifest, artifactCommit, immutablePaths)) {
43
+ return {
44
+ schemaVersion: 'oat-explainer-kit.finalization-plan/v1',
45
+ status: 'complete',
46
+ outcome: 'built-durable',
47
+ commands: [],
48
+ push: null,
49
+ };
50
+ }
51
+
52
+ const project = nonEmpty(context.project, 'project');
53
+ const recipe = nonEmpty(manifest.recipe?.id, 'manifest recipe id');
54
+ const artifact =
55
+ request.commitMode === 'dedicated' && !context.artifactCommit
56
+ ? {
57
+ mode: 'create',
58
+ ref: ARTIFACT_COMMIT_TOKEN,
59
+ paths: immutablePaths,
60
+ commands: commitCommands(
61
+ immutablePaths,
62
+ `docs(oat): persist ${recipe} for ${project}`,
63
+ ),
64
+ }
65
+ : {
66
+ mode: 'existing',
67
+ ref: artifactCommit,
68
+ paths: immutablePaths,
69
+ commands: [],
70
+ };
71
+ const evidenceCommit = {
72
+ parent: evidenceParent,
73
+ paths: mutablePaths,
74
+ commands: commitCommands(
75
+ mutablePaths,
76
+ `docs(oat): attest ${recipe} durability for ${project}`,
77
+ ),
78
+ };
79
+ const attestation = {
80
+ coreCreatesCommits: false,
81
+ request: {
82
+ schemaVersion: 'explainer-kit.durability-evidence/v1',
83
+ manifestPath,
84
+ evidence: {
85
+ kind: 'commit',
86
+ repoRoot,
87
+ commit: artifactCommit,
88
+ paths: immutablePaths,
89
+ },
90
+ },
91
+ };
92
+ const push = {
93
+ after: ['artifactCommit', 'evidenceCommit'],
94
+ commands: [{ command: 'git', args: ['push'] }],
95
+ instruction:
96
+ 'Invoke one push only after the artifact and evidence commits exist; push both commits together.',
97
+ };
98
+
99
+ return {
100
+ schemaVersion: 'oat-explainer-kit.finalization-plan/v1',
101
+ status: 'ready',
102
+ commitMode: request.commitMode,
103
+ repoRoot,
104
+ runRoot,
105
+ manifestPath,
106
+ relocatedFrom: request.relocatedFrom,
107
+ artifactCommit: artifact,
108
+ attestation,
109
+ evidenceCommit,
110
+ push,
111
+ commands: [
112
+ {
113
+ stage: 'artifact-commit',
114
+ commands: artifact.commands,
115
+ },
116
+ {
117
+ stage: 'attestation',
118
+ action: 'call-core-recordDurability',
119
+ request: attestation.request,
120
+ },
121
+ {
122
+ stage: 'evidence-commit',
123
+ commands: evidenceCommit.commands,
124
+ },
125
+ { stage: 'push', commands: push.commands },
126
+ ],
127
+ };
128
+ }
129
+
130
+ export function verifyTrackedRunFinalization(plan, observation) {
131
+ if (plan?.status === 'complete') {
132
+ return {
133
+ ok: true,
134
+ outcome: 'built-durable',
135
+ pushAllowed: false,
136
+ errors: [],
137
+ };
138
+ }
139
+ const errors = [];
140
+ if (plan?.status !== 'ready') {
141
+ errors.push(error('invalid-plan', 'Finalization plan is not ready.'));
142
+ return failed(errors);
143
+ }
144
+
145
+ const artifact = observation?.artifactCommit;
146
+ if (!SHA_PATTERN.test(artifact?.sha ?? '')) {
147
+ errors.push(error('artifact-commit', 'Artifact commit SHA is missing.'));
148
+ }
149
+ if (
150
+ plan.artifactCommit.mode === 'existing' &&
151
+ artifact?.sha !== plan.artifactCommit.ref
152
+ ) {
153
+ errors.push(
154
+ error('artifact-commit', 'Observed artifact commit does not match plan.'),
155
+ );
156
+ }
157
+ comparePaths({
158
+ expected: plan.artifactCommit.paths,
159
+ actual: artifact?.paths,
160
+ exact: plan.artifactCommit.mode === 'create',
161
+ label: 'artifact commit',
162
+ errors,
163
+ });
164
+ for (const path of plan.attestation.request.evidence.paths) {
165
+ if (isMutableRecord(path)) {
166
+ errors.push(
167
+ error(
168
+ 'mutable-record',
169
+ `Commit evidence includes mutable record ${path}.`,
170
+ ),
171
+ );
172
+ }
173
+ }
174
+
175
+ const attestation = observation?.attestation;
176
+ const outcome = attestation?.outcome;
177
+ if (
178
+ !attestation ||
179
+ typeof attestation !== 'object' ||
180
+ typeof attestation.durable !== 'boolean' ||
181
+ !Array.isArray(attestation.errors) ||
182
+ !(
183
+ (attestation.durable === true && outcome === 'built-durable') ||
184
+ (attestation.durable === false && outcome === 'built-not-durable')
185
+ )
186
+ ) {
187
+ errors.push(
188
+ error(
189
+ 'attestation-outcome',
190
+ 'Core attestation must include boolean durability, the corresponding exact terminal outcome, and an errors array.',
191
+ ),
192
+ );
193
+ }
194
+
195
+ const evidence = observation?.evidenceCommit;
196
+ if (!SHA_PATTERN.test(evidence?.sha ?? '')) {
197
+ errors.push(error('evidence-commit', 'Evidence commit SHA is missing.'));
198
+ }
199
+ const expectedEvidenceParent =
200
+ plan.evidenceCommit.parent === ARTIFACT_COMMIT_TOKEN
201
+ ? artifact?.sha
202
+ : plan.evidenceCommit.parent;
203
+ if (evidence?.parent !== expectedEvidenceParent) {
204
+ errors.push(
205
+ error(
206
+ 'commit-order',
207
+ 'Evidence commit must immediately follow the artifact commit.',
208
+ ),
209
+ );
210
+ }
211
+ comparePaths({
212
+ expected: plan.evidenceCommit.paths,
213
+ actual: evidence?.paths,
214
+ exact: true,
215
+ label: 'evidence commit',
216
+ errors,
217
+ });
218
+
219
+ if (
220
+ !sameSet(
221
+ observation?.unrelatedChangesBefore ?? [],
222
+ observation?.unrelatedChangesAfter ?? [],
223
+ )
224
+ ) {
225
+ errors.push(
226
+ error(
227
+ 'unrelated-change',
228
+ 'Finalization changed or consumed an unrelated working-tree change.',
229
+ ),
230
+ );
231
+ }
232
+
233
+ return errors.length === 0
234
+ ? { ok: true, outcome, pushAllowed: true, errors: [] }
235
+ : failed(errors);
236
+ }
237
+
238
+ function commitCommands(paths, subject) {
239
+ return [
240
+ { command: 'git', args: ['add', '--', ...paths] },
241
+ {
242
+ command: 'git',
243
+ args: ['commit', '--only', '-m', subject, '--', ...paths],
244
+ },
245
+ ];
246
+ }
247
+
248
+ function immutablePackagePaths(manifest) {
249
+ const paths = [
250
+ manifest.source?.factBasePath,
251
+ 'source/fact-base.md',
252
+ ...(manifest.artifacts ?? []).map(({ contentPath }) => contentPath),
253
+ manifest.theme?.path,
254
+ ...(manifest.artifacts ?? [])
255
+ .filter(
256
+ ({ status, renderedPath }) =>
257
+ status === 'built' && typeof renderedPath === 'string',
258
+ )
259
+ .map(({ renderedPath }) => renderedPath),
260
+ ];
261
+ if (paths.some((path) => typeof path !== 'string' || path.length === 0)) {
262
+ throw new Error('Manifest does not identify a complete immutable package.');
263
+ }
264
+ return [...new Set(paths)];
265
+ }
266
+
267
+ function resolveRunPath(runRoot, path) {
268
+ const target = resolve(runRoot, path);
269
+ assertWithin(runRoot, target, `Manifest path ${path}`);
270
+ return target;
271
+ }
272
+
273
+ function hasCommitEvidence(manifest, commit, paths) {
274
+ if (manifest.outcome !== 'built-durable' || !SHA_PATTERN.test(commit)) {
275
+ return false;
276
+ }
277
+ return (manifest.artifacts ?? [])
278
+ .filter(
279
+ ({ status, rebuildable }) => status === 'built' && rebuildable !== true,
280
+ )
281
+ .every(({ durableEvidence = [] }) =>
282
+ durableEvidence.some(
283
+ (evidence) =>
284
+ evidence.kind === 'commit' &&
285
+ evidence.ref === commit &&
286
+ arraysEqual(evidence.paths, paths),
287
+ ),
288
+ );
289
+ }
290
+
291
+ function comparePaths({ expected, actual, exact, label, errors }) {
292
+ if (!Array.isArray(actual)) {
293
+ errors.push(error('commit-paths', `Observed ${label} paths are missing.`));
294
+ return;
295
+ }
296
+ const missing = expected.filter((path) => !actual.includes(path));
297
+ const extra = exact ? actual.filter((path) => !expected.includes(path)) : [];
298
+ if (missing.length > 0 || extra.length > 0) {
299
+ errors.push(
300
+ error(
301
+ 'unrelated-change',
302
+ `${label} paths differ from the plan (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'}).`,
303
+ ),
304
+ );
305
+ }
306
+ }
307
+
308
+ function assertRequest(request) {
309
+ if (!request || typeof request !== 'object') {
310
+ throw new TypeError('Finalization request is required.');
311
+ }
312
+ if (!MODES.has(request.commitMode)) {
313
+ throw new Error(
314
+ `Unsupported commitMode: ${request.commitMode ?? 'missing'}.`,
315
+ );
316
+ }
317
+ nonEmpty(request.runRoot, 'runRoot');
318
+ nonEmpty(request.manifestPath, 'manifestPath');
319
+ if (
320
+ request.relocatedFrom !== undefined &&
321
+ typeof request.relocatedFrom !== 'string'
322
+ ) {
323
+ throw new TypeError('relocatedFrom must be a string when supplied.');
324
+ }
325
+ }
326
+
327
+ async function realpathRequired(value, label) {
328
+ return realpath(nonEmpty(value, label));
329
+ }
330
+
331
+ function nonEmpty(value, label) {
332
+ if (typeof value !== 'string' || value.trim().length === 0) {
333
+ throw new TypeError(`${label} is required.`);
334
+ }
335
+ return value;
336
+ }
337
+
338
+ function requiredSha(value) {
339
+ if (!SHA_PATTERN.test(value ?? '')) {
340
+ throw new TypeError(
341
+ 'completion-bookkeeping requires a full artifactCommit SHA.',
342
+ );
343
+ }
344
+ return value;
345
+ }
346
+
347
+ function assertWithin(root, target, label) {
348
+ const path = relative(root, target);
349
+ if (path === '..' || path.startsWith(`..${sep}`)) {
350
+ throw new Error(`${label} escapes the repository or run root.`);
351
+ }
352
+ }
353
+
354
+ function toRepoPath(repoRoot, absolutePath) {
355
+ assertWithin(repoRoot, absolutePath, 'Finalization path');
356
+ return relative(repoRoot, absolutePath).split(sep).join('/');
357
+ }
358
+
359
+ function isMutableRecord(path) {
360
+ return (
361
+ path === 'manifest.json' ||
362
+ path === 'build-record.json' ||
363
+ path.endsWith('/manifest.json') ||
364
+ path.endsWith('/build-record.json')
365
+ );
366
+ }
367
+
368
+ function arraysEqual(left, right) {
369
+ return (
370
+ left.length === right.length &&
371
+ left.every((value, index) => value === right[index])
372
+ );
373
+ }
374
+
375
+ function sameSet(left, right) {
376
+ return (
377
+ left.length === right.length && left.every((value) => right.includes(value))
378
+ );
379
+ }
380
+
381
+ function error(code, message) {
382
+ return { code, message };
383
+ }
384
+
385
+ function failed(errors) {
386
+ return {
387
+ ok: false,
388
+ outcome: 'built-not-durable',
389
+ pushAllowed: false,
390
+ errors,
391
+ };
392
+ }
@@ -0,0 +1,130 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ import { validateIntentRecord } from './resolve-intent.mjs';
6
+
7
+ const FRONTMATTER_KEYS = Object.freeze({
8
+ projectExplainer: 'oat_project_explainer',
9
+ projectRecap: 'oat_project_recap',
10
+ });
11
+
12
+ export function hashStateContent(content) {
13
+ if (typeof content !== 'string') {
14
+ throw new TypeError('State content must be a string.');
15
+ }
16
+ return createHash('sha256').update(content, 'utf8').digest('hex');
17
+ }
18
+
19
+ export function updateStateFrontmatter(content, product, record) {
20
+ validateIntentRecord(product, record);
21
+ if (typeof content !== 'string') {
22
+ throw new TypeError('State content must be a string.');
23
+ }
24
+
25
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
26
+ const lines = content.split(/\r?\n/);
27
+ if (lines[0] !== '---') {
28
+ throw new Error('Project state must begin with YAML frontmatter.');
29
+ }
30
+ const closingIndex = lines.indexOf('---', 1);
31
+ if (closingIndex < 0) {
32
+ throw new Error('Project state has no closing frontmatter delimiter.');
33
+ }
34
+
35
+ const key = FRONTMATTER_KEYS[product];
36
+ const keyPattern = new RegExp(`^${key}\\s*:`);
37
+ const matches = [];
38
+ for (let index = 1; index < closingIndex; index += 1) {
39
+ if (keyPattern.test(lines[index])) matches.push(index);
40
+ }
41
+ if (matches.length > 1) {
42
+ throw new Error(`Project state contains duplicate ${key} fields.`);
43
+ }
44
+
45
+ const replacement = [
46
+ `${key}:`,
47
+ ` decision: ${record.decision}`,
48
+ ` source: ${record.source}`,
49
+ ` decided_at: '${record.decided_at}'`,
50
+ ];
51
+ if (matches.length === 0) {
52
+ lines.splice(closingIndex, 0, ...replacement);
53
+ } else {
54
+ const start = matches[0];
55
+ let end = start + 1;
56
+ while (
57
+ end < closingIndex &&
58
+ (lines[end].startsWith(' ') ||
59
+ lines[end].startsWith('\t') ||
60
+ lines[end].trim() === '')
61
+ ) {
62
+ end += 1;
63
+ }
64
+ lines.splice(start, end - start, ...replacement);
65
+ }
66
+ return lines.join(newline);
67
+ }
68
+
69
+ export async function persistIntent({
70
+ statePath,
71
+ product,
72
+ record,
73
+ expectedHash,
74
+ }) {
75
+ if (typeof statePath !== 'string' || !statePath) {
76
+ throw new TypeError('statePath is required.');
77
+ }
78
+ if (basename(statePath) !== 'state.md') {
79
+ throw new Error('Lifecycle intent may only be persisted to state.md.');
80
+ }
81
+ if (
82
+ typeof expectedHash !== 'string' ||
83
+ !/^[a-f0-9]{64}$/.test(expectedHash)
84
+ ) {
85
+ throw new TypeError('expectedHash must be a SHA-256 state content hash.');
86
+ }
87
+ validateIntentRecord(product, record);
88
+
89
+ const fileInfo = await lstat(statePath);
90
+ if (fileInfo.isSymbolicLink() || !fileInfo.isFile()) {
91
+ throw new Error('Project state must be a regular file, not a symlink.');
92
+ }
93
+ const current = await readFile(statePath, 'utf8');
94
+ assertExpectedHash(current, expectedHash);
95
+ const updated = updateStateFrontmatter(current, product, record);
96
+ const tempPath = join(
97
+ dirname(statePath),
98
+ `.${basename(statePath)}.${process.pid}.${randomUUID()}.tmp`,
99
+ );
100
+
101
+ try {
102
+ await writeFile(tempPath, updated, {
103
+ encoding: 'utf8',
104
+ flag: 'wx',
105
+ mode: fileInfo.mode,
106
+ });
107
+ const latest = await readFile(statePath, 'utf8');
108
+ assertExpectedHash(latest, expectedHash);
109
+ await rename(tempPath, statePath);
110
+ } finally {
111
+ await rm(tempPath, { force: true });
112
+ }
113
+
114
+ return {
115
+ statePath,
116
+ product,
117
+ record,
118
+ previousHash: expectedHash,
119
+ stateHash: hashStateContent(updated),
120
+ };
121
+ }
122
+
123
+ function assertExpectedHash(content, expectedHash) {
124
+ if (hashStateContent(content) === expectedHash) return;
125
+ const error = new Error(
126
+ 'Project state changed after intent resolution; refusing stale intent write.',
127
+ );
128
+ error.code = 'E_INTENT_STALE_WRITE';
129
+ throw error;
130
+ }