@sabaiway/agent-workflow-kit 14.5.0 → 14.7.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.
@@ -68,20 +68,38 @@ const DISPATCH_FLAG = '--dispatch';
68
68
  const BASIC_ARGS = 2;
69
69
  const DISPATCH_ARGS = 4;
70
70
  const ARGV_OFFSET = 2;
71
- const NAMES = { shape: 'shape', grouping: 'grouping', row: 'row', story: 'story', reads: 'reads', binding: 'binding',
71
+ export const NAMES = { shape: 'shape', grouping: 'grouping', row: 'row', story: 'story', reads: 'reads', binding: 'binding',
72
72
  plan: 'binding-plan', read: 'binding-read', checkpoint: 'binding-checkpoint', stale: 'checkpoint-stale',
73
- head: 'head', boundHead: 'binding-head', dispatch: 'dispatch-inputs' };
73
+ head: 'head', boundHead: 'binding-head', dispatch: 'dispatch-inputs', environment: 'environment' };
74
+ const HANDED_UP = ['stem', 'sequence', 'no-checkpoint'];
75
+ const MEMBERS = new Set([...Object.values(NAMES), ...HANDED_UP]);
76
+ const NAME_SEPARATOR = ': ';
77
+ const LINE_BREAKS = /[\r\n]/g;
78
+ const ESCAPED_BREAKS = { '\r': '\\r', '\n': '\\n' };
79
+ const SLICE_NAMES = ['Plan', 'Row', 'Grouping', 'Files:'];
80
+ const STALE_REMEDY = 'mint a fresh checkpoint, then stamp the brief again';
74
81
  const USAGE_TEXT = 'usage: task-brief stamp <brief> | check <brief> [--dispatch <file>]';
75
82
  const WRITE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
76
83
 
77
84
  const refuse = (reason, code = REFUSE) => ({ ok: false, code, reason });
78
- const reject = (name, detail = EMPTY) => { throw new Error(`${name}: ${detail}`); };
85
+ const reject = (name, detail) => { throw new Error(`${name}${NAME_SEPARATOR}${detail}`); };
79
86
  const computeDigest = (bytes) => createHash(HASH).update(bytes).digest(HEX);
