@sabaiway/agent-workflow-kit 14.6.0 → 14.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 CHANGED
@@ -4,6 +4,68 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.8.0 — the kit ships an epic template and a task-brief template, each accepted by its checker once filled in (AD-154)
8
+
9
+ **A first epic and a first task brief can start from a file instead of from a checker's refusal.** Until 14.8.0 the
10
+ two shapes lived only in the planning reference and in what `epic-shape-cli` and `task-brief` refuse. The kit now
11
+ ships `references/authoring/EPIC_TEMPLATE.md` and `references/authoring/TASK_TEMPLATE.md` in its skill home. Copy one
12
+ from there into your project, replace every `{{KEY}}` placeholder with a value its checker admits and change no
13
+ other byte: the epic, placed at `docs/ai/epics/<EPIC_ID>.md`, passes `epic-shape-cli --check`; the brief, placed at
14
+ `docs/plans/TASK-<stem>-T<n>.md` beside a plan that carries the same Story line and the named row, with a checkpoint
15
+ minted, is stamped by `task-brief stamp` and passes `task-brief check`. A template copied and left unedited is
16
+ refused: the epic by the check, the brief by the stamp.
17
+ Story S2 of the epic `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`; contract `docs/ai/specs/kit/tier/tier-templates.md`
18
+ (new), with `docs/ai/specs/kit/tier/tier-walk.md` at revision 2.
19
+
20
+ - **Two templates, one placeholder form, a closed key list each.** The epic template carries eleven keys (`DATE`,
21
+ `EPIC_TITLE`, `INTENT`, `VALUE`, `NON_GOALS`, `ACCEPTANCE`, `SPECS`, `STORY_NAME`, `OWNED_PATHS`, `EPIC_ID`,
22
+ `QUEUE_BUCKET`) and one story row; the brief template carries thirteen keys and one task with one test file and one
23
+ module. Neither carries guidance prose: a template is the shape and its placeholders only. Where a grammar leaves a
24
+ choice, the template makes it: the epic opens `state: open` with `S1` planned, `depends-on: none` and
25
+ `shared: none`; the brief groups `test, impl` and reads the plan alone. For more stories, files or cases, repeat
26
+ the line in the form its checker takes: the next story id, and a `## Budget` line for each added file.
27
+ - **Not deployed into a project.** The directory sits outside `references/templates/`, so neither `bootstrap` nor
28
+ `upgrade` copies anything from it into `docs/ai/`; `init` places it in the kit's skill home only. Nothing in the
29
+ kit points at the templates yet; the guide that explains each key comes with a later story of the same epic.
30
+ - **Internals.** The repository walk fixture `test/tier-walk-e2e.test.mjs` now renders its epic and brief from the
31
+ installed templates, pins each template's bytes to a literal text, and checks that an unedited copy is refused; its
32
+ ground helpers move to `test/tier-walk-harness.test.mjs`. The package-content pin names both files, and the tarball
33
+ holds 333 files. Neither test file ships.
34
+
35
+ ## 14.7.0 — an epic closes in a project with no queue file, every task-brief refusal says what failed, and the missing-section offer is a command you can run (AD-153)
36
+
37
+ **A project outside this repo can now walk one epic, one story and one task to the end, solo.** A walk through the
38
+ three tiers on a fresh project, with no backend, stopped twice before 14.7.0: the epic close demanded a queue file the
39
+ project never had, and a malformed task brief was refused with `shape: ` and nothing after it. The offer to insert a
40
+ missing rules section printed `<kit>` and `<project>` placeholders, so it could not run as printed. Story S1 of the epic
41
+ `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`; contracts `docs/ai/specs/kit/tier-walk.md` (new),
42
+ `docs/ai/specs/kit/epic-shape/` (revision 2), `docs/ai/specs/kit/checkpoint/` (revision 5),
43
+ `docs/ai/specs/kit/rules-regions/` (revision 3).
44
+
45
+ - **`epic-shape-cli.mjs --close` accepts a project with no queue file.** When `docs/plans/queue.md` is proven absent,
46
+ no queue row can stand, so the close goes ahead once the other three conditions hold. Only a proven absence counts:
47
+ a symlink or a directory at that path, a failed read, or any other answer the read did not name refuses
48
+ `queue-read`, and the refusal names what the read returned, never "absent".
49
+ - **Every `task-brief` refusal is one line, `<name>: <detail>`, and the detail is never empty.** A `shape` refusal
50
+ names the grammar rule that failed and where: the offending line, or the heading, section, field or path that is
51
+ absent, foreign or repeated. `checkpoint-stale` names the cause and the remedy: mint a fresh checkpoint, then stamp
52
+ the brief again. A refusal from outside the tool's own names and the three the checkpoint hands up (`stem`,
53
+ `sequence`, `no-checkpoint`), such as git's `fatal: not a git repository` sentence, a thrown error or a failed
54
+ write, prints as `environment: <the whole reason>`. `environment` is the one new name; the other names, the exit
55
+ codes and the digest line are unchanged. A line break inside a detail is printed escaped, so the refusal stays on
56
+ one line.
57
+ - **The missing-section offer runs as printed.** When the upgrade reconcile (`tools/lens-region.mjs reconcile`) finds
58
+ no Communication or Story sessions section in `docs/ai/agent_rules.md`, its note now carries the real command:
59
+ `node`, the running kit's own `rules-insert.mjs` path, `--cwd` and your project root. A path with a space or a
60
+ shell-active byte is single-quoted, so the command pasted into a POSIX shell gets exactly those arguments; a plain
61
+ path prints unquoted. For a target that is not `docs/ai/agent_rules.md`, or a path holding a control byte or a
62
+ backtick, the reconcile prints the absent-section line alone, with no command. The gap entry
63
+ `story-sessions-section` builds its preview line with the same builder, so the two offers cannot drift apart.
64
+ - **Internals.** `tools/rules-regions.mjs` exports `buildInsertPreview`, the one builder of the insert preview line;
65
+ `tools/task-brief.mjs` exports its `NAMES` map. A new repository fixture, `test/tier-walk-e2e.test.mjs`, installs
66
+ the kit into a throwaway home and walks one epic, one story and one task solo, from a bare project and from a
67
+ seeded one, every tool spawned from the installed copy. It ships in no package.
68
+
7
69
  ## 14.6.0 — your rules file gains a Story sessions section, and the kit can now put a missing template section there for you (AD-152)
