@sabaiway/agent-workflow-kit 13.0.0 → 13.2.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.
@@ -0,0 +1,270 @@
1
+ // spec:checkpoint — docs/ai/specs/kit/checkpoint/task-brief.md
2
+ import { writeFileSync, realpathSync, constants as fsConstants } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep, posix } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { readFileBytesNoFollow } from './fs-read-nofollow.mjs';
6
+ import { parseLedger, isSweep, PLAN_HEADINGS } from './plan-shape.mjs';
7
+ import { expandSweepPaths } from './plan-shape-facts.mjs';
8
+ import { readStoryLine, PIN_FILE } from './plan-shape-ownership.mjs';
9
+ import { parseDispatchContract } from './dispatch-record.mjs';
10
+ import { newestCheckpoint, verifyCheckpoint } from './checkpoint.mjs';
11
+ import { withRepository, readGit } from './checkpoint-core.mjs';
12
+ import { isDirectRun } from './direct-run.mjs';
13
+
14
+ export const BRIEF_BINDING_INFO_STRING = 'aw-task-binding';
15
+ const ACCEPT = 0;
16
+ const REFUSE = 1;
17
+ const USAGE = 2;
18
+ const START = 0;
19
+ const ONE = 1;
20
+ const EMPTY = '';
21
+ const NL = '\n';
22
+ const CR_END = /\r$/;
23
+ const UTF8 = 'utf8';
24
+ const STRING = 'string';
25
+ const OBJECT = 'object';
26
+ const HASH = 'sha256';
27
+ const HEX = 'hex';
28
+ const SHA_PREFIX = 'sha256:';
29
+ const DIGEST_LINE = 'task-brief digest';
30
+ const FENCE = '```';
31
+ const FENCE_RE = /^(`{3,})(.*)$/;
32
+ const TITLE_RE = /^# Task: (\S.*)$/;
33
+ const HEADING_RE = /^#{1,2} /;
34
+ const STORY_PREFIX = 'Story:';
35
+ const HEADINGS = ['## Slice', '## Reads', '## Acceptance', '## Negative cases', '## Budget'];
36
+ const SLICE_FIELDS = [/^Plan: (docs\/plans\/[^/]+\.md)$/, /^Row: (\S+)$/, /^Grouping: (.*)$/, /^Files:$/];
37
+ const PAIR_RE = /^- (\S(?:.*\S)?) :: (\S(?:.*\S)?)$/;
38
+ const BULLET_RE = /^- (\S.*)$/;
39
+ const INTEGER_RE = /^[1-9]\d*$/;
40
+ const TEST_RE = /(?:^|\/)[^/]+\.test(?:\.mjs$|\/[^/].*$)/;
41
+ const EXTENSION_RE = /\.[^/.]+$/;
42
+ const TEST_SUFFIX = '.test.mjs';
43
+ const TEST_DIRECTORY = '.test/';
44
+ const TEST = 'test';
45
+ const IMPL = 'impl';
46
+ const PIN = 'pin';
47
+ const TAGS = [TEST, IMPL, PIN];
48
+ const GROUP_SEPARATOR = ', ';
49
+ const PARENT = '..';
50
+ const CURRENT = '.';
51
+ const READ_OK = 'ok';
52
+ const SCHEMA = 1;
53
+ const BINDING_KEYS = ['schema', 'plan', 'reads', 'checkpoint', 'head'];
54
+ const FILE_KEYS = ['path', 'sha256'];
55
+ const SHA_RE = /^[0-9a-f]{64}$/;
56
+ const OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
57
+ const HEAD_ARGS = ['rev-parse', '--verify', 'HEAD^{commit}'];
58
+ const STAMP = 'stamp';
59
+ const CHECK = 'check';
60
+ const DISPATCH_FLAG = '--dispatch';
61
+ const BASIC_ARGS = 2;
62
+ const DISPATCH_ARGS = 4;
63
+ const ARGV_OFFSET = 2;
64
+ const NAMES = { shape: 'shape', grouping: 'grouping', row: 'row', story: 'story', reads: 'reads', binding: 'binding',
65
+ plan: 'binding-plan', read: 'binding-read', checkpoint: 'binding-checkpoint', stale: 'checkpoint-stale',
66
+ head: 'head', boundHead: 'binding-head', dispatch: 'dispatch-inputs' };
67
+ const USAGE_TEXT = 'usage: task-brief stamp <brief> | check <brief> [--dispatch <file>]';
68
+ const WRITE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
69
+
70
+ const refuse = (reason, code = REFUSE) => ({ ok: false, code, reason });
71
+ const reject = (name, detail = EMPTY) => { throw new Error(`${name}: ${detail}`); };
72
+ const computeDigest = (bytes) => createHash(HASH).update(bytes).digest(HEX);
73
+ const matchLine = (pattern, line) => {
74
+ const match = pattern.exec(line ?? EMPTY);
75
+ if (match === null) reject(NAMES.shape, line);
76
+ return match.slice(ONE);
77
+ };
78
+ const splitBindingBlocks = (text) => {
79
+ const raw = text.split(NL);
80
+ const lines = raw.map((line) => line.replace(CR_END, EMPTY));
81
+ const state = lines.reduce((state, line, index) => {
82
+ const fence = FENCE_RE.exec(line);
83
+ if (fence === null) return state;
84
+ const ticks = fence[ONE].length;
85
+ const info = fence[BASIC_ARGS].trim();
86
+ if (state.open === null) return { ...state, open: { ticks, info, start: index } };
87
+ if (info !== EMPTY || ticks < state.open.ticks) return state;
88
+ const blocks = state.open.info === BRIEF_BINDING_INFO_STRING
89
+ ? [...state.blocks, { start: state.open.start, end: index, source: lines.slice(state.open.start + ONE, index).join(NL) }]
90
+ : state.blocks;
91
+ return { open: null, blocks };
92
+ }, { open: null, blocks: [] });
93
+ const unclosed = state.open?.info === BRIEF_BINDING_INFO_STRING;
94
+ const blocks = unclosed ? [...state.blocks, { start: state.open.start, end: lines.length, source: null }] : state.blocks;
95
+ const outside = (index) => !blocks.some(({ start, end }) => index >= start && index <= end);
96
+ return { blocks, unclosed, body: raw.filter((line, index) => outside(index)).join(NL),
97
+ lines: lines.filter((line, index) => outside(index) && line.trim() !== EMPTY) };
98
+ };
99
+ export const parseBrief = (text) => {
100
+ try {
101
+ if (typeof text !== STRING) return refuse(NAMES.shape);
102
+ const document = splitBindingBlocks(text);
103
+ const lines = document.lines;
104
+ const [title] = matchLine(TITLE_RE, lines[START]);
105
+ const headings = lines.flatMap((line, index) => HEADING_RE.test(line) ? [{ text: line, index }] : []);
106
+ if (headings.length !== HEADINGS.length + ONE || HEADINGS.some((text, index) => headings[index + ONE]?.text !== text)) reject(NAMES.shape);
107
+ const sections = HEADINGS.map((heading, index) => lines.slice(headings[index + ONE].index + ONE, headings[index + BASIC_ARGS]?.index ?? lines.length));
108
+ const storyLines = lines.slice(ONE, headings[ONE].index);
109
+ if (storyLines.some((line) => !line.startsWith(STORY_PREFIX))) reject(NAMES.shape);
110
+ const [slice, reads, acceptance, negative, budget] = sections;
111
+ const [[planPath], [rowId], [grouping]] = SLICE_FIELDS.map((pattern, index) => matchLine(pattern, slice[index]));
112
+ const files = slice.slice(SLICE_FIELDS.length).map((line) => {
113
+ const [path, tag] = matchLine(PAIR_RE, line);
114
+ return { path, tag };
115
+ });
116
+ const budgets = budget.map((line) => {
117
+ const [path, count] = matchLine(PAIR_RE, line);
118
+ if (!INTEGER_RE.test(count) || !Number.isSafeInteger(Number(count))) reject(NAMES.shape, line);
119
+ return { path, maxLines: Number(count) };
120
+ });
121
+ const filePaths = new Set(files.map(({ path }) => path));
122
+ if (files.length === START || budgets.length !== filePaths.size || new Set(budgets.map(({ path }) => path)).size !== filePaths.size
123
+ || budgets.some(({ path }) => !filePaths.has(path)) || acceptance.length === START || negative.length === START) reject(NAMES.shape);
124
+ return { ok: true, title, storyLines, planPath, rowId, grouping, files, budgets,
125
+ reads: reads.map((line) => matchLine(BULLET_RE, line)[START]),
126
+ acceptance: acceptance.map((line) => matchLine(PAIR_RE, line)), negative: negative.map((line) => matchLine(BULLET_RE, line)[START]),
127
+ blocks: document.blocks, unclosed: document.unclosed, body: document.body };
128
+ } catch (error) { return refuse(error.message); }
129
+ };
130
+ const isInside = (top, path) => {
131
+ const rel = relative(top, path);
132
+ return !isAbsolute(rel) && rel !== PARENT && !rel.startsWith(`${PARENT}${sep}`);
133
+ };
134
+ const readInside = (context, path, name = NAMES.reads) => {
135
+ try {
136
+ if (typeof path !== STRING || path === EMPTY || isAbsolute(path)) reject(name, path);
137
+ const absolute = resolve(context.cwd, path);
138
+ if (!isInside(context.top, absolute) || !isInside(realpathSync(context.top), realpathSync(dirname(absolute)))) reject(name, path);
139
+ const result = readFileBytesNoFollow(absolute, context.io);
140
+ if (result.outcome !== READ_OK) reject(name, `${path}: ${result.code ?? result.className ?? result.outcome}`);
141
+ return { path, absolute, bytes: result.bytes, sha256: computeDigest(result.bytes) };
142
+ } catch (error) { reject(name, `${path}: ${error.message}`); }
143
+ };
144
+ const checkGrouping = (brief, row) => {
145
+ if (brief.files.some(({ tag }) => !TAGS.includes(tag))
146
+ || brief.grouping !== TAGS.filter((tag) => brief.files.some((file) => file.tag === tag)).join(GROUP_SEPARATOR)) reject(NAMES.grouping);
147
+ const pins = brief.files.filter(({ tag }) => tag === PIN);
148
+ if (row && posix.basename(row.path) === PIN_FILE) {
149
+ if (pins.length !== ONE || pins[START].path !== row.path) reject(NAMES.grouping);
150
+ } else if (pins.length !== START) reject(NAMES.grouping);
151
+ };
152
+ const checkRow = (context, brief, row) => {
153
+ if (!row?.valid) reject(NAMES.row, brief.rowId);
154
+ const files = brief.files.map((file) => {
155
+ const path = posix.normalize(file.path);
156
+ if (path !== file.path || path === CURRENT || path.startsWith(PARENT) || posix.isAbsolute(path)) reject(NAMES.row, file.path);
157
+ return { ...file, path };
158
+ });
159
+ const implementations = files.filter(({ tag }) => tag === IMPL).map(({ path }) => path);
160
+ const allowed = isSweep(row.path) ? expandSweepPaths(context.top, [row.path])[row.path] : [row.path];
161
+ if (implementations.some((path) => !allowed.includes(path))) reject(NAMES.row, row.id);
162
+ const tests = files.filter(({ tag }) => tag === TEST);
163
+ if (tests.some(({ path }) => TEST_RE.test(row.path) ? path !== row.path : !implementations.some((impl) => {
164
+ const stem = impl.replace(EXTENSION_RE, EMPTY);
165
+ return path === `${stem}${TEST_SUFFIX}` || path.startsWith(`${stem}${TEST_DIRECTORY}`);
166
+ }))) reject(NAMES.row, row.id);
167
+ };
168
+ const selectStory = (lines) => {
169
+ const entries = lines.map((text, index) => ({ text, line: index + ONE }));
170
+ const result = readStoryLine(entries);
171
+ return result.story && result.findings.length === START ? lines[result.story.line - ONE] : null;
172
+ };
173
+ const readGrammar = (context, briefPath) => {
174
+ const file = readInside(context, briefPath);
175
+ const brief = parseBrief(file.bytes.toString(UTF8));
176
+ if (!brief.ok) return brief;
177
+ const plan = readInside(context, brief.planPath);
178
+ const ledger = parseLedger(plan.bytes.toString(UTF8));
179
+ const row = ledger.rows.find(({ id }) => id === brief.rowId);
180
+ checkGrouping(brief, row);
181
+ checkRow(context, brief, row);
182
+ const goal = ledger.document.headings.find(({ text }) => text === PLAN_HEADINGS[START]);
183
+ const end = ledger.document.headings.find(({ index }) => index > goal?.index);
184
+ const story = goal ? selectStory(ledger.document.lines.slice(goal.index + ONE, end?.index)) : null;
185
+ if (story === null || selectStory(brief.storyLines) !== story) reject(NAMES.story);
186
+ return { ok: true, file, brief, plan };
187
+ };
188
+ const checkBlockCount = (brief, required) => {
189
+ if (brief.unclosed || brief.blocks.length > ONE || (required && brief.blocks.length !== ONE)) reject(NAMES.binding);
190
+ };
191
+ const hasClosedKeys = (value, keys) => value !== null && typeof value === OBJECT && !Array.isArray(value)
192
+ && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
193
+ const isFileBinding = (value) => hasClosedKeys(value, FILE_KEYS) && typeof value.path === STRING && typeof value.sha256 === STRING && SHA_RE.test(value.sha256);
194
+ const readBinding = (brief) => {
195
+ checkBlockCount(brief, true);
196
+ try {
197
+ const value = JSON.parse(brief.blocks[START].source);
198
+ if (!hasClosedKeys(value, BINDING_KEYS) || value.schema !== SCHEMA || !isFileBinding(value.plan)
199
+ || !Array.isArray(value.reads) || !value.reads.every(isFileBinding) || typeof value.checkpoint !== STRING
200
+ || !OID_RE.test(value.checkpoint) || typeof value.head !== STRING || !OID_RE.test(value.head)) reject(NAMES.binding);
201
+ return value;
202
+ } catch (error) { reject(NAMES.binding, error.message); }
203
+ };
204
+ const readHead = (context, name) => {
205
+ try {
206
+ const oid = readGit(context, HEAD_ARGS);
207
+ if (!OID_RE.test(oid)) reject(name);
208
+ return oid;
209
+ } catch (error) { reject(name, error.message); }
210
+ };
211
+ const bindFile = ({ path, sha256 }) => ({ path, sha256 });
212
+ const checkDispatch = (context, dispatchPath, briefPath, digest) => {
213
+ const file = readInside(context, dispatchPath, NAMES.dispatch);
214
+ const parsed = parseDispatchContract(file.bytes.toString(UTF8));
215
+ if (!parsed.ok || typeof parsed.contract.inputs !== STRING || !parsed.contract.inputs.includes(briefPath)
216
+ || !parsed.contract.inputs.includes(`${SHA_PREFIX}${digest}`)) reject(NAMES.dispatch);
217
+ };
218
+
219
+ export const stampBrief = (cwd, briefPath, io = {}) => withRepository(cwd, io, (context) => {
220
+ const grammar = readGrammar(context, briefPath);
221
+ if (!grammar.ok) return grammar;
222
+ const { file, brief, plan } = grammar;
223
+ checkBlockCount(brief, false);
224
+ const reads = brief.reads.map((path) => bindFile(readInside(context, path)));
225
+ const newest = newestCheckpoint(cwd, brief.planPath, context.io);
226
+ if (!newest.ok) return newest;
227
+ const binding = { schema: SCHEMA, plan: bindFile(plan), reads, checkpoint: newest.oid, head: readHead(context, NAMES.head) };
228
+ const bytes = Buffer.from(`${brief.body.trimEnd()}${NL}${NL}${FENCE}${BRIEF_BINDING_INFO_STRING}${NL}${JSON.stringify(binding)}${NL}${FENCE}${NL}`, UTF8);
229
+ writeFileSync(file.absolute, bytes, { flag: WRITE_FLAGS });
230
+ return { ok: true, digest: computeDigest(bytes) };
231
+ });
232
+
233
+ export const checkBrief = (cwd, briefPath, { dispatchPath } = {}, io = {}) => withRepository(cwd, io, (context) => {
234
+ const grammar = readGrammar(context, briefPath);
235
+ if (!grammar.ok) return grammar;
236
+ const { file, brief, plan } = grammar;
237
+ const binding = readBinding(brief);
238
+ const newest = newestCheckpoint(cwd, brief.planPath, context.io);
239
+ if (!newest.ok) return newest;
240
+ const reads = brief.reads.map((path) => bindFile(readInside(context, path)));
241
+ if (binding.plan.path !== brief.planPath || binding.plan.sha256 !== plan.sha256) reject(NAMES.plan);
242
+ if (binding.reads.length !== reads.length || reads.some((entry, index) =>
243
+ entry.path !== binding.reads[index].path || entry.sha256 !== binding.reads[index].sha256)) reject(NAMES.read);
244
+ if (binding.checkpoint !== newest.oid) reject(NAMES.checkpoint);
245
+ const verified = verifyCheckpoint(cwd, binding.checkpoint, context.io);
246
+ if (!verified.ok || !verified.clean) reject(NAMES.stale, verified.reason);
247
+ if (binding.head !== readHead(context, NAMES.boundHead)) reject(NAMES.boundHead);
248
+ if (dispatchPath !== undefined) checkDispatch(context, dispatchPath, briefPath, file.sha256);
249
+ return { ok: true, digest: file.sha256 };
250
+ });
251
+
252
+ const formatResult = (result) => result.ok
253
+ ? { code: ACCEPT, stdout: `${DIGEST_LINE} ${SHA_PREFIX}${result.digest}${NL}`, stderr: EMPTY }
254
+ : { code: result.code, stdout: EMPTY, stderr: `${result.reason}${NL}` };
255
+ export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
256
+ try {
257
+ const [verb, path, flag, dispatchPath] = argv;
258
+ if (!path || !(verb === STAMP || verb === CHECK) || !(argv.length === BASIC_ARGS
259
+ || (verb === CHECK && argv.length === DISPATCH_ARGS && flag === DISPATCH_FLAG && dispatchPath))) return formatResult(refuse(USAGE_TEXT, USAGE));
260
+ const cwd = deps.cwd ?? process.cwd();
261
+ return formatResult(verb === STAMP ? stampBrief(cwd, path, deps) : checkBrief(cwd, path, { dispatchPath }, deps));
262
+ } catch (error) { return formatResult(refuse(error.message)); }
263
+ };
264
+
265
+ if (isDirectRun(import.meta.url)) {
266
+ const result = main();
267
+ process.stdout.write(result.stdout);
268
+ process.stderr.write(result.stderr);
269
+ process.exitCode = result.code;
270
+ }