80
- const matchLine = (pattern, line) => {
87
+ const matchLine = (pattern, line, rule) => {
81
88
  const match = pattern.exec(line ?? EMPTY);
82
- if (match === null) reject(NAMES.shape, line);
89
+ if (match === null) reject(NAMES.shape, line === undefined ? `${rule} is absent` : `${rule}: ${line}`);
83
90
  return match.slice(ONE);
84
91
  };
92
+ const checkMembers = (found, expected, label) => {
93
+ const faults = [[found.find((item) => !expected.includes(item)), 'is foreign'],
94
+ [found.find((item, index) => found.indexOf(item) !== index), 'is repeated'], [expected.find((item) => !found.includes(item)), 'is absent']];
95
+ const [item, fault] = faults.find(([candidate]) => candidate !== undefined) ?? [];
96
+ if (item !== undefined) reject(NAMES.shape, `${label} ${item} ${fault}`);
97
+ };
98
+ const checkSliceField = (pattern, line, index) => {
99
+ const name = SLICE_NAMES[index];
100
+ if (line === undefined || !line.startsWith(name)) reject(NAMES.shape, `## Slice field ${name} is absent before: ${line ?? 'the section end'}`);
101
+ return matchLine(pattern, line, `## Slice field ${name} is malformed`);
102
+ };
85
103
  const splitBindingBlocks = (text) => {
86
104
  const raw = text.split(NL);
87
105
  const lines = raw.map((line) => line.replace(CR_END, EMPTY));
@@ -105,32 +123,37 @@ const splitBindingBlocks = (text) => {
105
123
  };
106
124
  export const parseBrief = (text) => {
107
125
  try {
108
- if (typeof text !== STRING) return refuse(NAMES.shape);
126
+ if (typeof text !== STRING) return refuse(`${NAMES.shape}${NAME_SEPARATOR}the brief is not text`);
109
127
  const document = splitBindingBlocks(text);
110
128
  const lines = document.lines;
111
- const [title] = matchLine(TITLE_RE, lines[START]);
129
+ const [title] = matchLine(TITLE_RE, lines[START], 'the title is not "# Task: <name>"');
112
130
  const headings = lines.flatMap((line, index) => HEADING_RE.test(line) ? [{ text: line, index }] : []);
113
- if (headings.length !== HEADINGS.length + ONE || HEADINGS.some((text, index) => headings[index + ONE]?.text !== text)) reject(NAMES.shape);
131
+ const storyLines = lines.slice(ONE, headings[ONE]?.index ?? lines.length);
132
+ const stray = storyLines.find((line) => !line.startsWith(STORY_PREFIX));
133
+ if (stray !== undefined) reject(NAMES.shape, `a line before ## Slice is not a Story line: ${stray}`);
134
+ const found = headings.slice(ONE).map(({ text }) => text);
135
+ checkMembers(found, HEADINGS, 'heading');
136
+ if (HEADINGS.some((text, index) => found[index] !== text)) reject(NAMES.shape, `headings out of order: ${found.join(', ')}`);
114
137
  const sections = HEADINGS.map((heading, index) => lines.slice(headings[index + ONE].index + ONE, headings[index + BASIC_ARGS]?.index ?? lines.length));
115
- const storyLines = lines.slice(ONE, headings[ONE].index);
116
- if (storyLines.some((line) => !line.startsWith(STORY_PREFIX))) reject(NAMES.shape);
117
138
  const [slice, reads, acceptance, negative, budget] = sections;
118
- const [[planPath], [rowId], [grouping]] = SLICE_FIELDS.map((pattern, index) => matchLine(pattern, slice[index]));
139
+ const [[planPath], [rowId], [grouping]] = SLICE_FIELDS.map((pattern, index) => checkSliceField(pattern, slice[index], index));
119
140
  const files = slice.slice(SLICE_FIELDS.length).map((line) => {
120
- const [path, tag] = matchLine(PAIR_RE, line);
141
+ const [path, tag] = matchLine(PAIR_RE, line, 'a Files: line is not "- <path> :: <tag>"');
121
142
  return { path, tag };
122
143
  });
123
144
  const budgets = budget.map((line) => {
124
- const [path, count] = matchLine(PAIR_RE, line);
125
- if (!INTEGER_RE.test(count) || !Number.isSafeInteger(Number(count))) reject(NAMES.shape, line);
145
+ const [path, count] = matchLine(PAIR_RE, line, 'a ## Budget line is not "- <path> :: <lines>"');
146
+ if (!INTEGER_RE.test(count) || !Number.isSafeInteger(Number(count))) reject(NAMES.shape, `a ## Budget count is not a positive integer: ${line}`);
126
147
  return { path, maxLines: Number(count) };
127
148
  });
128
- const filePaths = new Set(files.map(({ path }) => path));
129
- if (files.length === START || budgets.length !== filePaths.size || new Set(budgets.map(({ path }) => path)).size !== filePaths.size
130
- || budgets.some(({ path }) => !filePaths.has(path)) || acceptance.length === START || negative.length === START) reject(NAMES.shape);
149
+ if (files.length === START) reject(NAMES.shape, 'Files: lists no path');
150
+ checkMembers(budgets.map(({ path }) => path), [...new Set(files.map(({ path }) => path))], '## Budget path');
151
+ if (acceptance.length === START) reject(NAMES.shape, '## Acceptance lists no line');
152
+ if (negative.length === START) reject(NAMES.shape, '## Negative cases lists no line');
131
153
  return { ok: true, title, storyLines, planPath, rowId, grouping, files, budgets,
132
- reads: reads.map((line) => matchLine(BULLET_RE, line)[START]),
133
- acceptance: acceptance.map((line) => matchLine(PAIR_RE, line)), negative: negative.map((line) => matchLine(BULLET_RE, line)[START]),
154
+ reads: reads.map((line) => matchLine(BULLET_RE, line, 'a ## Reads line is not a bullet')[START]),
155
+ acceptance: acceptance.map((line) => matchLine(PAIR_RE, line, 'an ## Acceptance line is not "- <command> :: <outcome>"')),
156
+ negative: negative.map((line) => matchLine(BULLET_RE, line, 'a ## Negative cases line is not a bullet')[START]),
134
157
  blocks: document.blocks, unclosed: document.unclosed, body: document.body };
135
158
  } catch (error) { return refuse(error.message); }
136
159
  };
@@ -149,12 +172,14 @@ const readInside = (context, path, name = NAMES.reads) => {
149
172
  } catch (error) { reject(name, `${path}: ${error.message}`); }
150
173
  };
151
174
  const checkGrouping = (brief, row) => {
152
- if (brief.files.some(({ tag }) => !TAGS.includes(tag))
153
- || brief.grouping !== TAGS.filter((tag) => brief.files.some((file) => file.tag === tag)).join(GROUP_SEPARATOR)) reject(NAMES.grouping);
175
+ const foreign = brief.files.find(({ tag }) => !TAGS.includes(tag));
176
+ if (foreign !== undefined) reject(NAMES.grouping, `${foreign.path} carries the tag ${foreign.tag}, not test, impl or pin`);
177
+ const expected = TAGS.filter((tag) => brief.files.some((file) => file.tag === tag)).join(GROUP_SEPARATOR);
178
+ if (brief.grouping !== expected) reject(NAMES.grouping, `Grouping: ${brief.grouping} differs from the Files tags ${expected}`);
154
179
  const pins = brief.files.filter(({ tag }) => tag === PIN);
155
180
  if (row && posix.basename(row.path) === PIN_FILE) {
156
- if (pins.length !== ONE || pins[START].path !== row.path) reject(NAMES.grouping);
157
- } else if (pins.length !== START) reject(NAMES.grouping);
181
+ if (pins.length !== ONE || pins[START].path !== row.path) reject(NAMES.grouping, `a pin row tags only its own path ${row.path} pin`);
182
+ } else if (pins.length !== START) reject(NAMES.grouping, `the pin tag belongs to a ${PIN_FILE} row only`);
158
183
  };
