@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.
- package/CHANGELOG.md +74 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/upgrade.md +11 -7
- package/references/shared/composition-handoff.md +6 -4
- package/references/templates/agent_rules.md +3 -0
- package/tools/delegation.mjs +2 -2
- package/tools/epic-shape-ledger.mjs +5 -1
- package/tools/lens-region.mjs +193 -310
- package/tools/profile-gaps.mjs +86 -0
- package/tools/rules-insert.mjs +203 -0
- package/tools/rules-regions.mjs +183 -0
- package/tools/task-brief.mjs +86 -45
package/tools/task-brief.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
224
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
if (
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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;
|