8
70
 
9
71
  **A section you were told to add by hand is now one command.** Up to 14.5.0 a project's
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.6.0'
6
+ version: '14.8.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.6.0",
6
+ "version": "14.8.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.6.0",
3
+ "version": "14.8.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,32 @@
1
+ ---
2
+ type: epic
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 60
8
+ state: open
9
+ ---
10
+
11
+ # Epic: {{EPIC_TITLE}}
12
+
13
+ ## Intent
14
+ {{INTENT}}
15
+
16
+ ## Value
17
+ {{VALUE}}
18
+
19
+ ## Non-goals
20
+ {{NON_GOALS}}
21
+
22
+ ## Acceptance
23
+ {{ACCEPTANCE}}
24
+
25
+ ## Specs
26
+ {{SPECS}}
27
+
28
+ ## Stories ledger
29
+ - S1 | {{STORY_NAME}} | depends-on: none | owns: {{OWNED_PATHS}} | shared: none | state: planned
30
+
31
+ ## Queue
32
+ Row {{EPIC_ID}} in {{QUEUE_BUCKET}}
@@ -0,0 +1,23 @@
1
+ # Task: {{TASK_NAME}}
2
+ Story: {{STORY_ID}} of {{EPIC_ID}}
3
+
4
+ ## Slice
5
+ Plan: {{PLAN_PATH}}
6
+ Row: {{ROW_ID}}
7
+ Grouping: test, impl
8
+ Files:
9
+ - {{TEST_PATH}} :: test
10
+ - {{MODULE_PATH}} :: impl
11
+
12
+ ## Reads
13
+ - {{PLAN_PATH}}
14
+
15
+ ## Acceptance
16
+ - {{ACCEPTANCE_COMMAND}} :: {{EXPECTED_OUTCOME}}
17
+
18
+ ## Negative cases
19
+ - {{NEGATIVE_CASE}} :: {{NEGATIVE_OUTCOME}}
20
+
21
+ ## Budget
22
+ - {{TEST_PATH}} :: {{TEST_MAX_LINES}}
23
+ - {{MODULE_PATH}} :: {{MODULE_MAX_LINES}}
@@ -9,6 +9,7 @@ const ERROR = 'error';
9
9
  const INFO = 'info';