159
184
  const checkRow = (context, brief, row) => {
160
185
  if (!row?.valid) reject(NAMES.row, brief.rowId);
@@ -190,38 +215,45 @@ const readGrammar = (context, briefPath) => {
190
215
  const goal = ledger.document.headings.find(({ text }) => text === PLAN_HEADINGS[START]);
191
216
  const end = ledger.document.headings.find(({ index }) => index > goal?.index);
192
217
  const story = goal ? selectStory(ledger.document.lines.slice(goal.index + ONE, end?.index)) : null;
193
- if (story === null || selectStory(brief.storyLines) !== story) reject(NAMES.story);
218
+ if (story === null) reject(NAMES.story, `${brief.planPath} carries no single Story line`);
219
+ if (selectStory(brief.storyLines) !== story) reject(NAMES.story, `the brief's Story line differs from the plan's ${story}`);
194
220
  return { ok: true, file, brief, plan };
195
221
  };
196
222
  const checkBlockCount = (brief, required) => {
197
- if (brief.unclosed || brief.blocks.length > ONE || (required && brief.blocks.length !== ONE)) reject(NAMES.binding);
223
+ if (brief.unclosed) reject(NAMES.binding, `the ${BRIEF_BINDING_INFO_STRING} block never closes`);
224
+ if (brief.blocks.length > ONE) reject(NAMES.binding, `more than one ${BRIEF_BINDING_INFO_STRING} block`);
225
+ if (required && brief.blocks.length !== ONE) reject(NAMES.binding, `no ${BRIEF_BINDING_INFO_STRING} block: stamp the brief first`);
198
226
  };
199
227
  const hasClosedKeys = (value, keys) => value !== null && typeof value === OBJECT && !Array.isArray(value)
200
228
  && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
201
229
  const isFileBinding = (value) => hasClosedKeys(value, FILE_KEYS) && typeof value.path === STRING && typeof value.sha256 === STRING && SHA_RE.test(value.sha256);
202
230
  const readBinding = (brief) => {
203
231
  checkBlockCount(brief, true);
232
+ let value;
204
233
  try {
205
- const value = JSON.parse(brief.blocks[START].source);
206
- if (!hasClosedKeys(value, BINDING_KEYS) || value.schema !== SCHEMA || !isFileBinding(value.plan)
207
- || !Array.isArray(value.reads) || !value.reads.every(isFileBinding) || typeof value.checkpoint !== STRING
208
- || !OID_RE.test(value.checkpoint) || typeof value.head !== STRING || !OID_RE.test(value.head)) reject(NAMES.binding);
209
- return value;
234
+ value = JSON.parse(brief.blocks[START].source);
210
235
  } catch (error) { reject(NAMES.binding, error.message); }
236
+ if (!hasClosedKeys(value, BINDING_KEYS) || value.schema !== SCHEMA || !isFileBinding(value.plan)
237
+ || !Array.isArray(value.reads) || !value.reads.every(isFileBinding) || typeof value.checkpoint !== STRING
238
+ || !OID_RE.test(value.checkpoint) || typeof value.head !== STRING || !OID_RE.test(value.head)) reject(NAMES.binding, `the block is not a schema ${SCHEMA} binding`);
239
+ return value;
211
240
  };
212
241
  const readHead = (context, name) => {
242
+ let oid;
213
243
  try {
214
- const oid = readGit(context, HEAD_ARGS);
215
- if (!OID_RE.test(oid)) reject(name);
216
- return oid;
244
+ oid = readGit(context, HEAD_ARGS);
217
245
  } catch (error) { reject(name, error.message); }
246
+ if (!OID_RE.test(oid)) reject(name, `HEAD resolves to ${oid}, not an object id`);
247
+ return oid;
218
248
  };
249
+ const stale = (cause) => reject(NAMES.stale, `${cause}; ${STALE_REMEDY}`);
219
250
  const bindFile = ({ path, sha256 }) => ({ path, sha256 });
220
251
  const checkDispatch = (context, dispatchPath, briefPath, digest) => {
221
252
  const file = readInside(context, dispatchPath, NAMES.dispatch);
222
253
  const parsed = parseDispatchContract(file.bytes.toString(UTF8));
223
- if (!parsed.ok || typeof parsed.contract.inputs !== STRING || !parsed.contract.inputs.includes(briefPath)
224
- || !parsed.contract.inputs.includes(`${SHA_PREFIX}${digest}`)) reject(NAMES.dispatch);
254
+ if (!parsed.ok) reject(NAMES.dispatch, `${dispatchPath}: ${parsed.reason}`);
255
+ const inputs = parsed.contract.inputs;
256
+ if (typeof inputs !== STRING || !inputs.includes(briefPath) || !inputs.includes(`${SHA_PREFIX}${digest}`)) reject(NAMES.dispatch, `${dispatchPath} inputs do not name ${briefPath} at ${SHA_PREFIX}${digest}`);
225
257
  };
226
258
 
227
259
  export const stampBrief = (cwd, briefPath, io = {}) => withRepository(cwd, io, (context) => {
@@ -246,31 +278,40 @@ export const checkBrief = (cwd, briefPath, { dispatchPath } = {}, io = {}) => wi
246
278
  const newest = newestCheckpoint(cwd, brief.planPath, context.io);
247
279
  if (!newest.ok) return newest;
248
280
  const reads = brief.reads.map((path) => bindFile(readInside(context, path)));
249
- if (binding.plan.path !== brief.planPath || binding.plan.sha256 !== plan.sha256) reject(NAMES.plan);
250
- if (binding.reads.length !== reads.length || reads.some((entry, index) =>
251
- entry.path !== binding.reads[index].path || entry.sha256 !== binding.reads[index].sha256)) reject(NAMES.read);
252
- if (binding.checkpoint !== newest.oid) reject(NAMES.checkpoint);
281
+ if (binding.plan.path !== brief.planPath || binding.plan.sha256 !== plan.sha256) reject(NAMES.plan, `${brief.planPath} changed since the stamp`);
282
+ if (binding.reads.length !== reads.length) reject(NAMES.read, `## Reads lists ${reads.length} files, the stamp bound ${binding.reads.length}`);
283
+ const moved = reads.find((entry, index) => entry.path !== binding.reads[index].path || entry.sha256 !== binding.reads[index].sha256);
284
+ if (moved !== undefined) reject(NAMES.read, `${moved.path} changed since the stamp`);
285
+ if (binding.checkpoint !== newest.oid) reject(NAMES.checkpoint, `the stamp bound checkpoint ${binding.checkpoint}, the newest is ${newest.oid}`);
253
286
  const verified = verifyCheckpoint(cwd, binding.checkpoint, context.io);
254
- if (!verified.ok) reject(NAMES.stale, verified.reason);
287
+ if (!verified.ok) return verified;
255
288
  if (!verified.clean) {
256
289
  const changed = runBaseDiff(context.top, { kind: CHECKPOINT_KIND, treeOid: binding.checkpoint }, CHANGED_PATH_ARGS, context.io);
257
- if (changed === null) reject(NAMES.stale, verified.reason);
290
+ if (changed === null) stale(`the tree differs from checkpoint ${binding.checkpoint}`);
258
291
  const paths = changed.toString(UTF8).split(NUL).filter(Boolean);
259
292
  const ledger = readDelegationLedger(context.cwd, context.env);
260
- if (ledger.state === READ_ERROR) reject(NAMES.stale, ledger.reason);
293
+ if (ledger.state === READ_ERROR) stale(ledger.reason);
261
294
  const claimed = new Set(ledger.state === READ_OK
262
295
  ? claimedPaths(ledger.records, binding.checkpoint, ledger.head, isThreadTerminalRecord) : []);
263
296
  const ownFiles = new Set(brief.files.map(({ path }) => path));
264
- if (!paths.every((path) => claimed.has(path) && !ownFiles.has(path))) reject(NAMES.stale, verified.reason);
297
+ const unclaimed = paths.filter((path) => !claimed.has(path) || ownFiles.has(path));
298
+ if (unclaimed.length > START) stale(`${unclaimed.join(', ')} changed since checkpoint ${binding.checkpoint}`);
265
299
  }
266
- if (binding.head !== readHead(context, NAMES.boundHead)) reject(NAMES.boundHead);
300
+ const head = readHead(context, NAMES.boundHead);
301
+ if (binding.head !== head) reject(NAMES.boundHead, `the stamp bound HEAD ${binding.head}, HEAD is now ${head}`);
267
302
  if (dispatchPath !== undefined) checkDispatch(context, dispatchPath, briefPath, file.sha256);
268
303
  return { ok: true, digest: file.sha256 };
269
304
  });
270
305
 
306
+ const nameReason = (reason) => {
307
+ const text = String(reason).replace(LINE_BREAKS, (byte) => ESCAPED_BREAKS[byte]);
308
+ const cut = text.indexOf(NAME_SEPARATOR);
309
+ const named = cut > START && MEMBERS.has(text.slice(START, cut)) && text.slice(cut + NAME_SEPARATOR.length).trim() !== EMPTY;
310
+ return named ? text : `${NAMES.environment}${NAME_SEPARATOR}${text}`;
311
+ };
271
312
  const formatResult = (result) => result.ok
272
313
  ? { code: ACCEPT, stdout: `${DIGEST_LINE} ${SHA_PREFIX}${result.digest}${NL}`, stderr: EMPTY }
273
- : { code: result.code, stdout: EMPTY, stderr: `${result.reason}${NL}` };
314
+ : { code: result.code, stdout: EMPTY, stderr: `${result.code === REFUSE ? nameReason(result.reason) : result.reason}${NL}` };
274
315
  export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
275
316
  try {
276
317
  const [verb, path, flag, dispatchPath] = argv;