@open-agent-toolkit/cli 0.2.62 → 0.2.64

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 (145) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/config-and-local-state.md +1 -0
  3. package/assets/docs/cli-utilities/configuration.md +10 -8
  4. package/assets/docs/cli-utilities/project-log.md +16 -0
  5. package/assets/docs/cli-utilities/tool-packs.md +83 -0
  6. package/assets/docs/cli-utilities/workflow-gates.md +124 -16
  7. package/assets/docs/contributing/skills.md +42 -7
  8. package/assets/docs/provider-sync/commands.md +3 -0
  9. package/assets/docs/provider-sync/instruction-sync.md +107 -0
  10. package/assets/docs/provider-sync/manifest-and-drift.md +105 -0
  11. package/assets/docs/reference/cli-reference.md +16 -6
  12. package/assets/docs/reference/oat-directory-structure.md +32 -31
  13. package/assets/docs/workflows/projects/lifecycle.md +45 -0
  14. package/assets/docs/workflows/projects/picking-up-projects.md +14 -0
  15. package/assets/docs/workflows/skills/explainer-kit.md +10 -4
  16. package/assets/docs/workflows/skills/repo-improve.md +15 -1
  17. package/assets/public-package-versions.json +4 -4
  18. package/assets/skills/create-agnostic-skill/SKILL.md +9 -7
  19. package/assets/skills/create-agnostic-skill/references/skill-template.md +9 -7
  20. package/assets/skills/oat-explainer-kit/SKILL.md +14 -1
  21. package/assets/skills/oat-explainer-kit/references/config-contract.md +12 -0
  22. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +57 -10
  23. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +28 -5
  24. package/assets/skills/oat-explainer-kit/scripts/probe-recap-seams.mjs +371 -0
  25. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +123 -1
  26. package/assets/skills/oat-project-autonomous/SKILL.md +10 -1
  27. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +122 -121
  28. package/assets/skills/oat-project-complete/SKILL.md +134 -4
  29. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +122 -121
  30. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  31. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +38 -6
  32. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +122 -121
  33. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +122 -121
  34. package/assets/skills/oat-project-next/SKILL.md +62 -19
  35. package/assets/skills/oat-project-plan/SKILL.md +31 -5
  36. package/assets/skills/oat-project-pr-final/SKILL.md +281 -6
  37. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +122 -121
  38. package/assets/skills/oat-project-progress/SKILL.md +12 -3
  39. package/assets/skills/oat-project-quick-start/SKILL.md +264 -4
  40. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +122 -121
  41. package/assets/skills/oat-project-summary/SKILL.md +34 -23
  42. package/assets/skills/oat-repo-improve/SKILL.md +40 -5
  43. package/assets/skills/oat-repo-improve/references/plan-template.md +84 -7
  44. package/dist/agents/canonical/resolve.d.ts.map +1 -1
  45. package/dist/agents/canonical/resolve.js +25 -4
  46. package/dist/commands/config/index.d.ts +1 -0
  47. package/dist/commands/config/index.d.ts.map +1 -1
  48. package/dist/commands/config/index.js +401 -18
  49. package/dist/commands/doctor/index.d.ts +7 -0
  50. package/dist/commands/doctor/index.d.ts.map +1 -1
  51. package/dist/commands/doctor/index.js +10 -2
  52. package/dist/commands/gate/index.d.ts +3 -1
  53. package/dist/commands/gate/index.d.ts.map +1 -1
  54. package/dist/commands/gate/index.js +699 -211
  55. package/dist/commands/init/tools/index.d.ts.map +1 -1
  56. package/dist/commands/init/tools/index.js +68 -36
  57. package/dist/commands/init/tools/shared/skill-script-references.d.ts +126 -0
  58. package/dist/commands/init/tools/shared/skill-script-references.d.ts.map +1 -0
  59. package/dist/commands/init/tools/shared/skill-script-references.js +311 -0
  60. package/dist/commands/instructions/instructions.types.d.ts +63 -0
  61. package/dist/commands/instructions/instructions.types.d.ts.map +1 -1
  62. package/dist/commands/instructions/instructions.utils.d.ts +51 -2
  63. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  64. package/dist/commands/instructions/instructions.utils.js +201 -4
  65. package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
  66. package/dist/commands/instructions/sync/sync.js +12 -1
  67. package/dist/commands/instructions/validate/validate.d.ts.map +1 -1
  68. package/dist/commands/instructions/validate/validate.js +12 -1
  69. package/dist/commands/project/log/append.d.ts +195 -0
  70. package/dist/commands/project/log/append.d.ts.map +1 -1
  71. package/dist/commands/project/log/append.js +1034 -11
  72. package/dist/commands/shared/frontmatter.d.ts +51 -0
  73. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  74. package/dist/commands/shared/frontmatter.js +118 -6
  75. package/dist/commands/status/index.d.ts.map +1 -1
  76. package/dist/commands/status/index.js +5 -3
  77. package/dist/commands/sync/index.d.ts +14 -0
  78. package/dist/commands/sync/index.d.ts.map +1 -1
  79. package/dist/commands/sync/index.js +21 -0
  80. package/dist/commands/tools/info/index.d.ts.map +1 -1
  81. package/dist/commands/tools/info/index.js +14 -1
  82. package/dist/commands/tools/info/info-tool.d.ts +12 -0
  83. package/dist/commands/tools/info/info-tool.d.ts.map +1 -1
  84. package/dist/commands/tools/info/info-tool.js +54 -6
  85. package/dist/commands/tools/info/skill-views.d.ts +86 -0
  86. package/dist/commands/tools/info/skill-views.d.ts.map +1 -0
  87. package/dist/commands/tools/info/skill-views.js +383 -0
  88. package/dist/commands/tools/install/index.d.ts.map +1 -1
  89. package/dist/commands/tools/install/index.js +3 -25
  90. package/dist/commands/tools/list/list-tools.d.ts +2 -0
  91. package/dist/commands/tools/list/list-tools.d.ts.map +1 -1
  92. package/dist/commands/tools/list/list-tools.js +23 -4
  93. package/dist/commands/tools/remove/index.d.ts.map +1 -1
  94. package/dist/commands/tools/remove/index.js +35 -42
  95. package/dist/commands/tools/remove/remove-tools.d.ts +3 -2
  96. package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
  97. package/dist/commands/tools/remove/remove-tools.js +14 -4
  98. package/dist/commands/tools/shared/auto-sync.d.ts +12 -1
  99. package/dist/commands/tools/shared/auto-sync.d.ts.map +1 -1
  100. package/dist/commands/tools/shared/auto-sync.js +20 -4
  101. package/dist/commands/tools/shared/format-pack-inventory.d.ts +12 -1
  102. package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
  103. package/dist/commands/tools/shared/format-pack-inventory.js +48 -6
  104. package/dist/commands/tools/shared/in-process-sync.d.ts +11 -0
  105. package/dist/commands/tools/shared/in-process-sync.d.ts.map +1 -0
  106. package/dist/commands/tools/shared/in-process-sync.js +79 -0
  107. package/dist/commands/tools/shared/pack-evidence.d.ts +78 -2
  108. package/dist/commands/tools/shared/pack-evidence.d.ts.map +1 -1
  109. package/dist/commands/tools/shared/pack-evidence.js +96 -2
  110. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts +11 -0
  111. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts.map +1 -1
  112. package/dist/commands/tools/shared/pack-lifecycle-outcome.js +24 -3
  113. package/dist/commands/tools/shared/pack-provider-evidence.d.ts +73 -0
  114. package/dist/commands/tools/shared/pack-provider-evidence.d.ts.map +1 -0
  115. package/dist/commands/tools/shared/pack-provider-evidence.js +161 -0
  116. package/dist/commands/tools/shared/provider-context.d.ts +88 -0
  117. package/dist/commands/tools/shared/provider-context.d.ts.map +1 -0
  118. package/dist/commands/tools/shared/provider-context.js +77 -0
  119. package/dist/commands/tools/shared/provider-reachability.d.ts +93 -0
  120. package/dist/commands/tools/shared/provider-reachability.d.ts.map +1 -0
  121. package/dist/commands/tools/shared/provider-reachability.js +358 -0
  122. package/dist/commands/tools/shared/sync-evidence.d.ts +57 -0
  123. package/dist/commands/tools/shared/sync-evidence.d.ts.map +1 -0
  124. package/dist/commands/tools/shared/sync-evidence.js +147 -0
  125. package/dist/commands/tools/update/index.d.ts +0 -4
  126. package/dist/commands/tools/update/index.d.ts.map +1 -1
  127. package/dist/commands/tools/update/index.js +22 -40
  128. package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
  129. package/dist/commands/tools/update/update-tools.js +23 -8
  130. package/dist/config/json.d.ts +36 -0
  131. package/dist/config/json.d.ts.map +1 -1
  132. package/dist/config/json.js +135 -3
  133. package/dist/config/oat-config.d.ts +41 -0
  134. package/dist/config/oat-config.d.ts.map +1 -1
  135. package/dist/config/oat-config.js +111 -0
  136. package/dist/drift/index.d.ts +2 -0
  137. package/dist/drift/index.d.ts.map +1 -1
  138. package/dist/drift/index.js +1 -0
  139. package/dist/drift/skill-view-diagnostic.d.ts +197 -0
  140. package/dist/drift/skill-view-diagnostic.d.ts.map +1 -0
  141. package/dist/drift/skill-view-diagnostic.js +343 -0
  142. package/dist/validation/skills.d.ts +1 -0
  143. package/dist/validation/skills.d.ts.map +1 -1
  144. package/dist/validation/skills.js +181 -19
  145. package/package.json +2 -2
