@jaybeeuu/agent-cortex 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -0
- package/README.md +102 -8
- package/agents/README.md +2 -2
- package/agents/ralph/agent.md +19 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/bin/agent-cortex.mjs +176 -67
- package/bin/installers/claude.mjs +33 -8
- package/bin/installers/pi.mjs +11 -1
- package/docs/skills/skill-anatomy.md +212 -0
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/lib/cli.mjs +88 -1
- package/lib/harness-update.mjs +254 -0
- package/lib/harness-versions.mjs +36 -2
- package/lib/self-update.mjs +553 -0
- package/package.json +2 -1
- package/scripts/lib/docs.mjs +67 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +20 -3
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +16 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +51 -24
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +69 -0
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/productivity/write-a-skill/SKILL.md +2 -2
- package/skills/review/refactor-skill/SKILL.md +2 -2
- package/skills/review/review-defects/EXAMPLES.md +122 -0
- package/skills/review/review-defects/SKILL.md +91 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/plan/SKILL.md +14 -3
- package/skills/workflow/ralph/REFERENCE.md +137 -30
- package/skills/workflow/ralph/SKILL.md +26 -16
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +41 -23
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +58 -0
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +66 -10
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/using-agent-skills/SKILL.md +15 -7
- package/token-map.README.md +41 -4
- package/token-map.json +16 -5
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The one writer for the bead trail: append a stage checkpoint to a bead's notes and
|
|
3
|
+
// record the refs it names as provenance events.
|
|
4
|
+
//
|
|
5
|
+
// The bead trail is the recovery contract: a fresh session must resume from
|
|
6
|
+
// `bd show <id>` alone. Every entry therefore carries the three mandatory fields in a
|
|
7
|
+
// fixed shape — Outcome, Evidence, What is left — so a reader greps one line each
|
|
8
|
+
// instead of parsing prose.
|
|
9
|
+
//
|
|
10
|
+
// Zero dependencies. Run:
|
|
11
|
+
//
|
|
12
|
+
// node trail-entry.mjs --bead <id> --stage <stage> --outcome <done|blocked|partial> \
|
|
13
|
+
// --evidence "<test or lint result, commit, command output>" \
|
|
14
|
+
// --remaining "<what is left>" [--commit <ref>] [--branch <name>] [--pr <url>] \
|
|
15
|
+
// [--work-id <id>] [--at <rfc3339>] [--cwd <dir>] [--json]
|
|
16
|
+
//
|
|
17
|
+
// Full SHA. `bd provenance record --ref-kind git-sha` rejects anything that is not 40
|
|
18
|
+
// lowercase hex, so a caller-supplied commit ref is resolved through `git rev-parse`
|
|
19
|
+
// and validated before anything is recorded. A resolution failure records nothing.
|
|
20
|
+
//
|
|
21
|
+
// Bounded retry. embeddeddolt serialises writers, so a concurrent ralph loop makes a
|
|
22
|
+
// write fail with `failed to open database ... context canceled`. Every bd call is one
|
|
23
|
+
// short-lived process, never a lock held across a long operation, and a transient
|
|
24
|
+
// failure is retried with bounded backoff. A retried note append re-reads the bead
|
|
25
|
+
// first, so a write that landed before its call reported failure is never duplicated.
|
|
26
|
+
//
|
|
27
|
+
// Idempotent for one writer, not concurrency-safe. The entry text carries a fingerprint of
|
|
28
|
+
// its own content, so a repeated fire — a second run after the first finished — finds it
|
|
29
|
+
// already present and appends nothing. Two writers that both read the bead before either
|
|
30
|
+
// appends both write, because `bd note` has no conditional append: fire one transition from
|
|
31
|
+
// one worker. A duplicate from that race carries the same fingerprint, so a reader can
|
|
32
|
+
// deduplicate it. Provenance recording is idempotent in bd itself: the event id is computed
|
|
33
|
+
// from source:issue:kind:ref, so re-recording the same event is a no-op.
|
|
34
|
+
|
|
35
|
+
import { execFile } from 'node:child_process';
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
import { promisify } from 'node:util';
|
|
39
|
+
|
|
40
|
+
// ─── Contract ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** The three accepted outcomes. The trail says what happened, not how it felt. @typedef {'done' | 'blocked' | 'partial'} Outcome */
|
|
43
|
+
|
|
44
|
+
/** @type {readonly Outcome[]} */
|
|
45
|
+
export const OUTCOMES = /** @type {const} */ (['done', 'blocked', 'partial']);
|
|
46
|
+
|
|
47
|
+
/** `--source` on every provenance event this script records. */
|
|
48
|
+
export const TRAIL_SOURCE = 'trail-entry';
|
|
49
|
+
|
|
50
|
+
/** Every ref a trail entry names is recorded as a stage handover. */
|
|
51
|
+
export const PROVENANCE_KIND = 'handoff';
|
|
52
|
+
|
|
53
|
+
/** Default attempts per bd call: the first try plus three retries. */
|
|
54
|
+
export const DEFAULT_ATTEMPTS = 4;
|
|
55
|
+
|
|
56
|
+
/** Default first backoff step, in milliseconds. Doubles on each retry. */
|
|
57
|
+
export const DEFAULT_BACKOFF_MS = 100;
|
|
58
|
+
|
|
59
|
+
/** Default per-call timeout, so a wedged bd process cannot stall a worker forever. */
|
|
60
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
/** The fingerprint width embedded in each entry. */
|
|
63
|
+
const FINGERPRINT_LENGTH = 16;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Failure shapes that mean "the database was busy, try again", not "this request is
|
|
67
|
+
* wrong". embeddeddolt serialises writers, so a second ralph loop surfaces the first
|
|
68
|
+
* two shapes routinely.
|
|
69
|
+
*/
|
|
70
|
+
export const TRANSIENT_PATTERNS = [
|
|
71
|
+
/context canceled/i,
|
|
72
|
+
/failed to open database/i,
|
|
73
|
+
/database is locked/i,
|
|
74
|
+
/could not acquire lock/i,
|
|
75
|
+
/lock acquisition/i,
|
|
76
|
+
/deadlock/i,
|
|
77
|
+
/connection reset by peer/i,
|
|
78
|
+
/connection refused/i,
|
|
79
|
+
/unexpected EOF/i,
|
|
80
|
+
/temporarily unavailable/i,
|
|
81
|
+
/server is shutting down/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** A child-process call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Refs the caller names, already resolved to the value provenance stores.
|
|
90
|
+
* @typedef {object} ResolvedRefs
|
|
91
|
+
* @property {string} [commit] Full 40-character lowercase SHA
|
|
92
|
+
* @property {string} [branch]
|
|
93
|
+
* @property {string} [pr] Full URL
|
|
94
|
+
* @property {string} [workId]
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/** A ref as `bd provenance record` takes it.
|
|
98
|
+
* @typedef {object} ProvenanceRef
|
|
99
|
+
* @property {'git-sha' | 'branch' | 'pr' | 'work-id'} refKind
|
|
100
|
+
* @property {string} ref
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Injected collaborators, so a test drives the script without a real bead database.
|
|
105
|
+
* @typedef {object} TrailDeps
|
|
106
|
+
* @property {Runner} [bd]
|
|
107
|
+
* @property {Runner} [git]
|
|
108
|
+
* @property {(ms: number) => Promise<void>} [sleep]
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What the run did, printed by the CLI and returned to tests.
|
|
113
|
+
* @typedef {object} TrailReport
|
|
114
|
+
* @property {string} bead
|
|
115
|
+
* @property {string} stage
|
|
116
|
+
* @property {Outcome} outcome
|
|
117
|
+
* @property {string} fingerprint
|
|
118
|
+
* @property {boolean} recorded False when an identical entry was already present.
|
|
119
|
+
* @property {string} entry The note text, appended or found.
|
|
120
|
+
* @property {ProvenanceRef[]} provenance Events recorded on this run.
|
|
121
|
+
* @property {number} attempts Total bd attempts spent on the note append.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/** A usage mistake: the caller asked for something malformed. Exit code 2. */
|
|
125
|
+
export class UsageError extends Error {}
|
|
126
|
+
|
|
127
|
+
/** A bd or git call that failed for good. Exit code 1. */
|
|
128
|
+
export class TrailError extends Error {
|
|
129
|
+
/** @param {string} message @param {{ step: string, attempts: number }} detail */
|
|
130
|
+
constructor(message, { step, attempts }) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = 'TrailError';
|
|
133
|
+
this.step = step;
|
|
134
|
+
this.attempts = attempts;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Value helpers ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** Read a non-negative integer from the environment, falling back to `fallback`.
|
|
141
|
+
* @param {string} name @param {number} fallback @returns {number} */
|
|
142
|
+
function envInt(name, fallback) {
|
|
143
|
+
const raw = process.env[name];
|
|
144
|
+
if (raw === undefined || raw === '') return fallback;
|
|
145
|
+
const value = Number(raw);
|
|
146
|
+
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : fallback;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The full message of a thrown error, including a child process's stderr.
|
|
150
|
+
* @param {unknown} error @returns {string} */
|
|
151
|
+
export function errorMessage(error) {
|
|
152
|
+
if (typeof error !== 'object' || error === null) return String(error);
|
|
153
|
+
const parts = [];
|
|
154
|
+
for (const key of /** @type {const} */ (['message', 'stderr'])) {
|
|
155
|
+
const value = /** @type {Record<string, unknown>} */ (error)[key];
|
|
156
|
+
if (typeof value === 'string' && value.trim() !== '') parts.push(value.trim());
|
|
157
|
+
}
|
|
158
|
+
return parts.join(': ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Is this failure worth retrying? @param {string} message @returns {boolean} */
|
|
162
|
+
export function isTransient(message) {
|
|
163
|
+
return TRANSIENT_PATTERNS.some((pattern) => pattern.test(message));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Collapse a value onto one line, so a field never breaks the entry's shape.
|
|
167
|
+
* @param {string} value @returns {string} */
|
|
168
|
+
export function oneLine(value) {
|
|
169
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The default backoff wait. @param {number} ms @returns {Promise<void>} */
|
|
173
|
+
const defaultSleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
174
|
+
|
|
175
|
+
// ─── Runners ──────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const execFileAsync = promisify(execFile);
|
|
178
|
+
|
|
179
|
+
// BD_PATH / GIT_PATH env overrides let machines where a binary is not on PATH pin a
|
|
180
|
+
// location. Nothing here names a repository, a branch or a host.
|
|
181
|
+
export const BD_PATH = process.env.BD_PATH || 'bd';
|
|
182
|
+
const GIT_PATH = process.env.GIT_PATH || 'git';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a runner for one binary. argv arrays only — never a shell string, so a value
|
|
186
|
+
* carrying spaces or quotes reaches the binary unmangled.
|
|
187
|
+
* @param {string} bin @param {string} cwd @returns {Runner}
|
|
188
|
+
*/
|
|
189
|
+
export function makeRunner(bin, cwd) {
|
|
190
|
+
return async (args) => {
|
|
191
|
+
const { stdout } = await execFileAsync(bin, args, {
|
|
192
|
+
cwd,
|
|
193
|
+
encoding: 'utf-8',
|
|
194
|
+
timeout: envInt('TRAIL_ENTRY_CALL_TIMEOUT_MS', DEFAULT_CALL_TIMEOUT_MS),
|
|
195
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
196
|
+
});
|
|
197
|
+
return stdout;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Retry ────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run one step, retrying only a transient failure, with bounded backoff.
|
|
205
|
+
*
|
|
206
|
+
* `run` is replayed from the top on every attempt, so a caller that must not repeat a
|
|
207
|
+
* side effect makes `run` itself detect whether the side effect already landed.
|
|
208
|
+
*
|
|
209
|
+
* @template T
|
|
210
|
+
* @param {object} options
|
|
211
|
+
* @param {string} options.step Human-readable label for errors.
|
|
212
|
+
* @param {() => Promise<T>} options.run
|
|
213
|
+
* @param {number} options.attempts
|
|
214
|
+
* @param {number} options.backoffMs
|
|
215
|
+
* @param {(ms: number) => Promise<void>} options.sleep
|
|
216
|
+
* @param {(attempt: number, message: string, waitMs: number) => void} [options.onRetry]
|
|
217
|
+
* @returns {Promise<{ value: T, attempts: number }>}
|
|
218
|
+
*/
|
|
219
|
+
export async function withRetry({ step, run, attempts, backoffMs, sleep, onRetry }) {
|
|
220
|
+
const limit = Math.max(1, attempts);
|
|
221
|
+
/** @type {unknown} */
|
|
222
|
+
let lastError;
|
|
223
|
+
let spent = 0;
|
|
224
|
+
|
|
225
|
+
for (let attempt = 1; attempt <= limit; attempt++) {
|
|
226
|
+
spent = attempt;
|
|
227
|
+
try {
|
|
228
|
+
return { value: await run(), attempts: attempt };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
lastError = error;
|
|
231
|
+
const message = errorMessage(error);
|
|
232
|
+
const retriable = isTransient(message);
|
|
233
|
+
if (!retriable || attempt === limit) {
|
|
234
|
+
const why = retriable ? `gave up after ${attempt} attempt(s)` : 'failed and is not transient';
|
|
235
|
+
throw new TrailError(`${step} ${why}: ${message}`, { step, attempts: attempt });
|
|
236
|
+
}
|
|
237
|
+
const waitMs = backoffMs * 2 ** (attempt - 1);
|
|
238
|
+
onRetry?.(attempt, message, waitMs);
|
|
239
|
+
await sleep(waitMs);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Unreachable: the loop either returns or throws on its last attempt.
|
|
244
|
+
throw new TrailError(`${step} failed: ${errorMessage(lastError)}`, { step, attempts: spent });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Refs ─────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** 40 lowercase hex characters, exactly what `--ref-kind git-sha` accepts. */
|
|
250
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
251
|
+
|
|
252
|
+
/** A provenance `pr` ref must be a whole URL, not a number or a path. */
|
|
253
|
+
const FULL_URL = /^https?:\/\/\S+$/;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a git ref to the full 40-character lowercase SHA.
|
|
257
|
+
*
|
|
258
|
+
* A short SHA, a branch name and `HEAD` all reach the same full value. Anything that
|
|
259
|
+
* does not resolve to 40 lowercase hex characters is rejected before a single bead is
|
|
260
|
+
* touched, because `bd provenance record --ref-kind git-sha` refuses it.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} options
|
|
263
|
+
* @param {string} options.ref
|
|
264
|
+
* @param {Runner} options.git
|
|
265
|
+
* @returns {Promise<string>}
|
|
266
|
+
*/
|
|
267
|
+
export async function resolveCommitSha({ ref, git }) {
|
|
268
|
+
const stdout = await withRetry({
|
|
269
|
+
step: `git rev-parse ${ref}`,
|
|
270
|
+
run: () => git(['rev-parse', '--verify', `${ref}^{commit}`]),
|
|
271
|
+
attempts: 1,
|
|
272
|
+
backoffMs: 0,
|
|
273
|
+
sleep: defaultSleep,
|
|
274
|
+
}).then(({ value }) => value);
|
|
275
|
+
|
|
276
|
+
const sha = stdout.trim();
|
|
277
|
+
if (!FULL_SHA.test(sha)) {
|
|
278
|
+
throw new TrailError(
|
|
279
|
+
`git rev-parse ${ref} did not resolve to a 40-character lowercase SHA (got ${JSON.stringify(sha)})`,
|
|
280
|
+
{ step: 'git rev-parse', attempts: 1 },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return sha;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One provenance ref per supplied field, in a stable order.
|
|
288
|
+
* @param {ResolvedRefs} refs @returns {ProvenanceRef[]}
|
|
289
|
+
*/
|
|
290
|
+
export function provenanceRefs(refs) {
|
|
291
|
+
/** @type {ProvenanceRef[]} */
|
|
292
|
+
const out = [];
|
|
293
|
+
if (refs.commit !== undefined) out.push({ refKind: 'git-sha', ref: refs.commit });
|
|
294
|
+
if (refs.branch !== undefined) out.push({ refKind: 'branch', ref: refs.branch });
|
|
295
|
+
if (refs.pr !== undefined) out.push({ refKind: 'pr', ref: refs.pr });
|
|
296
|
+
if (refs.workId !== undefined) out.push({ refKind: 'work-id', ref: refs.workId });
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Entry ────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A stable id for one transition: the same fields fired twice are the same entry.
|
|
304
|
+
* @param {object} input
|
|
305
|
+
* @param {string} input.beadId
|
|
306
|
+
* @param {string} input.stage
|
|
307
|
+
* @param {Outcome} input.outcome
|
|
308
|
+
* @param {string} input.evidence
|
|
309
|
+
* @param {string} input.remaining
|
|
310
|
+
* @param {ResolvedRefs} input.refs
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
export function fingerprintEntry({ beadId, stage, outcome, evidence, remaining, refs }) {
|
|
314
|
+
const key = [
|
|
315
|
+
beadId,
|
|
316
|
+
stage,
|
|
317
|
+
outcome,
|
|
318
|
+
evidence,
|
|
319
|
+
remaining,
|
|
320
|
+
refs.commit ?? '',
|
|
321
|
+
refs.branch ?? '',
|
|
322
|
+
refs.pr ?? '',
|
|
323
|
+
refs.workId ?? '',
|
|
324
|
+
].join('\u0000');
|
|
325
|
+
return createHash('sha256').update(key).digest('hex').slice(0, FINGERPRINT_LENGTH);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The note text for one entry: a timestamped header carrying the fingerprint, then the
|
|
330
|
+
* three mandatory fields, then the refs when the caller named any.
|
|
331
|
+
* @param {object} input
|
|
332
|
+
* @param {string} input.at
|
|
333
|
+
* @param {string} input.stage
|
|
334
|
+
* @param {Outcome} input.outcome
|
|
335
|
+
* @param {string} input.evidence
|
|
336
|
+
* @param {string} input.remaining
|
|
337
|
+
* @param {ResolvedRefs} input.refs
|
|
338
|
+
* @param {string} input.fingerprint
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
export function buildEntry({ at, stage, outcome, evidence, remaining, refs, fingerprint }) {
|
|
342
|
+
const lines = [
|
|
343
|
+
`[${at}] [${stage}] trail:${fingerprint}`,
|
|
344
|
+
`Outcome: ${outcome}`,
|
|
345
|
+
`Evidence: ${oneLine(evidence)}`,
|
|
346
|
+
`What is left: ${oneLine(remaining)}`,
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
const refLine = provenanceRefs(refs)
|
|
350
|
+
.map(({ refKind, ref }) => `${refKind}:${ref}`)
|
|
351
|
+
.join(', ');
|
|
352
|
+
if (refLine !== '') lines.push(`Refs: ${refLine}`);
|
|
353
|
+
|
|
354
|
+
return lines.join('\n');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The notes a bead already carries. `bd show <id> --json` prints a one-element array;
|
|
359
|
+
* a bare object is accepted too, because the shape is bd's to change.
|
|
360
|
+
* @param {object} options
|
|
361
|
+
* @param {string} options.beadId
|
|
362
|
+
* @param {Runner} options.bd
|
|
363
|
+
* @returns {Promise<string>}
|
|
364
|
+
*/
|
|
365
|
+
export async function readNotes({ beadId, bd }) {
|
|
366
|
+
const stdout = await bd(['show', beadId, '--json']);
|
|
367
|
+
let parsed;
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(stdout);
|
|
370
|
+
} catch {
|
|
371
|
+
throw new TrailError(`bd show ${beadId} --json did not print JSON`, { step: 'bd show', attempts: 1 });
|
|
372
|
+
}
|
|
373
|
+
const bead = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
374
|
+
if (typeof bead !== 'object' || bead === null) {
|
|
375
|
+
throw new TrailError(`bd show ${beadId} --json printed no bead`, { step: 'bd show', attempts: 1 });
|
|
376
|
+
}
|
|
377
|
+
const notes = /** @type {{ notes?: unknown }} */ (bead).notes;
|
|
378
|
+
return typeof notes === 'string' ? notes : '';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Validate the fields a caller supplies, so a malformed entry is rejected before any bd
|
|
383
|
+
* call rather than written in a shape no reader expects.
|
|
384
|
+
* @param {object} input
|
|
385
|
+
* @param {string} input.beadId
|
|
386
|
+
* @param {string} input.stage
|
|
387
|
+
* @param {string} input.outcome
|
|
388
|
+
* @param {string} input.evidence
|
|
389
|
+
* @param {string} input.remaining
|
|
390
|
+
* @param {{ commit?: string, branch?: string, pr?: string, workId?: string }} input.refs
|
|
391
|
+
* @returns {{ outcome: Outcome, evidence: string, remaining: string }}
|
|
392
|
+
*/
|
|
393
|
+
export function validateFields({ beadId, stage, outcome, evidence, remaining, refs }) {
|
|
394
|
+
if (beadId.trim() === '') throw new UsageError('--bead is required');
|
|
395
|
+
if (stage.trim() === '') throw new UsageError('--stage is required');
|
|
396
|
+
if (!OUTCOMES.includes(/** @type {Outcome} */ (outcome))) {
|
|
397
|
+
throw new UsageError(`--outcome must be one of ${OUTCOMES.join('|')} (got ${JSON.stringify(outcome)})`);
|
|
398
|
+
}
|
|
399
|
+
if (oneLine(evidence) === '') throw new UsageError('--evidence is required: a test or lint result, a commit, or command output');
|
|
400
|
+
if (oneLine(remaining) === '') throw new UsageError('--remaining is required: say what is left, or "none"');
|
|
401
|
+
if (refs.pr !== undefined && !FULL_URL.test(refs.pr)) {
|
|
402
|
+
throw new UsageError(`--pr must be a full URL (got ${JSON.stringify(refs.pr)})`);
|
|
403
|
+
}
|
|
404
|
+
for (const [flag, value] of /** @type {const} */ ([
|
|
405
|
+
['--branch', refs.branch],
|
|
406
|
+
['--work-id', refs.workId],
|
|
407
|
+
])) {
|
|
408
|
+
if (value !== undefined && value.trim() === '') throw new UsageError(`${flag} must not be empty`);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
outcome: /** @type {Outcome} */ (outcome),
|
|
412
|
+
evidence: oneLine(evidence),
|
|
413
|
+
remaining: oneLine(remaining),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Record ───────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Append one trail entry and record its refs.
|
|
421
|
+
*
|
|
422
|
+
* The note is written before provenance: the note is the recovery contract, so a run
|
|
423
|
+
* that fails between the two leaves a readable trail and a reported error rather than a
|
|
424
|
+
* provenance event pointing at a checkpoint nobody can read.
|
|
425
|
+
*
|
|
426
|
+
* @param {object} input
|
|
427
|
+
* @param {string} input.beadId
|
|
428
|
+
* @param {string} input.stage
|
|
429
|
+
* @param {string} input.outcome
|
|
430
|
+
* @param {string} input.evidence
|
|
431
|
+
* @param {string} input.remaining
|
|
432
|
+
* @param {{ commit?: string, branch?: string, pr?: string, workId?: string }} [input.refs]
|
|
433
|
+
* @param {string} [input.at] Entry timestamp; defaults to now, UTC.
|
|
434
|
+
* @param {string} [input.cwd] Directory bd and git read; defaults to the process cwd.
|
|
435
|
+
* @param {TrailDeps} [input.deps]
|
|
436
|
+
* @param {number} [input.attempts]
|
|
437
|
+
* @param {number} [input.backoffMs]
|
|
438
|
+
* @param {(attempt: number, message: string, waitMs: number) => void} [input.onRetry]
|
|
439
|
+
* @returns {Promise<TrailReport>}
|
|
440
|
+
*/
|
|
441
|
+
export async function recordTrailEntry({
|
|
442
|
+
beadId,
|
|
443
|
+
stage,
|
|
444
|
+
outcome,
|
|
445
|
+
evidence,
|
|
446
|
+
remaining,
|
|
447
|
+
refs = {},
|
|
448
|
+
at = new Date().toISOString(),
|
|
449
|
+
cwd = process.cwd(),
|
|
450
|
+
deps = {},
|
|
451
|
+
attempts = envInt('TRAIL_ENTRY_ATTEMPTS', DEFAULT_ATTEMPTS),
|
|
452
|
+
backoffMs = envInt('TRAIL_ENTRY_BACKOFF_MS', DEFAULT_BACKOFF_MS),
|
|
453
|
+
onRetry,
|
|
454
|
+
}) {
|
|
455
|
+
const { outcome: cleanOutcome, evidence: cleanEvidence, remaining: cleanRemaining } = validateFields({
|
|
456
|
+
beadId,
|
|
457
|
+
stage,
|
|
458
|
+
outcome,
|
|
459
|
+
evidence,
|
|
460
|
+
remaining,
|
|
461
|
+
refs,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const bd = deps.bd ?? makeRunner(BD_PATH, cwd);
|
|
465
|
+
const git = deps.git ?? makeRunner(GIT_PATH, cwd);
|
|
466
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
467
|
+
|
|
468
|
+
/** @type {ResolvedRefs} */
|
|
469
|
+
const resolvedRefs = {
|
|
470
|
+
...(refs.branch === undefined ? {} : { branch: refs.branch }),
|
|
471
|
+
...(refs.pr === undefined ? {} : { pr: refs.pr }),
|
|
472
|
+
...(refs.workId === undefined ? {} : { workId: refs.workId }),
|
|
473
|
+
// A commit ref is resolved before the first bead call: a bad ref must record
|
|
474
|
+
// nothing at all, not a note whose provenance is then refused.
|
|
475
|
+
...(refs.commit === undefined ? {} : { commit: await resolveCommitSha({ ref: refs.commit, git }) }),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const fingerprint = fingerprintEntry({
|
|
479
|
+
beadId,
|
|
480
|
+
stage,
|
|
481
|
+
outcome: cleanOutcome,
|
|
482
|
+
evidence: cleanEvidence,
|
|
483
|
+
remaining: cleanRemaining,
|
|
484
|
+
refs: resolvedRefs,
|
|
485
|
+
});
|
|
486
|
+
const entry = buildEntry({
|
|
487
|
+
at,
|
|
488
|
+
stage,
|
|
489
|
+
outcome: cleanOutcome,
|
|
490
|
+
evidence: cleanEvidence,
|
|
491
|
+
remaining: cleanRemaining,
|
|
492
|
+
refs: resolvedRefs,
|
|
493
|
+
fingerprint,
|
|
494
|
+
});
|
|
495
|
+
const marker = `trail:${fingerprint}`;
|
|
496
|
+
|
|
497
|
+
// The append is replayed from the top on retry. Reading first means an attempt whose
|
|
498
|
+
// outcome is unknown — the call died after the write landed — is detected instead of
|
|
499
|
+
// appended a second time.
|
|
500
|
+
const note = await withRetry({
|
|
501
|
+
// One step covers the read and the write, because a retry must judge the pair.
|
|
502
|
+
step: `trail append on ${beadId}`,
|
|
503
|
+
attempts,
|
|
504
|
+
backoffMs,
|
|
505
|
+
sleep,
|
|
506
|
+
onRetry,
|
|
507
|
+
run: async () => {
|
|
508
|
+
const notes = await readNotes({ beadId, bd });
|
|
509
|
+
if (notes.includes(marker)) return false;
|
|
510
|
+
await bd(['note', beadId, entry]);
|
|
511
|
+
return true;
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
/** @type {ProvenanceRef[]} */
|
|
516
|
+
const provenance = [];
|
|
517
|
+
for (const ref of provenanceRefs(resolvedRefs)) {
|
|
518
|
+
await withRetry({
|
|
519
|
+
step: `bd provenance record ${ref.refKind}:${ref.ref}`,
|
|
520
|
+
attempts,
|
|
521
|
+
backoffMs,
|
|
522
|
+
sleep,
|
|
523
|
+
onRetry,
|
|
524
|
+
run: () => bd([
|
|
525
|
+
'provenance', 'record',
|
|
526
|
+
'--issue', beadId,
|
|
527
|
+
'--kind', PROVENANCE_KIND,
|
|
528
|
+
'--source', TRAIL_SOURCE,
|
|
529
|
+
'--ref-kind', ref.refKind,
|
|
530
|
+
'--ref', ref.ref,
|
|
531
|
+
]),
|
|
532
|
+
});
|
|
533
|
+
provenance.push(ref);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
bead: beadId,
|
|
538
|
+
stage,
|
|
539
|
+
outcome: cleanOutcome,
|
|
540
|
+
fingerprint,
|
|
541
|
+
recorded: note.value,
|
|
542
|
+
entry,
|
|
543
|
+
provenance,
|
|
544
|
+
attempts: note.attempts,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
const USAGE = `Usage: node trail-entry.mjs --bead <id> --stage <stage> --outcome <done|blocked|partial> \\
|
|
551
|
+
--evidence "<test or lint result, commit, or command output>" --remaining "<what is left>" \\
|
|
552
|
+
[--commit <ref>] [--branch <name>] [--pr <url>] [--work-id <id>] [--at <rfc3339>] \\
|
|
553
|
+
[--cwd <dir>] [--json]
|
|
554
|
+
|
|
555
|
+
Appends one checkpoint to the bead's notes and records its refs as provenance events.
|
|
556
|
+
The entry carries three mandatory fields — Outcome, Evidence, What is left.
|
|
557
|
+
--commit is resolved to a full 40-character lowercase SHA before anything is written.
|
|
558
|
+
A transient bd failure is retried; a repeated fire records nothing twice. Two concurrent
|
|
559
|
+
fires of one entry can both append, so fire one transition from one worker.
|
|
560
|
+
|
|
561
|
+
Environment:
|
|
562
|
+
BD_PATH bd binary (default: bd)
|
|
563
|
+
GIT_PATH git binary (default: git)
|
|
564
|
+
TRAIL_ENTRY_ATTEMPTS attempts per bd call (default: ${DEFAULT_ATTEMPTS})
|
|
565
|
+
TRAIL_ENTRY_BACKOFF_MS first backoff step in ms (default: ${DEFAULT_BACKOFF_MS})
|
|
566
|
+
TRAIL_ENTRY_CALL_TIMEOUT_MS per-call timeout in ms (default: ${DEFAULT_CALL_TIMEOUT_MS})
|
|
567
|
+
`;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Parse the CLI's argv into `recordTrailEntry` options.
|
|
571
|
+
* @param {string[]} argv @returns {{ beadId: string, stage: string, outcome: string, evidence: string, remaining: string, refs: { commit?: string, branch?: string, pr?: string, workId?: string }, at?: string, cwd: string, json: boolean }}
|
|
572
|
+
*/
|
|
573
|
+
export function parseArgs(argv) {
|
|
574
|
+
/** @type {{ commit?: string, branch?: string, pr?: string, workId?: string }} */
|
|
575
|
+
const refs = {};
|
|
576
|
+
/** @type {Record<string, string>} */
|
|
577
|
+
const values = {};
|
|
578
|
+
let cwd = process.cwd();
|
|
579
|
+
let json = false;
|
|
580
|
+
|
|
581
|
+
const FLAGS = new Map([
|
|
582
|
+
['--bead', 'bead'],
|
|
583
|
+
['--stage', 'stage'],
|
|
584
|
+
['--outcome', 'outcome'],
|
|
585
|
+
['--evidence', 'evidence'],
|
|
586
|
+
['--remaining', 'remaining'],
|
|
587
|
+
['--at', 'at'],
|
|
588
|
+
['--cwd', 'cwd'],
|
|
589
|
+
]);
|
|
590
|
+
|
|
591
|
+
for (let i = 0; i < argv.length; i++) {
|
|
592
|
+
const arg = argv[i];
|
|
593
|
+
if (arg === '--json') { json = true; continue; }
|
|
594
|
+
if (arg === '--commit') { refs.commit = argv[++i]; continue; }
|
|
595
|
+
if (arg === '--branch') { refs.branch = argv[++i]; continue; }
|
|
596
|
+
if (arg === '--pr') { refs.pr = argv[++i]; continue; }
|
|
597
|
+
if (arg === '--work-id') { refs.workId = argv[++i]; continue; }
|
|
598
|
+
const name = FLAGS.get(arg);
|
|
599
|
+
if (name === undefined) throw new UsageError(`unexpected argument: ${arg}`);
|
|
600
|
+
const value = argv[++i];
|
|
601
|
+
if (value === undefined) throw new UsageError(`${arg} needs a value`);
|
|
602
|
+
if (name === 'cwd') cwd = value;
|
|
603
|
+
else values[name] = value;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const [flag, name] of /** @type {const} */ ([
|
|
607
|
+
['--bead', 'bead'],
|
|
608
|
+
['--stage', 'stage'],
|
|
609
|
+
['--outcome', 'outcome'],
|
|
610
|
+
['--evidence', 'evidence'],
|
|
611
|
+
['--remaining', 'remaining'],
|
|
612
|
+
])) {
|
|
613
|
+
if (values[name] === undefined) throw new UsageError(`${flag} is required`);
|
|
614
|
+
}
|
|
615
|
+
for (const ref of Object.entries(refs)) {
|
|
616
|
+
if (ref[1] === undefined) throw new UsageError(`--${ref[0]} needs a value`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
beadId: values.bead,
|
|
621
|
+
stage: values.stage,
|
|
622
|
+
outcome: values.outcome,
|
|
623
|
+
evidence: values.evidence,
|
|
624
|
+
remaining: values.remaining,
|
|
625
|
+
refs,
|
|
626
|
+
...(values.at === undefined ? {} : { at: values.at }),
|
|
627
|
+
cwd,
|
|
628
|
+
json,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* @param {object} options
|
|
634
|
+
* @param {string[]} options.argv CLI arguments (without node/script).
|
|
635
|
+
* @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
|
|
636
|
+
* @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
|
|
637
|
+
* @param {TrailDeps} [options.deps] Collaborators; injectable for tests.
|
|
638
|
+
* @param {(attempt: number, message: string, waitMs: number) => void} [options.onRetry]
|
|
639
|
+
* @returns {Promise<number>} process exit code.
|
|
640
|
+
*/
|
|
641
|
+
export async function runCli({
|
|
642
|
+
argv,
|
|
643
|
+
write = (chunk) => process.stdout.write(chunk),
|
|
644
|
+
writeError = (chunk) => process.stderr.write(chunk),
|
|
645
|
+
deps = {},
|
|
646
|
+
onRetry,
|
|
647
|
+
}) {
|
|
648
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
649
|
+
write(USAGE);
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let options;
|
|
654
|
+
try {
|
|
655
|
+
options = parseArgs(argv);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
writeError(`trail-entry: ${errorMessage(error)}\n\n${USAGE}`);
|
|
658
|
+
return 2;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const report = await recordTrailEntry({
|
|
663
|
+
beadId: options.beadId,
|
|
664
|
+
stage: options.stage,
|
|
665
|
+
outcome: options.outcome,
|
|
666
|
+
evidence: options.evidence,
|
|
667
|
+
remaining: options.remaining,
|
|
668
|
+
refs: options.refs,
|
|
669
|
+
cwd: options.cwd,
|
|
670
|
+
deps,
|
|
671
|
+
onRetry,
|
|
672
|
+
...(options.at === undefined ? {} : { at: options.at }),
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
if (options.json) {
|
|
676
|
+
write(`${JSON.stringify(report, null, 2)}\n`);
|
|
677
|
+
} else {
|
|
678
|
+
const verb = report.recorded ? 'recorded' : 'already recorded';
|
|
679
|
+
const refs = report.provenance.length === 0
|
|
680
|
+
? 'no refs'
|
|
681
|
+
: report.provenance.map(({ refKind, ref }) => `${refKind}:${ref}`).join(', ');
|
|
682
|
+
write(`✓ trail entry ${verb} on ${report.bead} (trail:${report.fingerprint}; ${refs})\n`);
|
|
683
|
+
}
|
|
684
|
+
return 0;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
const kind = error instanceof UsageError ? 'usage' : 'failed';
|
|
687
|
+
writeError(`trail-entry ${kind}: ${errorMessage(error)}\n`);
|
|
688
|
+
return error instanceof UsageError ? 2 : 1;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Only run the CLI when invoked directly, so tests can import the module.
|
|
693
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
694
|
+
runCli({ argv: process.argv.slice(2) })
|
|
695
|
+
.then((code) => {
|
|
696
|
+
process.exitCode = code;
|
|
697
|
+
})
|
|
698
|
+
.catch((error) => {
|
|
699
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
700
|
+
process.exitCode = 1;
|
|
701
|
+
});
|
|
702
|
+
}
|