@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.
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/config-and-local-state.md +1 -0
- package/assets/docs/cli-utilities/configuration.md +10 -8
- package/assets/docs/cli-utilities/project-log.md +16 -0
- package/assets/docs/cli-utilities/tool-packs.md +83 -0
- package/assets/docs/cli-utilities/workflow-gates.md +124 -16
- package/assets/docs/contributing/skills.md +42 -7
- package/assets/docs/provider-sync/commands.md +3 -0
- package/assets/docs/provider-sync/instruction-sync.md +107 -0
- package/assets/docs/provider-sync/manifest-and-drift.md +105 -0
- package/assets/docs/reference/cli-reference.md +16 -6
- package/assets/docs/reference/oat-directory-structure.md +32 -31
- package/assets/docs/workflows/projects/lifecycle.md +45 -0
- package/assets/docs/workflows/projects/picking-up-projects.md +14 -0
- package/assets/docs/workflows/skills/explainer-kit.md +10 -4
- package/assets/docs/workflows/skills/repo-improve.md +15 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/create-agnostic-skill/SKILL.md +9 -7
- package/assets/skills/create-agnostic-skill/references/skill-template.md +9 -7
- package/assets/skills/oat-explainer-kit/SKILL.md +14 -1
- package/assets/skills/oat-explainer-kit/references/config-contract.md +12 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +57 -10
- package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +28 -5
- package/assets/skills/oat-explainer-kit/scripts/probe-recap-seams.mjs +371 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +123 -1
- package/assets/skills/oat-project-autonomous/SKILL.md +10 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +122 -121
- package/assets/skills/oat-project-complete/SKILL.md +134 -4
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +122 -121
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +38 -6
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +122 -121
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +122 -121
- package/assets/skills/oat-project-next/SKILL.md +62 -19
- package/assets/skills/oat-project-plan/SKILL.md +31 -5
- package/assets/skills/oat-project-pr-final/SKILL.md +281 -6
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +122 -121
- package/assets/skills/oat-project-progress/SKILL.md +12 -3
- package/assets/skills/oat-project-quick-start/SKILL.md +264 -4
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +122 -121
- package/assets/skills/oat-project-summary/SKILL.md +34 -23
- package/assets/skills/oat-repo-improve/SKILL.md +40 -5
- package/assets/skills/oat-repo-improve/references/plan-template.md +84 -7
- package/dist/agents/canonical/resolve.d.ts.map +1 -1
- package/dist/agents/canonical/resolve.js +25 -4
- package/dist/commands/config/index.d.ts +1 -0
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +401 -18
- package/dist/commands/doctor/index.d.ts +7 -0
- package/dist/commands/doctor/index.d.ts.map +1 -1
- package/dist/commands/doctor/index.js +10 -2
- package/dist/commands/gate/index.d.ts +3 -1
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +699 -211
- package/dist/commands/init/tools/index.d.ts.map +1 -1
- package/dist/commands/init/tools/index.js +68 -36
- package/dist/commands/init/tools/shared/skill-script-references.d.ts +126 -0
- package/dist/commands/init/tools/shared/skill-script-references.d.ts.map +1 -0
- package/dist/commands/init/tools/shared/skill-script-references.js +311 -0
- package/dist/commands/instructions/instructions.types.d.ts +63 -0
- package/dist/commands/instructions/instructions.types.d.ts.map +1 -1
- package/dist/commands/instructions/instructions.utils.d.ts +51 -2
- package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
- package/dist/commands/instructions/instructions.utils.js +201 -4
- package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
- package/dist/commands/instructions/sync/sync.js +12 -1
- package/dist/commands/instructions/validate/validate.d.ts.map +1 -1
- package/dist/commands/instructions/validate/validate.js +12 -1
- package/dist/commands/project/log/append.d.ts +195 -0
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +1034 -11
- package/dist/commands/shared/frontmatter.d.ts +51 -0
- package/dist/commands/shared/frontmatter.d.ts.map +1 -1
- package/dist/commands/shared/frontmatter.js +118 -6
- package/dist/commands/status/index.d.ts.map +1 -1
- package/dist/commands/status/index.js +5 -3
- package/dist/commands/sync/index.d.ts +14 -0
- package/dist/commands/sync/index.d.ts.map +1 -1
- package/dist/commands/sync/index.js +21 -0
- package/dist/commands/tools/info/index.d.ts.map +1 -1
- package/dist/commands/tools/info/index.js +14 -1
- package/dist/commands/tools/info/info-tool.d.ts +12 -0
- package/dist/commands/tools/info/info-tool.d.ts.map +1 -1
- package/dist/commands/tools/info/info-tool.js +54 -6
- package/dist/commands/tools/info/skill-views.d.ts +86 -0
- package/dist/commands/tools/info/skill-views.d.ts.map +1 -0
- package/dist/commands/tools/info/skill-views.js +383 -0
- package/dist/commands/tools/install/index.d.ts.map +1 -1
- package/dist/commands/tools/install/index.js +3 -25
- package/dist/commands/tools/list/list-tools.d.ts +2 -0
- package/dist/commands/tools/list/list-tools.d.ts.map +1 -1
- package/dist/commands/tools/list/list-tools.js +23 -4
- package/dist/commands/tools/remove/index.d.ts.map +1 -1
- package/dist/commands/tools/remove/index.js +35 -42
- package/dist/commands/tools/remove/remove-tools.d.ts +3 -2
- package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
- package/dist/commands/tools/remove/remove-tools.js +14 -4
- package/dist/commands/tools/shared/auto-sync.d.ts +12 -1
- package/dist/commands/tools/shared/auto-sync.d.ts.map +1 -1
- package/dist/commands/tools/shared/auto-sync.js +20 -4
- package/dist/commands/tools/shared/format-pack-inventory.d.ts +12 -1
- package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/format-pack-inventory.js +48 -6
- package/dist/commands/tools/shared/in-process-sync.d.ts +11 -0
- package/dist/commands/tools/shared/in-process-sync.d.ts.map +1 -0
- package/dist/commands/tools/shared/in-process-sync.js +79 -0
- package/dist/commands/tools/shared/pack-evidence.d.ts +78 -2
- package/dist/commands/tools/shared/pack-evidence.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-evidence.js +96 -2
- package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts +11 -0
- package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-lifecycle-outcome.js +24 -3
- package/dist/commands/tools/shared/pack-provider-evidence.d.ts +73 -0
- package/dist/commands/tools/shared/pack-provider-evidence.d.ts.map +1 -0
- package/dist/commands/tools/shared/pack-provider-evidence.js +161 -0
- package/dist/commands/tools/shared/provider-context.d.ts +88 -0
- package/dist/commands/tools/shared/provider-context.d.ts.map +1 -0
- package/dist/commands/tools/shared/provider-context.js +77 -0
- package/dist/commands/tools/shared/provider-reachability.d.ts +93 -0
- package/dist/commands/tools/shared/provider-reachability.d.ts.map +1 -0
- package/dist/commands/tools/shared/provider-reachability.js +358 -0
- package/dist/commands/tools/shared/sync-evidence.d.ts +57 -0
- package/dist/commands/tools/shared/sync-evidence.d.ts.map +1 -0
- package/dist/commands/tools/shared/sync-evidence.js +147 -0
- package/dist/commands/tools/update/index.d.ts +0 -4
- package/dist/commands/tools/update/index.d.ts.map +1 -1
- package/dist/commands/tools/update/index.js +22 -40
- package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
- package/dist/commands/tools/update/update-tools.js +23 -8
- package/dist/config/json.d.ts +36 -0
- package/dist/config/json.d.ts.map +1 -1
- package/dist/config/json.js +135 -3
- package/dist/config/oat-config.d.ts +41 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +111 -0
- package/dist/drift/index.d.ts +2 -0
- package/dist/drift/index.d.ts.map +1 -1
- package/dist/drift/index.js +1 -0
- package/dist/drift/skill-view-diagnostic.d.ts +197 -0
- package/dist/drift/skill-view-diagnostic.d.ts.map +1 -0
- package/dist/drift/skill-view-diagnostic.js +343 -0
- package/dist/validation/skills.d.ts +1 -0
- package/dist/validation/skills.d.ts.map +1 -1
- package/dist/validation/skills.js +181 -19
- package/package.json +2 -2
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
971
|
+
const content = await dependencies.readLog(logPath);
|
|
151
972
|
const synthesisIndex = content.indexOf(SYNTHESIS_HEADING_PREFIX);
|
|
152
973
|
if (synthesisIndex >= 0) {
|
|
153
|
-
await
|
|
974
|
+
await dependencies.writeLog(logPath, `${content.slice(0, synthesisIndex)}${entry}${content.slice(synthesisIndex)}`);
|
|
154
975
|
return;
|
|
155
976
|
}
|
|
156
|
-
await
|
|
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
|
|
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(
|
|
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
|
|