@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 +62 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/authoring/EPIC_TEMPLATE.md +32 -0
- package/references/authoring/TASK_TEMPLATE.md +23 -0
- package/tools/epic-shape-ledger.mjs +5 -1
- package/tools/lens-region.mjs +15 -10
- package/tools/profile-gaps.mjs +2 -6
- package/tools/rules-regions.mjs +16 -0
- package/tools/task-brief.mjs +86 -45
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
|
+
version: '14.8.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "14.
|
|
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
|
-
|
|
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 });
|
package/tools/lens-region.mjs
CHANGED
|
@@ -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
|
|
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 ${
|
|
109
|
-
|
|
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 ${
|
|
122
|
-
|
|
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'
|
package/tools/profile-gaps.mjs
CHANGED
|
@@ -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
|
]);
|
package/tools/rules-regions.mjs
CHANGED
|
@@ -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
|
+
};
|
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;
|