@@ -1,6 +1,9 @@
1
- import { appendFile, readFile, writeFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { basename, isAbsolute, join, resolve } from 'node:path';
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { closeSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, writeSync, } from 'node:fs';
4
+ import { appendFile, readFile, realpath, rm, writeFile, } from 'node:fs/promises';
5
+ import { homedir, hostname, tmpdir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
4
7
  import { buildCommandContext, } from '../../../app/command-context.js';
5
8
  import { readGlobalOptions } from '../../shared/shared.utils.js';
6
9
  import { resolveActiveProject, } from '../../../config/oat-config.js';
@@ -10,13 +13,800 @@ import { dirExists, fileExists } from '../../../fs/io.js';
10
13
  import { resolveProjectRoot } from '../../../fs/paths.js';
11
14
  import { Command } from 'commander';
12
15
  import { composeJudgmentHeading, composeStructuralHeading, isProjectLogEntryMarker, isProjectLogSectionMarker, PROJECT_LOG_AREA_MAX_LENGTH, PROJECT_LOG_HEADING_DELIMITER, PROJECT_LOG_SCOPES, PROJECT_LOG_TYPES, } from './grammar.js';
13
- const PROJECT_LOG_FILENAME = 'project-log.md';
16
+ export const PROJECT_LOG_FILENAME = 'project-log.md';
14
17
  const SYNTHESIS_HEADING_PREFIX = '\n## End-of-run synthesis';
18
+ /**
19
+ * Directory, relative to a project, that holds gate partial-finalization
20
+ * receipts. Ignored by the repository so a receipt never dirties the tree.
21
+ */
22
+ export const GATE_RECEIPTS_DIRNAME = 'gate-receipts';
23
+ /**
24
+ * Bounded attempt budget for committing the project log across a transient
25
+ * `.git/index.lock`. Three attempts means exactly two waits, so the delays
26
+ * below are the whole window the persistent-versus-transient mtime comparison
27
+ * spans. Declared here rather than inline so the bound is reviewable and the
28
+ * gate and the recovery entry point share one budget.
29
+ */
30
+ export const PROJECT_LOG_COMMIT_ATTEMPTS = 3;
31
+ export const PROJECT_LOG_COMMIT_RETRY_DELAYS_MS = [250, 500];
32
+ const COMMIT_MESSAGE = 'chore(oat): record project log entry';
33
+ /**
34
+ * Directory, under the OS temp root, that holds the advisory locks serializing
35
+ * project-log mutations.
36
+ *
37
+ * The lock is deliberately *not* stored beside the log. A tracked project
38
+ * directory would gain an untracked file for the duration of every append, and
39
+ * a crashed writer would leave one behind for review to trip over. A lock must
40
+ * also not outlive the machine that took it, which is exactly what a temp root
41
+ * gives: the receipt needed durability across processes and therefore rejected
42
+ * `tmpdir()` (DR-260907), while a lock needs the opposite.
43
+ *
44
+ * This serializes writers on one machine, which is the condition the gate
45
+ * actually meets: overlapping `oat` processes in one worktree. It is advisory,
46
+ * so a writer that cannot take it still proceeds — the identity verification in
47
+ * `commitProjectLog` is the assurance that a lost entry is never reported as
48
+ * settled work.
49
+ */
50
+ const PROJECT_LOG_LOCK_DIRNAME = 'oat-project-log-locks';
51
+ /**
52
+ * Bounded wait for the advisory lock. Declared rather than inline so the bound
53
+ * is reviewable: a writer waits at most `WAIT_MS`, polling every `POLL_MS`, and
54
+ * treats a lock whose owner is gone — or that is older than `STALE_MS` — as
55
+ * abandoned.
56
+ */
57
+ export const PROJECT_LOG_LOCK_WAIT_MS = 5_000;
58
+ export const PROJECT_LOG_LOCK_POLL_MS = 25;
59
+ export const PROJECT_LOG_LOCK_STALE_MS = 30_000;
60
+ const DEFAULT_LOCK_DEPENDENCIES = {
61
+ waitMs: PROJECT_LOG_LOCK_WAIT_MS,
62
+ pollMs: PROJECT_LOG_LOCK_POLL_MS,
63
+ staleMs: PROJECT_LOG_LOCK_STALE_MS,
64
+ sleep: async (ms) => {
65
+ await new Promise((settle) => setTimeout(settle, ms));
66
+ },
67
+ now: () => Date.now(),
68
+ };
69
+ /**
70
+ * The lock file for one log, keyed by the log's resolved location so two
71
+ * processes naming the same file through different relative paths — or through
72
+ * a symlinked temp root — contend on the same lock.
73
+ */
74
+ /**
75
+ * The real, symlink-free location of `target`, falling back to the plain
76
+ * resolution when the path cannot be canonicalized.
77
+ *
78
+ * This matters everywhere two spellings of one path must compare equal: a
79
+ * macOS temp root is `/var/...` to the caller and `/private/var/...` to git, so
80
+ * a raw `relative()` between the two reads as "outside the repository".
81
+ */
82
+ function canonicalPath(target) {
83
+ try {
84
+ return realpathSync(target);
85
+ }
86
+ catch {
87
+ return resolve(target);
88
+ }
89
+ }
90
+ export function projectLogLockPath(logPath) {
91
+ const resolvedDirectory = canonicalPath(dirname(resolve(logPath)));
92
+ const digest = createHash('sha256')
93
+ .update(join(resolvedDirectory, basename(logPath)))
94
+ .digest('hex');
95
+ return join(tmpdir(), PROJECT_LOG_LOCK_DIRNAME, `${digest}.lock`);
96
+ }
97
+ function isProcessAlive(pid) {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ }
102
+ catch (error) {
103
+ // EPERM means the process exists but belongs to another user, which is
104
+ // still a live owner; only ESRCH proves it is gone.
105
+ return (error != null &&
106
+ typeof error === 'object' &&
107
+ error.code === 'EPERM');
108
+ }
109
+ }
110
+ function readLockHolder(lockPath) {
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
113
+ return parsed != null && typeof parsed === 'object'
114
+ ? parsed
115
+ : undefined;
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
121
+ /**
122
+ * Creates the lock file exclusively and confirms the file still names us.
123
+ *
124
+ * `wx` is the primitive: the create either wins or fails with `EEXIST`, in this
125
+ * process exactly as in any other. The confirming read is what makes a stale
126
+ * takeover safe. POSIX has no compare-and-unlink, so a contender that judged a
127
+ * lock abandoned can still remove a *live* lock created in the gap between its
128
+ * decision and its unlink. When that happens the loser's token is no longer in
129
+ * the file, so it reports a failed acquire and retries instead of entering the
130
+ * critical section alongside the winner. Exactly one writer sees its own token.
131
+ */
132
+ function tryCreateProjectLogLock(lockPath, token) {
133
+ let descriptor;
134
+ try {
135
+ descriptor = openSync(lockPath, 'wx');
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ try {
141
+ writeSync(descriptor, `${JSON.stringify({
142
+ token,
143
+ pid: process.pid,
144
+ host: hostname(),
145
+ acquiredAt: new Date().toISOString(),
146
+ })}\n`);
147
+ }
148
+ catch {
149
+ // A lock we cannot identify ourselves in is a lock we must not claim.
150
+ try {
151
+ closeSync(descriptor);
152
+ }
153
+ catch {
154
+ // Nothing to recover.
155
+ }
156
+ return false;
157
+ }
158
+ try {
159
+ closeSync(descriptor);
160
+ }
161
+ catch {
162
+ // Nothing to recover: the exclusive create already succeeded.
163
+ }
164
+ return readLockHolder(lockPath)?.token === token;
165
+ }
166
+ /**
167
+ * Takes the single-winner right to reclaim an abandoned lock.
168
+ *
169
+ * Reclamation is the one operation that removes a file this process did not
170
+ * create, so it is itself serialized. With the slot held there is no second
171
+ * reclaimer, and an ordinary acquirer cannot slip in either: its `wx` create
172
+ * fails for as long as the abandoned lock is still on disk. The lock the
173
+ * reclaimer unlinks is therefore provably the lock it inspected.
174
+ *
175
+ * A slot whose owner died mid-reclamation is itself reclaimed once it is older
176
+ * than `staleMs`. That fallback is the only place two reclaimers can meet, and
177
+ * it costs a 30-second stall rather than a lost entry.
178
+ */
179
+ function acquireReclaimSlot(reclaimPath, staleMs, nowMs) {
180
+ if (tryCreateReclaimSlot(reclaimPath)) {
181
+ return true;
182
+ }
183
+ const mtime = lockMtimeMs(reclaimPath);
184
+ if (mtime === undefined) {
185
+ return tryCreateReclaimSlot(reclaimPath);
186
+ }
187
+ if (nowMs - mtime < staleMs) {
188
+ return false;
189
+ }
190
+ try {
191
+ unlinkSync(reclaimPath);
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ return tryCreateReclaimSlot(reclaimPath);
197
+ }
198
+ function tryCreateReclaimSlot(reclaimPath) {
199
+ try {
200
+ closeSync(openSync(reclaimPath, 'wx'));
201
+ return true;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ /**
208
+ * Whether a lock's owner can be shown to be gone.
209
+ *
210
+ * A same-host lock whose recorded pid no longer exists was abandoned by a
211
+ * crashed writer. Anything else — an unreadable body, or a lock recorded by
212
+ * another host — counts as abandoned only once it is older than `staleMs`, so
213
+ * a live owner is never evicted on a guess.
214
+ */
215
+ function lockIsAbandoned(holder, mtimeMs, staleMs, nowMs) {
216
+ if (holder?.host === hostname() && typeof holder.pid === 'number') {
217
+ return !isProcessAlive(holder.pid);
218
+ }
219
+ return nowMs - mtimeMs >= staleMs;
220
+ }
221
+ /**
222
+ * Removes a lock whose owner can be shown to be gone.
223
+ *
224
+ * This only ever removes *this module's own* advisory lock. It never touches
225
+ * `.git/index.lock`, which belongs to git and to whichever process took it.
226
+ *
227
+ * A same-host lock whose recorded pid no longer exists is abandoned by a
228
+ * crashed writer and is cleared immediately; anything else — an unreadable
229
+ * body, or a lock recorded by another host — is cleared only once it is older
230
+ * than `staleMs`, so a live owner is never evicted on a guess.
231
+ */
232
+ function clearStaleProjectLogLock(lockPath, staleMs, nowMs) {
233
+ const mtime = lockMtimeMs(lockPath);
234
+ if (mtime === undefined) {
235
+ // Already gone: the next exclusive create is the retry.
236
+ return true;
237
+ }
238
+ if (!lockIsAbandoned(readLockHolder(lockPath), mtime, staleMs, nowMs)) {
239
+ return false;
240
+ }
241
+ const reclaimPath = `${lockPath}.reclaim`;
242
+ if (!acquireReclaimSlot(reclaimPath, staleMs, nowMs)) {
243
+ return false;
244
+ }
245
+ try {
246
+ // Re-decided under the slot, against the file as it is now. Nothing can
247
+ // have replaced it: a competing reclaimer would need this slot, and an
248
+ // ordinary acquirer cannot create a lock that still exists.
249
+ const currentMtime = lockMtimeMs(lockPath);
250
+ if (currentMtime === undefined) {
251
+ return true;
252
+ }
253
+ if (!lockIsAbandoned(readLockHolder(lockPath), currentMtime, staleMs, nowMs)) {
254
+ return false;
255
+ }
256
+ unlinkSync(lockPath);
257
+ return true;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ finally {
263
+ try {
264
+ unlinkSync(reclaimPath);
265
+ }
266
+ catch {
267
+ // A slot we cannot remove is reclaimed by the staleness fallback above.
268
+ }
269
+ }
270
+ }
271
+ /**
272
+ * Takes the advisory lock for `logPath`, waiting a bounded time.
273
+ *
274
+ * Returns a handle whose `held` is false when the wait elapsed. What that means
275
+ * is the caller's to decide: see `withProjectLogLock`, where a file-rewriting
276
+ * mutation refuses and a commit proceeds.
277
+ */
278
+ async function acquireProjectLogLock(logPath, overrides = {}) {
279
+ const dependencies = { ...DEFAULT_LOCK_DEPENDENCIES, ...overrides };
280
+ const lockPath = projectLogLockPath(logPath);
281
+ const token = randomUUID();
282
+ try {
283
+ mkdirSync(dirname(lockPath), { recursive: true });
284
+ }
285
+ catch {
286
+ return { path: lockPath, token, held: false };
287
+ }
288
+ const deadline = dependencies.now() + dependencies.waitMs;
289
+ for (;;) {
290
+ if (tryCreateProjectLogLock(lockPath, token)) {
291
+ return { path: lockPath, token, held: true };
292
+ }
293
+ if (clearStaleProjectLogLock(lockPath, dependencies.staleMs, dependencies.now()) &&
294
+ tryCreateProjectLogLock(lockPath, token)) {
295
+ return { path: lockPath, token, held: true };
296
+ }
297
+ if (dependencies.now() >= deadline) {
298
+ return { path: lockPath, token, held: false };
299
+ }
300
+ await dependencies.sleep(dependencies.pollMs);
301
+ }
302
+ }
303
+ /**
304
+ * Releases a lock this process still owns. A lock that was stolen as stale now
305
+ * belongs to its new holder, so the token is checked before the unlink.
306
+ */
307
+ function releaseProjectLogLock(handle) {
308
+ if (!handle.held) {
309
+ return;
310
+ }
311
+ const holder = readLockHolder(handle.path);
312
+ if (holder !== undefined && holder.token !== handle.token) {
313
+ return;
314
+ }
315
+ try {
316
+ unlinkSync(handle.path);
317
+ }
318
+ catch {
319
+ // Already released or never created; there is nothing to report.
320
+ }
321
+ }
322
+ /**
323
+ * Runs `mutate` with the log's advisory lock held, releasing it on every path.
324
+ *
325
+ * `required` is for mutations that rewrite the file. Running one of those
326
+ * without the lock is exactly the read/modify/write race this exists to close,
327
+ * so an unavailable lock refuses rather than proceeding: a loud, retryable
328
+ * failure that the caller reports is strictly better than a silent clobber of
329
+ * another writer's entry, and the recovery command replays the append safely.
330
+ *
331
+ * `best-effort` is for work that cannot lose an entry — committing does not
332
+ * rewrite the log — where holding the lock only keeps this process from racing
333
+ * its own sibling into `.git/index.lock`.
334
+ */
335
+ async function withProjectLogLock(logPath, overrides, policy, mutate) {
336
+ const handle = await acquireProjectLogLock(logPath, overrides);
337
+ if (!handle.held && policy === 'required') {
338
+ throw new Error(`Timed out waiting for the project log lock at ${handle.path}. Another writer is holding it; retry once it finishes.`);
339
+ }
340
+ try {
341
+ return await mutate();
342
+ }
343
+ finally {
344
+ releaseProjectLogLock(handle);
345
+ }
346
+ }
347
+ const DEFAULT_COMMIT_DEPENDENCIES = {
348
+ attempts: PROJECT_LOG_COMMIT_ATTEMPTS,
349
+ retryDelaysMs: PROJECT_LOG_COMMIT_RETRY_DELAYS_MS,
350
+ sleep: async (ms) => {
351
+ await new Promise((settle) => setTimeout(settle, ms));
352
+ },
353
+ lock: {},
354
+ runGit,
355
+ };
356
+ /**
357
+ * Outcomes that mean the log entry is committed and a partial-finalization
358
+ * receipt is no longer needed.
359
+ */
360
+ const SETTLED_COMMIT_OUTCOMES = new Set([
361
+ 'committed',
362
+ 'already-committed',
363
+ 'nothing-to-commit',
364
+ ]);
365
+ /**
366
+ * Git's own index-lock contention evidence. Deliberately narrow: any stderr
367
+ * that merely mentions `index.lock` — a hook that prints advice, for instance —
368
+ * is a real failure, and treating it as contention would retry and then bury it
369
+ * behind a partial-finalization receipt.
370
+ *
371
+ * Git emits its "another git process" advice only alongside the fatal line that
372
+ * names the lock it could not create, so the advice on its own is not evidence;
373
+ * it counts only when the same output also names an index lock.
374
+ */
375
+ const INDEX_LOCK_CREATE_RE = /unable to create '[^']*index\.lock': file exists/i;
376
+ const GIT_CONTENTION_ADVICE_RE = /another git process seems to be running in this repository/i;
377
+ const INDEX_LOCK_MENTION_RE = /index\.lock/i;
378
+ /**
379
+ * Accepted residual: a hook that reproduces git's advice sentence *and* names
380
+ * an index lock in the same output is read as contention and retried three
381
+ * times. That costs one wasted retry window and a misleading `lockClass`, but
382
+ * it is not a swallowed failure — the exit status stays non-zero, the raw
383
+ * stderr is preserved in `error`, nothing is left staged, and the gate still
384
+ * emits `gate-project-log-commit-failed`. Demanding the fatal line for the
385
+ * advice branch too would reject nothing git actually emits, but it would also
386
+ * make the branch redundant with the primary regex, so the branch is kept for
387
+ * git output shapes that lead with the advice.
388
+ */
389
+ function isIndexLockContention(stderr) {
390
+ return (INDEX_LOCK_CREATE_RE.test(stderr) ||
391
+ (GIT_CONTENTION_ADVICE_RE.test(stderr) &&
392
+ INDEX_LOCK_MENTION_RE.test(stderr)));
393
+ }
394
+ function runGit(repoRoot, args) {
395
+ return execFileSync('git', args, {
396
+ cwd: repoRoot,
397
+ encoding: 'utf8',
398
+ // Capture stderr rather than inheriting it so skip and failure probes do
399
+ // not leak raw `git fatal:` lines into command output.
400
+ stdio: ['ignore', 'pipe', 'pipe'],
401
+ // Reading the committed log back is one of these calls, and a long-lived
402
+ // project log outgrows the 1 MiB default.
403
+ maxBuffer: 64 * 1024 * 1024,
404
+ }).trim();
405
+ }
406
+ function gitFailureMessage(error) {
407
+ const stderr = error && typeof error === 'object' && 'stderr' in error
408
+ ? error.stderr
409
+ : undefined;
410
+ return ((stderr != null ? stderr.toString().trim() : '') ||
411
+ (error instanceof Error ? error.message : String(error)));
412
+ }
413
+ function lockMtimeMs(lockPath) {
414
+ try {
415
+ return statSync(lockPath).mtimeMs;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ }
421
+ function resolveIndexLockPath(repoRoot) {
422
+ try {
423
+ return join(runGit(repoRoot, ['rev-parse', '--absolute-git-dir']), 'index.lock');
424
+ }
425
+ catch {
426
+ return join(repoRoot, '.git', 'index.lock');
427
+ }
428
+ }
429
+ /**
430
+ * Classifies a failed git invocation as an index-lock contention or a real
431
+ * failure.
432
+ *
433
+ * Only a stderr that actually names `index.lock` is lock contention; every
434
+ * other failure (hook, signing, identity, pathspec) stays `other` so it is
435
+ * reported rather than retried and swallowed. A lock is `persistent` only when
436
+ * its mtime is unchanged across the whole retry window, which is why the
437
+ * caller passes the mtime it observed on the first lock failure.
438
+ */
439
+ export function classifyGitLockFailure(stderr, lockPath, firstObservedMtimeMs) {
440
+ if (!isIndexLockContention(stderr)) {
441
+ return 'other';
442
+ }
443
+ const current = lockMtimeMs(lockPath);
444
+ if (current === undefined || firstObservedMtimeMs === undefined) {
445
+ return 'transient-index-lock';
446
+ }
447
+ return current === firstObservedMtimeMs
448
+ ? 'persistent-index-lock'
449
+ : 'transient-index-lock';
450
+ }
451
+ function readHead(run) {
452
+ try {
453
+ return run(['rev-parse', 'HEAD']);
454
+ }
455
+ catch {
456
+ return undefined;
457
+ }
458
+ }
459
+ /**
460
+ * True when the log on disk still carries the entry this commit is finalizing.
461
+ * An absent identity means the caller did not name an entry, so there is
462
+ * nothing to contradict.
463
+ */
464
+ function logCarriesIdentity(input) {
465
+ if (input.identity === undefined) {
466
+ return true;
467
+ }
468
+ try {
469
+ return (findProjectLogEntryByIdempotencyKey(readFileSync(input.logPath, 'utf8'), input.identity.key, input.identity.body) !== undefined);
470
+ }
471
+ catch {
472
+ // An unreadable log cannot be shown to carry the entry, and this function
473
+ // never throws: the caller reports the disposition instead.
474
+ return false;
475
+ }
476
+ }
477
+ /**
478
+ * The content of `logPath` as committed at HEAD, or undefined when HEAD does
479
+ * not have it.
480
+ *
481
+ * The working tree is deliberately not consulted. A dirty entry is precisely
482
+ * the state an exhausted commit retry leaves behind, so a reader that answers
483
+ * from the working tree cannot tell finished finalization from unfinished
484
+ * finalization — which is the whole question both callers here are asking.
485
+ */
486
+ function readCommittedProjectLog(run, logPath) {
487
+ try {
488
+ const top = canonicalPath(run(['rev-parse', '--show-toplevel']));
489
+ const relativePath = relative(top, canonicalPath(logPath))
490
+ .split(sep)
491
+ .join('/');
492
+ if (relativePath === '' || relativePath.startsWith('..')) {
493
+ return undefined;
494
+ }
495
+ return run(['show', `HEAD:${relativePath}`]);
496
+ }
497
+ catch {
498
+ return undefined;
499
+ }
500
+ }
501
+ function committedIdentityState(run, logPath, key, body) {
502
+ const committed = readCommittedProjectLog(run, logPath);
503
+ if (committed === undefined) {
504
+ return 'unknown';
505
+ }
506
+ return findProjectLogEntryByIdempotencyKey(committed, key, body) !== undefined
507
+ ? 'present'
508
+ : 'absent';
509
+ }
510
+ /**
511
+ * Whether the committed log carries `key`.
512
+ *
513
+ * Exported for callers that must distinguish finished finalization from
514
+ * unfinished finalization — the gate's receipt classifier among them — so that
515
+ * question is answered by this module rather than by a caller opening
516
+ * `project-log.md` itself (DR-260718).
517
+ */
518
+ export async function committedProjectLogIdentityState(repoRoot, logPath, key, body) {
519
+ return committedIdentityState((args) => runGit(repoRoot, args), logPath, key, body);
520
+ }
521
+ /**
522
+ * The committed-log verdict for the entry this commit names, or `unknown` when
523
+ * the caller named no entry.
524
+ */
525
+ function committedInputIdentityState(run, input) {
526
+ if (input.identity === undefined) {
527
+ return 'unknown';
528
+ }
529
+ return committedIdentityState(run, input.logPath, input.identity.key, input.identity.body);
530
+ }
531
+ /**
532
+ * True when the entry this commit names survives: present at HEAD, or — when
533
+ * HEAD cannot answer — still present in the file on disk.
534
+ *
535
+ * The working-tree reading is a fallback for `unknown`, never an override of
536
+ * `absent`: HEAD is the authority whenever it can speak.
537
+ */
538
+ function identitySurvives(run, input) {
539
+ const state = committedInputIdentityState(run, input);
540
+ if (state === 'present') {
541
+ return true;
542
+ }
543
+ if (state === 'absent') {
544
+ return false;
545
+ }
546
+ return logCarriesIdentity(input);
547
+ }
548
+ function entryMissingMessage(input) {
549
+ return `the committed project log does not carry this entry (${input.identity?.key ?? 'unknown'}); another writer rewrote it`;
550
+ }
551
+ /**
552
+ * True when another writer already committed the entry we were trying to
553
+ * commit.
554
+ *
555
+ * All three conditions are required. A clean log means the working tree matches
556
+ * HEAD, a moved HEAD means someone committed, and — when the caller named the
557
+ * entry's identity — that identity still being in the *committed* log means the
558
+ * content that landed is the content we appended, not a rewrite that dropped
559
+ * it.
560
+ */
561
+ function settledByAnotherWriter(run, snapshot, input) {
562
+ try {
563
+ if (run(['status', '--porcelain', '--', input.logPath]).length !== 0) {
564
+ return false;
565
+ }
566
+ if (readHead(run) === snapshot.head) {
567
+ return false;
568
+ }
569
+ return identitySurvives(run, input);
570
+ }
571
+ catch {
572
+ return false;
573
+ }
574
+ }
575
+ /**
576
+ * The locked half of `commitProjectLog`: snapshot, bounded retry, and the
577
+ * committed-identity verification, all with the advisory lock held so this
578
+ * process never races its own sibling into `.git/index.lock` and never reads a
579
+ * snapshot another local writer is about to invalidate.
580
+ */
581
+ async function commitLockedProjectLog(input, dependencies, run, message) {
582
+ let snapshot;
583
+ try {
584
+ snapshot = {
585
+ head: readHead(run),
586
+ dirty: run(['status', '--porcelain', '--', input.logPath]).length > 0,
587
+ };
588
+ }
589
+ catch (error) {
590
+ return {
591
+ outcome: 'failed',
592
+ committed: false,
593
+ attempts: 0,
594
+ error: gitFailureMessage(error),
595
+ };
596
+ }
597
+ if (!snapshot.dirty) {
598
+ if (!identitySurvives(run, input)) {
599
+ // A committed log that no longer carries this entry is a lost entry, not
600
+ // finished work: reporting it as settled would consume the receipt while
601
+ // the finalization never happened. The recovery command re-appends it
602
+ // idempotently and commits.
603
+ return {
604
+ outcome: 'entry-missing-after-commit',
605
+ committed: false,
606
+ attempts: 0,
607
+ error: entryMissingMessage(input),
608
+ };
609
+ }
610
+ return { outcome: 'nothing-to-commit', committed: false, attempts: 0 };
611
+ }
612
+ const lockPath = resolveIndexLockPath(input.repoRoot);
613
+ let lockObserved = false;
614
+ let firstLockMtimeMs;
615
+ let lastError = '';
616
+ let attempt = 0;
617
+ while (attempt < dependencies.attempts) {
618
+ attempt += 1;
619
+ let staged = false;
620
+ try {
621
+ run(['add', '--', input.logPath]);
622
+ staged = true;
623
+ run(['commit', '-m', message, '--', input.logPath]);
624
+ // A nominally successful commit is not a successful finalization. A
625
+ // caller that named its entry gets `committed` only when HEAD is read and
626
+ // positively carries that entry: `absent` means an overlapping writer
627
+ // clobbered the append before it was staged, and `unknown` means the
628
+ // verification could not be performed at all. Settling on unread evidence
629
+ // is what would let both writers succeed with one run id missing, so
630
+ // neither state settles — both route to the same idempotent recovery.
631
+ const verified = input.identity === undefined
632
+ ? 'present'
633
+ : committedInputIdentityState(run, input);
634
+ if (verified !== 'present') {
635
+ return {
636
+ outcome: verified === 'absent'
637
+ ? 'entry-missing-after-commit'
638
+ : 'commit-unverified',
639
+ committed: false,
640
+ attempts: attempt,
641
+ error: verified === 'absent'
642
+ ? entryMissingMessage(input)
643
+ : `the commit succeeded but the committed project log could not be read back to confirm this entry (${input.identity?.key ?? 'unknown'})`,
644
+ };
645
+ }
646
+ return { outcome: 'committed', committed: true, attempts: attempt };
647
+ }
648
+ catch (error) {
649
+ if (staged) {
650
+ try {
651
+ // A failed commit (hook, signing, identity) would otherwise leave the
652
+ // log staged, which is a worse state than the dirty tree we started
653
+ // in.
654
+ run(['reset', '--quiet', '--', input.logPath]);
655
+ }
656
+ catch {
657
+ // Best effort: the reported commit failure already tells the caller
658
+ // the log needs attention.
659
+ }
660
+ }
661
+ lastError = gitFailureMessage(error);
662
+ if (classifyGitLockFailure(lastError, lockPath) === 'other') {
663
+ // A retry can race a competing writer: once it commits our appended
664
+ // entry and releases the lock, our next `git commit` fails with
665
+ // "nothing to commit", which is settlement rather than a failure.
666
+ // Settlement demands a moved HEAD and a clean log that still carries
667
+ // this entry, so a genuine failure — where the log stays dirty — can
668
+ // never be reclassified by this branch.
669
+ if (settledByAnotherWriter(run, snapshot, input)) {
670
+ return {
671
+ outcome: 'already-committed',
672
+ committed: false,
673
+ attempts: attempt,
674
+ };
675
+ }
676
+ return {
677
+ outcome: 'failed',
678
+ committed: false,
679
+ attempts: attempt,
680
+ error: lastError,
681
+ lockClass: 'other',
682
+ };
683
+ }
684
+ if (!lockObserved) {
685
+ // Record the first observation even when the lock is already gone: an
686
+ // absent baseline means the lock changed during the window, which is
687
+ // contention rather than a stuck lock.
688
+ lockObserved = true;
689
+ firstLockMtimeMs = lockMtimeMs(lockPath);
690
+ }
691
+ if (attempt < dependencies.attempts) {
692
+ await dependencies.sleep(dependencies.retryDelaysMs[attempt - 1] ?? 0);
693
+ }
694
+ }
695
+ }
696
+ if (settledByAnotherWriter(run, snapshot, input)) {
697
+ return {
698
+ outcome: 'already-committed',
699
+ committed: false,
700
+ attempts: attempt,
701
+ };
702
+ }
703
+ return {
704
+ outcome: 'blocked-by-index-lock',
705
+ committed: false,
706
+ attempts: attempt,
707
+ error: lastError,
708
+ lockClass: classifyGitLockFailure(lastError, lockPath, firstLockMtimeMs),
709
+ };
710
+ }
711
+ /**
712
+ * Stages and commits `project-log.md`, retrying a bounded number of times when
713
+ * the failure is a transient `.git/index.lock`.
714
+ *
715
+ * The log is tracked, so an uncommitted append leaves the worktree dirty for
716
+ * whatever runs next — including a dispatched subagent whose preflight requires
717
+ * a clean tree. The commit is pathspec-scoped to the log alone so unrelated
718
+ * working-tree changes are never swept in.
719
+ *
720
+ * Scope note: this commits the whole log file, so a log that was already dirty
721
+ * before the caller ran is committed along with this entry. That is deliberate
722
+ * — leaving the earlier append uncommitted would reproduce the dirty tree this
723
+ * exists to prevent — but it does mean the commit is not always exactly one
724
+ * entry.
725
+ *
726
+ * Never throws, and never deletes, moves, or forces an index lock: a lock this
727
+ * process did not take is another process's, and only its owner may clear it.
728
+ * Git failures are reported to the caller, which degrades to a diagnostic
729
+ * rather than altering any exit status. On failure the index is restored so a
730
+ * partially staged log is not left behind.
731
+ *
732
+ * Exactly one outcome is derived from a single pre-action snapshot (entry HEAD
733
+ * plus entry dirtiness) rather than from eligibility re-sampled after the
734
+ * action, so a log another writer committed mid-retry reads as
735
+ * `already-committed` instead of a spurious failure.
736
+ */
737
+ export async function commitProjectLog(input, overrides = {}) {
738
+ const dependencies = { ...DEFAULT_COMMIT_DEPENDENCIES, ...overrides };
739
+ const run = (args) => dependencies.runGit(input.repoRoot, args);
740
+ const message = input.message ?? COMMIT_MESSAGE;
741
+ try {
742
+ run(['rev-parse', '--is-inside-work-tree']);
743
+ }
744
+ catch {
745
+ return { outcome: 'not-a-repo', committed: false, attempts: 0 };
746
+ }
747
+ return withProjectLogLock(input.logPath, dependencies.lock, 'best-effort', async () => commitLockedProjectLog(input, dependencies, run, message));
748
+ }
749
+ /**
750
+ * The whole whitespace-delimited word of `body` that carries `key` — for a gate
751
+ * finalization body that is `run=<runId>`, not the bare run id.
752
+ *
753
+ * Matching the whole word rather than the bare key is what keeps
754
+ * `run=<key>.suffix` from reading as a prior append of `<key>`, and it makes
755
+ * the recorded identity the same token a reader sees in the log.
756
+ */
757
+ function idempotencyToken(key, body) {
758
+ if (body === undefined) {
759
+ return undefined;
760
+ }
761
+ return body.split(/\s+/).find((word) => word.includes(key));
762
+ }
763
+ /**
764
+ * Returns the heading of the first existing entry whose body already carries
765
+ * this idempotency identity, or undefined when the log has no such entry.
766
+ *
767
+ * With `body` supplied the match is the exact whole-word token derived from it;
768
+ * without one — the advisory staleness check, which has only a run id — any
769
+ * word containing `key` counts. Callers that must not duplicate an entry always
770
+ * pass the body.
771
+ *
772
+ * The scan lives here, in the log module, because log reads and mutations are
773
+ * CLI-owned (DR-260718); callers such as the gate never open `project-log.md`
774
+ * themselves.
775
+ */
776
+ export function findProjectLogEntryByIdempotencyKey(content, key, body) {
777
+ const token = idempotencyToken(key, body);
778
+ const carries = (line) => line
779
+ .split(/\s+/)
780
+ .some((word) => token === undefined ? word.includes(key) : word === token);
781
+ let heading;
782
+ for (const line of content.split(/\r?\n/)) {
783
+ if (isProjectLogEntryMarker(line)) {
784
+ heading = line.trim();
785
+ continue;
786
+ }
787
+ if (isProjectLogSectionMarker(line)) {
788
+ heading = undefined;
789
+ continue;
790
+ }
791
+ if (heading !== undefined && carries(line)) {
792
+ return heading;
793
+ }
794
+ }
795
+ return undefined;
796
+ }
15
797
  const DEFAULT_APPEND_DEPENDENCIES = {
16
798
  resolveActiveProject,
17
799
  resolveEffectiveConfig,
18
800
  resolveAssetsRoot,
19
801
  now: () => new Date(),
802
+ readLog: async (path) => readFile(path, 'utf8'),
803
+ writeLog: async (path, content) => {
804
+ await writeFile(path, content, 'utf8');
805
+ },
806
+ appendLog: async (path, content) => {
807
+ await appendFile(path, content, 'utf8');
808
+ },
809
+ lock: {},
20
810
  };
21
811
  const DEFAULT_COMMAND_DEPENDENCIES = {
22
812
  ...DEFAULT_APPEND_DEPENDENCIES,
@@ -117,6 +907,28 @@ function validateEntry(input, date) {
117
907
  versionNote,
118
908
  };
119
909
  }
910
+ /**
911
+ * Validates the optional idempotency key and requires it to be present in the
912
+ * body.
913
+ *
914
+ * The dedupe scan recognizes a prior append by finding the key inside an
915
+ * existing entry body, so a key the body never carries would make every replay
916
+ * append a fresh duplicate. Rejecting that combination is what keeps
917
+ * `already-appended` a real guarantee rather than a hope.
918
+ */
919
+ function validateIdempotencyKey(value, body) {
920
+ if (value === undefined) {
921
+ return undefined;
922
+ }
923
+ const key = validateSingleLine(value, '--idempotency-key');
924
+ if (/\s/.test(key)) {
925
+ throw new Error('--idempotency-key must not contain whitespace.');
926
+ }
927
+ if (idempotencyToken(key, body) === undefined) {
928
+ throw new Error('--idempotency-key must appear in --body so a replayed append can recognize its own entry.');
929
+ }
930
+ return key;
931
+ }
120
932
  async function resolveTargetProject(input, dependencies) {
121
933
  if (input.project) {
122
934
  const projectPath = isAbsolute(input.project)
@@ -142,23 +954,44 @@ export function instantiateProjectLogTemplate(template, projectName, date) {
142
954
  .replaceAll(/\n?oat_template_name:\s*[^\n]*\n/gi, '\n');
143
955
  return instantiated.endsWith('\n') ? instantiated : `${instantiated}\n`;
144
956
  }
145
- async function appendEntry(logPath, heading, body, versionNote) {
957
+ /**
958
+ * Inserts one entry, before the end-of-run synthesis section when the log has
959
+ * one.
960
+ *
961
+ * The synthesis branch is a read/modify/write, so two overlapping writers would
962
+ * lose an entry; every caller therefore runs this with the log's advisory lock
963
+ * held. The plain branch still appends rather than rewriting, which keeps a
964
+ * writer that could not take the lock strictly additive.
965
+ */
966
+ async function appendEntry(logPath, heading, body, versionNote, dependencies) {
146
967
  const bodyWithVersion = versionNote?.trim()
147
968
  ? `${body} (observed on ${versionNote.trim()})`
148
969
  : body;
149
970
  const entry = `\n${heading}\n\n${bodyWithVersion}\n`;
150
- const content = await readFile(logPath, 'utf8');
971
+ const content = await dependencies.readLog(logPath);
151
972
  const synthesisIndex = content.indexOf(SYNTHESIS_HEADING_PREFIX);
152
973
  if (synthesisIndex >= 0) {
153
- await writeFile(logPath, `${content.slice(0, synthesisIndex)}${entry}${content.slice(synthesisIndex)}`, 'utf8');
974
+ await dependencies.writeLog(logPath, `${content.slice(0, synthesisIndex)}${entry}${content.slice(synthesisIndex)}`);
154
975
  return;
155
976
  }
156
- await appendFile(logPath, `${content.endsWith('\n') ? '' : '\n'}${entry}`, 'utf8');
977
+ await dependencies.appendLog(logPath, `${content.endsWith('\n') ? '' : '\n'}${entry}`);
157
978
  }
979
+ /**
980
+ * Appends one entry to the project log, at most once per idempotency key.
981
+ *
982
+ * The existence check, the idempotency scan, the template instantiation, and
983
+ * the write are one critical section under the log's advisory lock. Separating
984
+ * the scan from the mutation is what let two overlapping writers both pass the
985
+ * scan and then lose one entry to the other's stale rewrite, so they are not
986
+ * separable here.
987
+ */
158
988
  export async function appendProjectLog(input, overrides = {}) {
159
989
  const dependencies = { ...DEFAULT_APPEND_DEPENDENCIES, ...overrides };
160
990
  const projectPath = await resolveTargetProject(input, dependencies);
161
991
  const logPath = join(projectPath, PROJECT_LOG_FILENAME);
992
+ return withProjectLogLock(logPath, dependencies.lock, 'required', async () => appendLockedProjectLog(input, dependencies, logPath));
993
+ }
994
+ async function appendLockedProjectLog(input, dependencies, logPath) {
162
995
  const logExists = await fileExists(logPath);
163
996
  if (!logExists) {
164
997
  const effective = await dependencies.resolveEffectiveConfig(input.repoRoot, join(input.home ?? homedir(), '.oat'));
@@ -168,32 +1001,219 @@ export async function appendProjectLog(input, overrides = {}) {
168
1001
  }
169
1002
  const date = dependencies.now().toISOString().slice(0, 10);
170
1003
  const { heading, body, versionNote } = validateEntry(input, date);
1004
+ const idempotencyKey = validateIdempotencyKey(input.idempotencyKey, body);
171
1005
  let created = false;
1006
+ if (logExists && idempotencyKey !== undefined) {
1007
+ const existing = findProjectLogEntryByIdempotencyKey(await dependencies.readLog(logPath), idempotencyKey, body);
1008
+ if (existing !== undefined) {
1009
+ // Append-only order is preserved (DR-260714): a replay observes the
1010
+ // entry it already wrote instead of writing a second one.
1011
+ return {
1012
+ status: 'already-appended',
1013
+ logPath,
1014
+ heading: existing,
1015
+ created: false,
1016
+ };
1017
+ }
1018
+ }
172
1019
  if (!logExists) {
173
1020
  const assetsRoot = await dependencies.resolveAssetsRoot();
174
1021
  const template = await readFile(join(assetsRoot, 'templates', PROJECT_LOG_FILENAME), 'utf8');
175
- await writeFile(logPath, instantiateProjectLogTemplate(template, basename(projectPath), date), 'utf8');
1022
+ await dependencies.writeLog(logPath, instantiateProjectLogTemplate(template, basename(dirname(logPath)), date));
176
1023
  created = true;
177
1024
  }
178
- await appendEntry(logPath, heading, body, versionNote);
1025
+ await appendEntry(logPath, heading, body, versionNote, dependencies);
179
1026
  return { status: 'appended', logPath, heading, created };
180
1027
  }
1028
+ async function samePath(left, right) {
1029
+ if (resolve(left) === resolve(right)) {
1030
+ return true;
1031
+ }
1032
+ try {
1033
+ return (await realpath(left)) === (await realpath(right));
1034
+ }
1035
+ catch {
1036
+ return false;
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Validates a gate partial-finalization receipt against the live tree before
1041
+ * anything is appended or committed.
1042
+ *
1043
+ * Recovery finalizes a log entry; it never re-reads or re-validates the review
1044
+ * verdict, so the check here is identity only: the receipt must describe this
1045
+ * project, in this worktree, for a review artifact whose bytes are unchanged.
1046
+ * Any mismatch refuses and touches nothing.
1047
+ */
1048
+ async function validateGateProjectLogReceipt(options) {
1049
+ let receipt;
1050
+ try {
1051
+ receipt = JSON.parse(await readFile(options.receiptPath, 'utf8'));
1052
+ }
1053
+ catch (error) {
1054
+ return {
1055
+ ok: false,
1056
+ reason: `receipt is unreadable: ${error instanceof Error ? error.message : String(error)}`,
1057
+ };
1058
+ }
1059
+ if (receipt.runId !== options.idempotencyKey) {
1060
+ return {
1061
+ ok: false,
1062
+ reason: `receipt run id ${String(receipt.runId)} does not match --idempotency-key ${options.idempotencyKey}`,
1063
+ };
1064
+ }
1065
+ if (typeof receipt.projectPath !== 'string' ||
1066
+ !(await samePath(receipt.projectPath, options.projectPath))) {
1067
+ return {
1068
+ ok: false,
1069
+ reason: `receipt project ${String(receipt.projectPath)} does not resolve to ${options.projectPath}`,
1070
+ };
1071
+ }
1072
+ let worktreeRoot;
1073
+ try {
1074
+ worktreeRoot = runGit(options.repoRoot, ['rev-parse', '--show-toplevel']);
1075
+ }
1076
+ catch {
1077
+ return { ok: false, reason: 'no git worktree resolves for the project' };
1078
+ }
1079
+ if (typeof receipt.worktreeRoot !== 'string' ||
1080
+ !(await samePath(receipt.worktreeRoot, worktreeRoot))) {
1081
+ return {
1082
+ ok: false,
1083
+ reason: `receipt worktree ${String(receipt.worktreeRoot)} does not match ${worktreeRoot}`,
1084
+ };
1085
+ }
1086
+ if (receipt.artifactPath != null) {
1087
+ if (typeof receipt.artifactSignature !== 'string' ||
1088
+ receipt.artifactSignature.length === 0) {
1089
+ // A receipt that names an artifact always records its signature, so an
1090
+ // unsigned one is malformed rather than merely artifact-free.
1091
+ return {
1092
+ ok: false,
1093
+ reason: `receipt names ${receipt.artifactPath} without an artifact signature`,
1094
+ };
1095
+ }
1096
+ const artifactPath = isAbsolute(receipt.artifactPath)
1097
+ ? receipt.artifactPath
1098
+ : join(worktreeRoot, receipt.artifactPath);
1099
+ let content;
1100
+ try {
1101
+ content = await readFile(artifactPath, 'utf8');
1102
+ }
1103
+ catch {
1104
+ return {
1105
+ ok: false,
1106
+ reason: `review artifact is missing: ${receipt.artifactPath}`,
1107
+ };
1108
+ }
1109
+ const signature = createHash('sha256').update(content).digest('hex');
1110
+ if (signature !== receipt.artifactSignature) {
1111
+ return {
1112
+ ok: false,
1113
+ reason: `review artifact signature changed for ${receipt.artifactPath}`,
1114
+ };
1115
+ }
1116
+ }
1117
+ // The receipt describes exactly one entry. Consuming it while writing some
1118
+ // other entry would finalize the wrong thing and discard the real pending
1119
+ // work, so the replayed entry has to be the entry the receipt names.
1120
+ for (const [label, expected, actual] of [
1121
+ ['--producer', receipt.producer, options.producer],
1122
+ ['--ref', receipt.ref, options.ref],
1123
+ ['--body', receipt.body, options.body],
1124
+ ]) {
1125
+ if (expected !== actual) {
1126
+ return {
1127
+ ok: false,
1128
+ reason: `${label} does not match the receipt entry (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`,
1129
+ };
1130
+ }
1131
+ }
1132
+ return { ok: true, receipt };
1133
+ }
1134
+ function reportReceiptMismatch(context, receiptPath, reason) {
1135
+ if (context.json) {
1136
+ context.logger.json({
1137
+ status: 'error',
1138
+ type: 'gate-project-log-receipt-mismatch',
1139
+ receiptPath,
1140
+ reason,
1141
+ });
1142
+ return;
1143
+ }
1144
+ context.logger.error(`gate-project-log-receipt-mismatch: ${reason} (${receiptPath}). Nothing was appended or committed.`);
1145
+ }
181
1146
  async function runAppendCommand(options, context, dependencies) {
182
1147
  try {
183
1148
  const repoRoot = await dependencies.resolveProjectRoot(context.cwd);
184
1149
  const body = options.body === '-' ? await dependencies.readStdin() : options.body;
1150
+ let receiptPath;
1151
+ if (options.commit && options.idempotencyKey) {
1152
+ const projectPath = await resolveTargetProject({ repoRoot, home: context.home, project: options.project }, dependencies);
1153
+ const candidate = join(projectPath, GATE_RECEIPTS_DIRNAME, `${options.idempotencyKey}.json`);
1154
+ if (await fileExists(candidate)) {
1155
+ const validation = await validateGateProjectLogReceipt({
1156
+ receiptPath: candidate,
1157
+ repoRoot,
1158
+ projectPath,
1159
+ idempotencyKey: options.idempotencyKey,
1160
+ producer: options.producer,
1161
+ ref: options.ref,
1162
+ body,
1163
+ });
1164
+ if (!validation.ok) {
1165
+ reportReceiptMismatch(context, candidate, validation.reason);
1166
+ process.exitCode = 1;
1167
+ return;
1168
+ }
1169
+ receiptPath = candidate;
1170
+ }
1171
+ }
185
1172
  const result = await appendProjectLog({
186
1173
  repoRoot,
187
1174
  home: context.home,
188
1175
  ...options,
189
1176
  body,
190
1177
  }, dependencies);
1178
+ let commit;
1179
+ let receiptRemoved = false;
1180
+ if (options.commit && result.status !== 'skipped') {
1181
+ commit = await commitProjectLog({
1182
+ repoRoot,
1183
+ logPath: result.logPath,
1184
+ ...(options.idempotencyKey !== undefined && body !== undefined
1185
+ ? { identity: { key: options.idempotencyKey, body } }
1186
+ : {}),
1187
+ });
1188
+ if (receiptPath !== undefined &&
1189
+ SETTLED_COMMIT_OUTCOMES.has(commit.outcome)) {
1190
+ await rm(receiptPath, { force: true });
1191
+ receiptRemoved = true;
1192
+ }
1193
+ }
1194
+ const settled = commit === undefined || SETTLED_COMMIT_OUTCOMES.has(commit.outcome);
191
1195
  if (context.json) {
192
- context.logger.json(result);
1196
+ context.logger.json({
1197
+ ...result,
1198
+ ...(commit ? { commit } : {}),
1199
+ ...(receiptPath !== undefined
1200
+ ? { receipt: { path: receiptPath, removed: receiptRemoved } }
1201
+ : {}),
1202
+ });
193
1203
  }
194
1204
  else if (result.status === 'appended') {
195
1205
  context.logger.success(`Appended project log entry: ${result.logPath}`);
196
1206
  }
1207
+ else if (result.status === 'already-appended') {
1208
+ context.logger.success(`Project log entry already present for --idempotency-key: ${result.heading}`);
1209
+ }
1210
+ if (!settled) {
1211
+ if (!context.json) {
1212
+ context.logger.error(`Unable to commit the project log (${commit.outcome}${commit.lockClass ? `, ${commit.lockClass}` : ''}): ${commit.error ?? 'no detail'}`);
1213
+ }
1214
+ process.exitCode = 1;
1215
+ return;
1216
+ }
197
1217
  process.exitCode = 0;
198
1218
  }
199
1219
  catch (error) {
@@ -220,6 +1240,8 @@ export function createProjectLogAppendCommand(overrides = {}) {
220
1240
  .option('--body <text>', 'Entry body, or - to read the body from stdin')
221
1241
  .option('--version-note <text>', 'Append an observed-on version clause')
222
1242
  .option('--project <path>', 'Explicit project path; defaults to the active project')
1243
+ .option('--idempotency-key <key>', 'Globally unique key (a run id, not a common word) that must appear in --body; an entry already carrying it is reported as already-appended instead of duplicated')
1244
+ .option('--commit', 'Stage and commit the project log after appending, retrying across a transient index lock')
223
1245
  .addHelpText('after', `
224
1246
  Entry contract:
225
1247
  Log breaks, surprises, workarounds, or notable successes; record evidence, not narrative.
@@ -227,6 +1249,7 @@ Entry contract:
227
1249
  High-value judgments may use Observation:, Impact:, and Recommendation: fields.
228
1250
  Structural bodies are one line and reference artifacts by path instead of inlining them.
229
1251
  Add --version-note for tool-related observations.
1252
+ Pass --idempotency-key with --commit to finalize a gate partial-finalization receipt; a replay reports already-appended instead of duplicating the entry.
230
1253
  Never record secret values (tokens, keys, signed URLs, or credentials); reference secrets by name or source.
231
1254
  Prior entries are never edited or struck through. Append a new judgment entry that references and explains a correction.
232
1255