10
10
  const OPEN = 'open';
11
11
  const LANDED = 'landed';
12
+ const ABSENT = 'absent';
12
13
  const EPIC = 'epic';
13
14
  const OWN = 'owns';
14
15
  const FRONT_DELIMITER = '---';
@@ -122,9 +123,12 @@ const carriesToken = (title, id) => {
122
123
  const escaped = id.replace(REGEX_BYTES, '\\$&');
123
124
  return new RegExp(`(?<!${TOKEN_EDGE})${escaped}(?!${TOKEN_EDGE})`, 'u').test(title);
124
125
  };
126
+ const describeQueue = (queue) => [queue?.outcome, queue?.reason].filter((part) => part !== undefined).join(': ')
127
+ || (queue === null ? 'null' : typeof queue);
125
128
  const checkQueueAbsence = (queue, path, id) => {
126
129
  if (typeof queue !== 'string') {
127
- return [makeFinding(path, FIRST_LINE, 'queue-read', `${queue?.outcome ?? 'absent'}: ${queue?.reason ?? 'no readable queue text'}`)];
130
+ if (queue?.outcome === ABSENT) return [];
131
+ return [makeFinding(path, FIRST_LINE, 'queue-read', describeQueue(queue))];
128
132
  }
129
133
  try {
130
134
  const { rows } = auditQueue(queue, { label: path });
@@ -2,7 +2,7 @@
2
2
  // The only mutation of a deployed rules file the composition root runs WITHOUT a consent ask.
3
3
  import { isDirectRun } from './direct-run.mjs';
4
4
  import { normalizeCanonical } from './orchestration-config.mjs';
5
- import { RULES_REGIONS, extractRegionBy, readTemplateSpan, frontmatterMaxLines, exceedsCap } from './rules-regions.mjs';
5
+ import { RULES_REGIONS, extractRegionBy, readTemplateSpan, frontmatterMaxLines, exceedsCap, buildInsertPreview } from './rules-regions.mjs';
6
6
 
7
7
  export { frontmatterMaxLines } from './rules-regions.mjs';
8
8
  const [COMMUNICATION, STORY, LENS] = RULES_REGIONS;
@@ -13,6 +13,7 @@ const LF = String.fromCharCode(10);
13
13
  const CR = String.fromCharCode(13);
14
14
  const CRLF = CR + LF;
15
15
  const BACKSLASH = String.fromCharCode(92);
16
+ const BACKTICK = String.fromCharCode(96);
16
17
  const EM_DASH = String.fromCharCode(8212);
17
18
  const EXIT_OK = 0;
18
19
  const EXIT_STOP = 1;
@@ -34,12 +35,14 @@ const NEUTRAL_COMMS_RE = /^### 2[.]x[.] Communication [(]user-facing messages[)]
34
35
  const NEUTRAL_STORY_RE = /^### 2[.]x[.] Story sessions/;
35
36
  const LINE_UNSAFE = new RegExp(`[${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]`, 'g');
36
37
  const HUMAN_UNSAFE = new RegExp(`[${BACKSLASH}u0000-${BACKSLASH}u001f${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]+`, 'g');
37
- const INSERT_NOTE = '[lens-region] note: preview the absent section with `node <kit>/tools/rules-insert.mjs --cwd <project>`; use `--apply` only after an explicit yes.';
38
+ const SERVED_TARGET = ['docs', 'ai', 'agent_rules.md'];
39
+ const insertNote = (command) => command ? [`[lens-region] note: preview the absent section with \`${command}\`; use \`--apply\` only after an explicit yes.`] : [];
38
40
  const OUTCOME_PREFIXES = Object.freeze({ communication: 'comms', 'story-sessions': 'story', lens: 'lens' });
39
41
  const stripCr = (line) => (line.endsWith(CR) ? line.slice(0, -1) : line);
40
42
  const escUnsafe = (character) => `${BACKSLASH}u${character.codePointAt(0).toString(HEX_RADIX).padStart(UNICODE_WIDTH, '0')}`;
41
43
  const errorDetail = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
42
44
  const oneLine = (value) => String(value).replace(HUMAN_UNSAFE, ' ');
45
+ const spanFree = (value) => oneLine(value).replaceAll(BACKTICK, escUnsafe(BACKTICK));
43
46
 
44
47
  export const parseLensPriors = (text) => {
45
48
  const entries = [];
@@ -104,9 +107,9 @@ const RECONCILERS = Object.freeze({ communication: reconcileCommsText, 'story-se
104
107
  export const OUTCOME_LINES = Object.freeze({
105
108
  errorDetail,
106
109
  targetAbsent: (target) => `[lens-region] ${oneLine(target)} is absent ${EM_DASH} skipped (nothing to update; the file is seeded at bootstrap).`,
107
- commsNoRegion: (target) => [
108
- `[lens-region] no "${COMMUNICATION.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
109
- INSERT_NOTE,
110
+ commsNoRegion: (target, command) => [
111
+ `[lens-region] no "${COMMUNICATION.label}" section in ${spanFree(target)} ${EM_DASH} left untouched.`,
112
+ ...insertNote(command),
110
113
  ],
111
114
  commsCurrent: () => `[lens-region] Communication section already current ${EM_DASH} nothing to do (zero-diff).`,
112
115
  commsCustom: () => [
@@ -117,9 +120,9 @@ export const OUTCOME_LINES = Object.freeze({
117
120
  commsCapRefused: (target, count, cap) => `[lens-region] refused ${EM_DASH} refreshing the Communication section would push ${oneLine(target)} to ${count} lines (cap ${cap}); trim the file and re-run. The Communication section was not changed.`,
118
121
  commsRefreshed: () => '[lens-region] refreshed the Communication section to the current canon.',
119
122
  templateCanonStop: () => `[lens-region] STOP ${EM_DASH} the kit's bundled agent_rules.md template canon is unreadable; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`,
120
- storyNoRegion: (target) => [
121
- `[lens-region] no "${STORY.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
122
- INSERT_NOTE,
123
+ storyNoRegion: (target, command) => [
124
+ `[lens-region] no "${STORY.label}" section in ${spanFree(target)} ${EM_DASH} left untouched.`,
125
+ ...insertNote(command),
123
126
  ],
124
127
  storyCurrent: () => `[lens-region] Story sessions section already current ${EM_DASH} nothing to do (zero-diff).`,
125
128
  storyCustom: () => [
@@ -187,9 +190,11 @@ export const runCli = async (argv, deps = {}) => {
187
190
  return EXIT_USAGE;
188
191
  }
189
192
  const fs = deps.fs ?? (await import('node:fs/promises'));
190
- const { dirname, basename, join, resolve } = await import('node:path');
193
+ const { dirname, basename, join, resolve, sep } = await import('node:path');
191
194
  const { fileURLToPath } = await import('node:url');
192
195
  const targetPath = resolve(argv[1]);
196
+ const served = targetPath.endsWith(sep + SERVED_TARGET.join(sep));
197
+ const command = served ? buildInsertPreview(resolve(targetPath, '..', '..', '..')) : '';
193
198
  const text = await (async () => {
194
199
  try {
195
200
  return await fs.readFile(targetPath, UTF8);
@@ -238,7 +243,7 @@ export const runCli = async (argv, deps = {}) => {
238
243
  return state;
239
244
  }
240
245
  if (!region.found) {
241
- for (const line of OUTCOME_LINES[`${prefix}NoRegion`](argv[1])) log(line);
246
+ for (const line of OUTCOME_LINES[`${prefix}NoRegion`](argv[1], command)) log(line);
242
247
  return state;
243
248
  }
244
249
  const canon = descriptor.canon === 'template'
@@ -7,6 +7,7 @@ import {
7
7
  planInsert,
8
8
  frontmatterMaxLines,
9
9
  decodeUtf8Strict,
10
+ buildInsertPreview,
10
11
  } from './rules-regions.mjs';
11
12
 
12
13
  const GAP_ID = 'story-sessions-section';
@@ -17,9 +18,6 @@ const MISSING_FILE_CODE = 'ENOENT';
17
18
  const SINGLE_HEADING = 1;
18
19
  const TARGET_PATH = 'docs/ai/agent_rules.md';
19
20
  const TEMPLATE_PATH = fileURLToPath(new URL('../references/templates/agent_rules.md', import.meta.url));
20
- const INSERT_PATH = fileURLToPath(new URL('./rules-insert.mjs', import.meta.url));
21
- const COMMAND_PREFIX = 'node ';
22
- const CWD_FLAG = ' --cwd ';
23
21
  const VERDICTS = Object.freeze({
24
22
  present: 'present',
25
23
  absent: 'absent',
@@ -83,8 +81,6 @@ const detectStorySessions = ({ root, deps }) => {
83
81
  return { verdict: VERDICTS.absent };
84
82
  };
85
83
 
86
- const buildInsertPreview = (root) => COMMAND_PREFIX + INSERT_PATH + CWD_FLAG + root;
87
-
88
84
  export const PROFILE_GAPS = Object.freeze([
89
- Object.freeze({ id: GAP_ID, detect: detectStorySessions, apply: buildInsertPreview }),
85
+ Object.freeze({ id: GAP_ID, detect: detectStorySessions, apply: (root) => buildInsertPreview(root) }),
90
86
  ]);
@@ -1,3 +1,5 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
1
3
  const LF = String.fromCharCode(10);
2
4
  const CR = String.fromCharCode(13);
3
5
  const CRLF = CR + LF;
@@ -18,6 +20,11 @@ const UTF8_LABEL = 'utf-8';
18
20
  const UTF8_ENCODING = 'utf8';
19
21
  const ROUND_TRIP_MESSAGE = 'the bytes read do not survive a UTF-8 round trip';
20
22
  const BOM_CHAR = String.fromCharCode(65279);
23
+ const INSERT_PATH = fileURLToPath(new URL('./rules-insert.mjs', import.meta.url));
24
+ const COMMAND_UNSAFE_RE = /[\u0000-\u001f\u007f`]/;
25
+ const SHELL_PLAIN_RE = /^[A-Za-z0-9_./-]+$/;
26
+ const SINGLE_QUOTE = "'";
27
+ const QUOTE_ESCAPE = "'\\''";
21
28
 
22
29
  // Append the OUTGOING canon here in the same release that changes the template
23
30
  // §2.5 region — the fragment-or-prior reconcile depends on it.
@@ -165,3 +172,12 @@ export const planInsert = ({ text, spans, cap }) => {
165
172
  if (decision.over) return { refusal: CAP_REFUSAL, count: decision.count, cap };
166
173
  return { planned, text: resultText };
167
174
  };
175
+
176
+ const shellWord = (path) => SHELL_PLAIN_RE.test(path)
177
+ ? path
178
+ : SINGLE_QUOTE + path.split(SINGLE_QUOTE).join(QUOTE_ESCAPE) + SINGLE_QUOTE;
179
+
180
+ export const buildInsertPreview = (root, insertPath = INSERT_PATH) => {
181
+ if ([root, insertPath].some((path) => COMMAND_UNSAFE_RE.test(path))) return '';
182
+ return 'node ' + shellWord(insertPath) + ' --cwd ' + shellWord(root);
183
+ };
@@ -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;