@sabaiway/agent-workflow-kit 11.0.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +9 -8
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/launchers/windsurf-workflow.md +2 -2
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +5 -5
- package/references/modes/procedures.md +4 -1
- package/references/modes/recipes.md +1 -1
- package/references/modes/recommendations.md +2 -1
- package/references/modes/set-recipe.md +2 -1
- package/references/modes/status.md +1 -1
- package/references/scripts/archiver-structure.test.mjs +48 -3
- package/references/scripts/install-git-hooks.test.mjs +31 -1
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/agent_rules.md +1 -1
- package/references/templates/changelog.md +1 -1
- package/references/templates/decisions.md +2 -2
- package/references/templates/orchestration.json +1 -1
- package/tools/carriers.mjs +1 -0
- package/tools/feedback-record-cli.mjs +300 -0
- package/tools/feedback-record.mjs +243 -0
- package/tools/inject-methodology.mjs +2 -0
- package/tools/orchestration-readme.mjs +12 -2
- package/tools/procedures.mjs +43 -8
- package/tools/recommendations.mjs +53 -3
|
@@ -13,7 +13,7 @@ maxLines: 500
|
|
|
13
13
|
> consequences, newest at the bottom. Link related ADRs with `[[AD-XXX]]`; retrieval is by the
|
|
14
14
|
> `AD-NNN` id (filename), grep over the flat store, or the `[[AD-NNN]]` supersession chain.
|
|
15
15
|
|
|
16
|
-
> **Archive:** older ADRs are stored one immutable file per record under [`adr/`](./adr/) — see the active-set navigator [`adr/log.md`](./adr/log.md). `archive-decisions.mjs` explodes the oldest entries beyond this window automatically; no cap is ever raised and there is no monolithic ledger.
|
|
16
|
+
> **Archive:** older ADRs are stored one immutable file per record under [`adr/`](./adr/) — see the active-set navigator [`adr/log.md`](./adr/log.md). `archive-decisions.mjs` explodes the oldest entries beyond this window automatically where the enforcement scripts are deployed (installed on a yes); no cap is ever raised and there is no monolithic ledger.
|
|
17
17
|
|
|
18
18
|
## AD-001 — Adopt AI-agent memory system (`docs/ai/`)
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ maxLines: 500
|
|
|
22
22
|
|
|
23
23
|
**Context.** Multi-session AI work loses context between runs. Without a structured handover, each new session re-reads code, re-discovers decisions, and repeats past mistakes.
|
|
24
24
|
|
|
25
|
-
**Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + a one-file-per-ADR archive via
|
|
25
|
+
**Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + a one-file-per-ADR archive via the deployed scripts + pre-commit hook (offered on every project, installed on a yes). Deployed via the `agent-workflow-kit` skill.
|
|
26
26
|
|
|
27
27
|
**Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + one-file-per-ADR archival keep every record scannable as the decision history grows without bound.
|
|
28
28
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_README": "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option never goes away.
|
|
2
|
+
"_README": "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option never goes away. Four activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review), 'routine' (slots carrier, parallel) and 'feedback-triage' (slot review). A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form; an 'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts on | off and decides whether file-disjoint subagent slices dispatch concurrently. Every slot seeded below is 'solo' — no execution backend required. A slot the seed leaves silent takes the computed default stated further down: 'feedback-triage.review' is reviewed as soon as a review backend is ready (solo until then); 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' stay 'solo', and 'routine.parallel' stays 'on'. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review backend is ready and otherwise solo for a review slot, solo for author, fold, execute and carrier, on for parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment. Strict JSON — no comments.",
|
|
3
3
|
"plan-authoring": {
|
|
4
4
|
"review": "solo"
|
|
5
5
|
},
|
package/tools/carriers.mjs
CHANGED
|
@@ -15,6 +15,7 @@ export const ACTIVITIES = {
|
|
|
15
15
|
'plan-authoring': { slots: { author: 'carrier', fold: 'carrier', review: 'review' }, policy: true },
|
|
16
16
|
'plan-execution': { slots: { execute: 'execute', review: 'review' }, policy: true },
|
|
17
17
|
routine: { slots: { carrier: 'carrier', parallel: 'switch' }, policy: false },
|
|
18
|
+
'feedback-triage': { slots: { review: 'review' }, policy: false },
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const POLICY_ACTIVITIES = Object.fromEntries(
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Feedback-record filesystem and argv shell. Governing contract:
|
|
3
|
+
// docs/ai/specs/kit/feedback-triage.md.
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
8
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
9
|
+
import { readFileBytesNoFollowCapped } from './fs-read-nofollow.mjs';
|
|
10
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, stripGitLocationEnv, withGitPath } from './git-env.mjs';
|
|
11
|
+
import { assertScratchDestination, DEFAULT_MAX_PROMPT_BYTES, trimToBudget } from './grounding.mjs';
|
|
12
|
+
import { judgeAnchors, parseRecord, ratchetLine, REFUSALS, renderExcerpts, renderRows } from './feedback-record.mjs';
|
|
13
|
+
|
|
14
|
+
const RECORD_MAX_BYTES = 1024 * 1024;
|
|
15
|
+
export const EXCERPTS_FRAMING_RESERVE_BYTES = 4096;
|
|
16
|
+
const GATES_REL = 'docs/ai/gates.json';
|
|
17
|
+
const QUEUE_GATE_TOKEN = 'queue-audit-cli.mjs';
|
|
18
|
+
const MODE_FLAGS = Object.freeze(['--check', '--rows']);
|
|
19
|
+
const FATAL_PARSE_REFUSALS = Object.freeze(REFUSALS.slice(0, 4));
|
|
20
|
+
const FINDING_NAMES = Object.freeze({
|
|
21
|
+
anchorPath: 'anchor-path', anchorDirty: 'anchor-dirty', gitLocation: 'git-location',
|
|
22
|
+
headMismatch: 'head-mismatch', excerptsDestination: 'excerpts-destination',
|
|
23
|
+
excerptsBudget: 'excerpts-budget', excerptsWrite: 'excerpts-write', exists: 'EEXIST',
|
|
24
|
+
});
|
|
25
|
+
const GIT_ENV_LOCALE = 'C';
|
|
26
|
+
const GIT_OPTIONAL_LOCKS_DISABLED = '0';
|
|
27
|
+
const EXCLUSIVE_CREATE_FLAG = 'wx';
|
|
28
|
+
const EMPTY_ANSWER_OK = new Set(['status', 'ls-files']);
|
|
29
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
30
|
+
|
|
31
|
+
const readValue = (argv, index, flag) => {
|
|
32
|
+
const value = argv[index + 1];
|
|
33
|
+
if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const parseArgv = (argv) => {
|
|
38
|
+
const options = { mode: null, record: null, excerpts: null };
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const [index, arg] of argv.entries()) {
|
|
41
|
+
if (seen.has(index)) continue;
|
|
42
|
+
if (MODE_FLAGS.includes(arg)) {
|
|
43
|
+
if (options.mode !== null) throw fail(2, 'name exactly one of --check or --rows');
|
|
44
|
+
options.mode = arg.slice(2);
|
|
45
|
+
options.record = readValue(argv, index, arg);
|
|
46
|
+
seen.add(index + 1);
|
|
47
|
+
} else if (arg === '--excerpts') {
|
|
48
|
+
if (options.excerpts !== null) throw fail(2, '--excerpts was given twice');
|
|
49
|
+
options.excerpts = readValue(argv, index, arg);
|
|
50
|
+
seen.add(index + 1);
|
|
51
|
+
} else throw fail(2, `unknown argument: ${arg}`);
|
|
52
|
+
}
|
|
53
|
+
if (options.mode === null) throw fail(2, 'one of --check or --rows is required');
|
|
54
|
+
if (options.excerpts !== null && options.mode !== 'check') throw fail(2, '--excerpts is available only with --check');
|
|
55
|
+
return options;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const decodeBytes = (bytes) => {
|
|
59
|
+
try { return FATAL_UTF8.decode(bytes); }
|
|
60
|
+
catch { return null; }
|
|
61
|
+
};
|
|
62
|
+
const countLines = (text) => text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
63
|
+
const getHeadLine = (text) => Math.max(1, text.split('\n').findIndex((line) => line.replace(/\r$/u, '').startsWith('Head: ')) + 1);
|
|
64
|
+
const formatFinding = (record, finding) =>
|
|
65
|
+
`${String(record).replace(/[\r\n]+/gu, ' ')}:${finding.line}: ${finding.name}: ${String(finding.message).replace(/[\r\n]+/gu, ' ')}`;
|
|
66
|
+
const reportFindings = (record, findings, error) => {
|
|
67
|
+
for (const finding of findings) error(formatFinding(record, finding));
|
|
68
|
+
return findings.length === 0 ? 0 : 1;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const readRecord = (recordPath, io) => {
|
|
72
|
+
const result = readFileBytesNoFollowCapped(recordPath, RECORD_MAX_BYTES, io);
|
|
73
|
+
if (result.outcome !== 'ok') {
|
|
74
|
+
const reason = result.outcome === 'over-cap' ? `over the ${RECORD_MAX_BYTES}-byte cap` : result.code ?? result.className ?? result.outcome;
|
|
75
|
+
throw fail(2, `cannot read regular feedback record ${recordPath}: ${reason}`);
|
|
76
|
+
}
|
|
77
|
+
const text = decodeBytes(result.bytes);
|
|
78
|
+
if (text === null) throw fail(2, `cannot read regular feedback record ${recordPath}: invalid UTF-8`);
|
|
79
|
+
return { bytes: result.bytes, text };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const isContained = (root, target) => {
|
|
83
|
+
const rel = relative(root, target);
|
|
84
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
85
|
+
};
|
|
86
|
+
const inspectAnchors = (claims, cwd, io) => {
|
|
87
|
+
const anchors = claims.flatMap((claim) => claim.anchors);
|
|
88
|
+
const paths = [...new Set(anchors.map((anchor) => anchor.path))];
|
|
89
|
+
const facts = Object.create(null);
|
|
90
|
+
const texts = Object.create(null);
|
|
91
|
+
const targets = Object.create(null);
|
|
92
|
+
const findings = [];
|
|
93
|
+
const resolvedRoot = (() => {
|
|
94
|
+
try { return { root: realpathSync(cwd), reason: null }; }
|
|
95
|
+
catch (err) { return { root: null, reason: err?.code ?? err?.message ?? String(err) }; }
|
|
96
|
+
})();
|
|
97
|
+
if (paths.length === 0) return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
|
|
98
|
+
if (resolvedRoot.root === null) {
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
facts[path] = { kind: 'regular', lines: Number.MAX_SAFE_INTEGER };
|
|
101
|
+
for (const anchor of anchors.filter((candidate) => candidate.path === path)) {
|
|
102
|
+
findings.push({ name: FINDING_NAMES.anchorPath, line: anchor.line, path, message: `the working directory cannot be resolved (${resolvedRoot.reason})` });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
|
|
106
|
+
}
|
|
107
|
+
for (const path of paths) {
|
|
108
|
+
const occurrences = anchors.filter((anchor) => anchor.path === path);
|
|
109
|
+
const full = resolve(cwd, path);
|
|
110
|
+
const parent = (() => {
|
|
111
|
+
try { return realpathSync(dirname(full)); }
|
|
112
|
+
catch { return null; }
|
|
113
|
+
})();
|
|
114
|
+
if (parent === null) {
|
|
115
|
+
facts[path] = { kind: 'absent' };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const target = join(parent, basename(full));
|
|
119
|
+
if (!isContained(resolvedRoot.root, target)) {
|
|
120
|
+
facts[path] = { kind: 'regular', lines: Number.MAX_SAFE_INTEGER };
|
|
121
|
+
for (const anchor of occurrences) findings.push({ name: FINDING_NAMES.anchorPath, line: anchor.line, path, message: `anchor resolves outside the working tree: ${path}` });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const result = readFileBytesNoFollowCapped(target, RECORD_MAX_BYTES, io);
|
|
125
|
+
if (result.outcome === 'absent' || result.outcome === 'foreign') {
|
|
126
|
+
facts[path] = { kind: result.outcome === 'absent' ? 'absent' : 'other' };
|
|
127
|
+
} else if (result.outcome === 'over-cap' || result.outcome === 'error') {
|
|
128
|
+
const reason = result.outcome === 'over-cap' ? `over the ${RECORD_MAX_BYTES}-byte cap` : result.code ?? 'read failed';
|
|
129
|
+
facts[path] = { kind: 'unreadable', reason };
|
|
130
|
+
} else {
|
|
131
|
+
const text = decodeBytes(result.bytes);
|
|
132
|
+
if (text === null) facts[path] = { kind: 'unreadable', reason: 'invalid UTF-8' };
|
|
133
|
+
else {
|
|
134
|
+
facts[path] = { kind: 'regular', lines: countLines(text) };
|
|
135
|
+
texts[path] = text;
|
|
136
|
+
targets[path] = target;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const runGit = (spawn, cwd, env, args) => {
|
|
144
|
+
const options = {
|
|
145
|
+
cwd, env: withGitPath({
|
|
146
|
+
...stripGitLocationEnv(env), LC_ALL: GIT_ENV_LOCALE, GIT_OPTIONAL_LOCKS: GIT_OPTIONAL_LOCKS_DISABLED,
|
|
147
|
+
}),
|
|
148
|
+
encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
149
|
+
};
|
|
150
|
+
const attempted = (() => {
|
|
151
|
+
try { return { result: spawn('git', args, options), error: null }; }
|
|
152
|
+
catch (err) { return { result: null, error: `git threw synchronously (${err?.message ?? err})` }; }
|
|
153
|
+
})();
|
|
154
|
+
if (attempted.error !== null) return { error: attempted.error };
|
|
155
|
+
const result = attempted.result;
|
|
156
|
+
if (result == null || typeof result !== 'object') return { error: 'the git runner returned no result' };
|
|
157
|
+
if (result.error) return { error: `git could not run (${result.error.code ?? result.error.message ?? result.error})` };
|
|
158
|
+
if (result.signal) return { error: `git ${args[0]} was killed by ${result.signal}` };
|
|
159
|
+
const stderr = String(result.stderr ?? '').trim();
|
|
160
|
+
if (result.status !== 0) return { error: `git ${args[0]} exited ${result.status}${stderr ? ` (${stderr})` : ''}` };
|
|
161
|
+
const stdout = String(result.stdout ?? '');
|
|
162
|
+
if (stdout.length === 0 && !EMPTY_ANSWER_OK.has(args[0])) return { error: `git ${args[0]} answered nothing` };
|
|
163
|
+
return { stdout };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const parseDirtyPaths = (stdout) => stdout.split('\0').filter(Boolean).map((entry) => entry.slice(3));
|
|
167
|
+
|
|
168
|
+
const inspectGit = ({ cwd, env, spawn, head, headLine, anchors }) => {
|
|
169
|
+
const findings = [];
|
|
170
|
+
const location = resolveGitLocation(cwd, { spawn, env });
|
|
171
|
+
if (location.state !== 'work-tree') {
|
|
172
|
+
return { findings: [{ name: FINDING_NAMES.gitLocation, line: headLine, message: `${location.state}: "${location.cause ?? 'unknown cause'}"` }], top: null };
|
|
173
|
+
}
|
|
174
|
+
const checkout = runGit(spawn, cwd, env, ['rev-parse', 'HEAD']);
|
|
175
|
+
if (checkout.error) return { findings: [{ name: FINDING_NAMES.gitLocation, line: headLine, message: checkout.error }], top: null };
|
|
176
|
+
const actualHead = checkout.stdout.trim();
|
|
177
|
+
if (actualHead === '') findings.push({ name: FINDING_NAMES.gitLocation, line: headLine, message: 'git rev-parse HEAD answered nothing' });
|
|
178
|
+
else if (actualHead.toLowerCase() !== head.toLowerCase()) findings.push({ name: FINDING_NAMES.headMismatch, line: headLine, message: `record head ${head} does not match checkout head ${actualHead}` });
|
|
179
|
+
const resolvedTop = (() => {
|
|
180
|
+
try { return { value: realpathSync(location.top), error: null }; }
|
|
181
|
+
catch (err) { return { value: null, error: err?.code ?? err?.message ?? String(err) }; }
|
|
182
|
+
})();
|
|
183
|
+
if (resolvedTop.error !== null) {
|
|
184
|
+
return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `the work-tree top cannot be resolved (${resolvedTop.error})` }], top: location.top };
|
|
185
|
+
}
|
|
186
|
+
const top = resolvedTop.value;
|
|
187
|
+
const rels = Object.fromEntries(Object.keys(anchors.targets).map((path) => [
|
|
188
|
+
path, relative(top, resolve(anchors.root, path)).split(sep).join('/'),
|
|
189
|
+
]));
|
|
190
|
+
if (Object.keys(rels).length === 0) return { findings, top: location.top };
|
|
191
|
+
const pathspecs = Object.values(rels).map((rel) => `:(literal)${rel}`);
|
|
192
|
+
const status = runGit(spawn, location.top, env, ['status', '--porcelain', '-z', '--untracked-files=all', '--ignored=traditional', '--', ...pathspecs]);
|
|
193
|
+
if (status.error) return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `${FINDING_NAMES.anchorDirty} cannot be judged: ${status.error}` }], top: location.top };
|
|
194
|
+
const dirty = new Set(parseDirtyPaths(status.stdout));
|
|
195
|
+
const listing = runGit(spawn, location.top, env, ['ls-files', '-v', '-z', '--', ...pathspecs]);
|
|
196
|
+
if (listing.error) return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `${FINDING_NAMES.anchorDirty} cannot be judged: ${listing.error}` }], top: location.top };
|
|
197
|
+
const listed = new Map();
|
|
198
|
+
for (const entry of listing.stdout.split('\0').filter(Boolean)) {
|
|
199
|
+
listed.set(entry.slice(2), entry[0]);
|
|
200
|
+
}
|
|
201
|
+
for (const path of Object.keys(rels)) {
|
|
202
|
+
const rel = rels[path];
|
|
203
|
+
const tag = listed.get(rel);
|
|
204
|
+
const message = tag === undefined
|
|
205
|
+
? `anchor is not tracked at the stamped HEAD: ${path}`
|
|
206
|
+
: tag !== 'H'
|
|
207
|
+
? `the index conceals the working tree for ${path} (ls-files tag ${tag})`
|
|
208
|
+
: dirty.has(rel)
|
|
209
|
+
? `anchor differs from the stamped HEAD: ${path}`
|
|
210
|
+
: null;
|
|
211
|
+
if (message === null) continue;
|
|
212
|
+
for (const anchor of anchors.anchors.filter((candidate) => candidate.path === path)) {
|
|
213
|
+
findings.push({ name: FINDING_NAMES.anchorDirty, line: anchor.line, path, message });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { findings, top: location.top };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const readRatchet = (cwd, rows) => {
|
|
220
|
+
const path = join(cwd, GATES_REL);
|
|
221
|
+
const parsed = (() => {
|
|
222
|
+
try { return { value: JSON.parse(readFileSync(path, 'utf8')), error: null }; }
|
|
223
|
+
catch (err) { return { value: null, error: err }; }
|
|
224
|
+
})();
|
|
225
|
+
if (parsed.error?.code === 'ENOENT') return `ratchet: ${GATES_REL} is absent`;
|
|
226
|
+
if (parsed.error) return `ratchet: ${GATES_REL} cannot be read (${parsed.error.message})`;
|
|
227
|
+
const gate = Array.isArray(parsed.value?.gates)
|
|
228
|
+
? parsed.value.gates.find((entry) => typeof entry?.cmd === 'string' && entry.cmd.includes(QUEUE_GATE_TOKEN))
|
|
229
|
+
: null;
|
|
230
|
+
return gate ? ratchetLine(gate.cmd, rows) : `ratchet: gate carrying ${QUEUE_GATE_TOKEN} is absent`;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const writeExcerpts = ({ options, cwd, recordBytes, anchors, error }) => {
|
|
234
|
+
const destination = (() => {
|
|
235
|
+
try { return { value: assertScratchDestination(options.excerpts, cwd), error: null }; }
|
|
236
|
+
catch (err) { return { value: null, error: err }; }
|
|
237
|
+
})();
|
|
238
|
+
if (destination.error) {
|
|
239
|
+
error(`${FINDING_NAMES.excerptsDestination}: ${String(destination.error.message ?? destination.error).replace(/^--out/u, '--excerpts')}`);
|
|
240
|
+
return 2;
|
|
241
|
+
}
|
|
242
|
+
const budget = DEFAULT_MAX_PROMPT_BYTES - recordBytes - EXCERPTS_FRAMING_RESERVE_BYTES;
|
|
243
|
+
if (budget <= 0) return reportFindings(options.record, [{ name: FINDING_NAMES.excerptsBudget, line: 1, message: 'the record leaves no byte budget for excerpts' }], error);
|
|
244
|
+
const payload = renderExcerpts(anchors.anchors, anchors.texts, budget);
|
|
245
|
+
const trimmed = trimToBudget(payload, budget);
|
|
246
|
+
if (trimmed.trimmedBytes > 0 && !/^[^\n]/u.test(trimmed.text)) {
|
|
247
|
+
return reportFindings(options.record, [{ name: FINDING_NAMES.excerptsBudget, line: 1, message: `the ${budget}-byte budget leaves no excerpt line beside the trim marker` }], error);
|
|
248
|
+
}
|
|
249
|
+
const written = (() => {
|
|
250
|
+
try { writeFileSync(destination.value.path, trimmed.text, { flag: EXCLUSIVE_CREATE_FLAG }); return null; }
|
|
251
|
+
catch (err) { return err; }
|
|
252
|
+
})();
|
|
253
|
+
if (written === null) return 0;
|
|
254
|
+
const name = written.code === FINDING_NAMES.exists ? FINDING_NAMES.exists : FINDING_NAMES.excerptsWrite;
|
|
255
|
+
return reportFindings(options.record, [{ name, line: 1, message: written.message ?? String(written) }], error);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const main = (argv, deps = {}) => {
|
|
259
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
260
|
+
const env = deps.env ?? process.env;
|
|
261
|
+
const spawn = deps.spawn ?? spawnSync;
|
|
262
|
+
const log = deps.log ?? console.log;
|
|
263
|
+
const error = deps.error ?? console.error;
|
|
264
|
+
const io = deps.io ?? {};
|
|
265
|
+
const now = deps.now ?? (() => new Date());
|
|
266
|
+
const options = (() => {
|
|
267
|
+
try { return { value: parseArgv(argv), error: null }; }
|
|
268
|
+
catch (err) { return { value: null, error: err }; }
|
|
269
|
+
})();
|
|
270
|
+
if (options.error) { error(options.error.message); return options.error.exitCode ?? 2; }
|
|
271
|
+
const loaded = (() => {
|
|
272
|
+
try { return { value: readRecord(resolve(cwd, options.value.record), io), error: null }; }
|
|
273
|
+
catch (err) { return { value: null, error: err }; }
|
|
274
|
+
})();
|
|
275
|
+
if (loaded.error) { error(loaded.error.message); return loaded.error.exitCode ?? 2; }
|
|
276
|
+
const parsed = parseRecord(loaded.value.text);
|
|
277
|
+
if (parsed.refusals.some((finding) => FATAL_PARSE_REFUSALS.includes(finding.name))) {
|
|
278
|
+
return reportFindings(options.value.record, parsed.refusals, error);
|
|
279
|
+
}
|
|
280
|
+
const anchors = inspectAnchors(parsed.claims, cwd, io);
|
|
281
|
+
const git = inspectGit({ cwd, env, spawn, head: parsed.head, headLine: getHeadLine(loaded.value.text), anchors });
|
|
282
|
+
const findings = [
|
|
283
|
+
...parsed.refusals,
|
|
284
|
+
...anchors.findings,
|
|
285
|
+
...judgeAnchors(parsed.claims, anchors.facts),
|
|
286
|
+
...git.findings,
|
|
287
|
+
];
|
|
288
|
+
if (reportFindings(options.value.record, findings, error) !== 0) return 1;
|
|
289
|
+
if (options.value.mode === 'rows') {
|
|
290
|
+
const rows = renderRows(parsed, { date: now().toISOString().slice(0, 10), recordPath: options.value.record });
|
|
291
|
+
for (const row of rows) log(row);
|
|
292
|
+
log(readRatchet(git.top, rows));
|
|
293
|
+
}
|
|
294
|
+
if (options.value.excerpts !== null) {
|
|
295
|
+
return writeExcerpts({ options: options.value, cwd, recordBytes: loaded.value.bytes.length, anchors, error });
|
|
296
|
+
}
|
|
297
|
+
return 0;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Feedback-record grammar and pure judgments. Governing contract:
|
|
2
|
+
// docs/ai/specs/kit/feedback-triage.md.
|
|
3
|
+
|
|
4
|
+
const REFUSAL_NAMES = Object.freeze({
|
|
5
|
+
title: 'title', source: 'source', head: 'head', table: 'table', rowCells: 'row-cells',
|
|
6
|
+
claimId: 'claim-id', anchorGrammar: 'anchor-grammar', anchorPath: 'anchor-path',
|
|
7
|
+
verdict: 'verdict', disposition: 'disposition', anchorAbsent: 'anchor-absent',
|
|
8
|
+
anchorUnreadable: 'anchor-unreadable', anchorLine: 'anchor-line',
|
|
9
|
+
});
|
|
10
|
+
export const VERDICTS = Object.freeze(['confirmed', 'corrected', 'refuted', 'works-as-designed']);
|
|
11
|
+
export const REFUSALS = Object.freeze(Object.values(REFUSAL_NAMES));
|
|
12
|
+
|
|
13
|
+
const TITLE_PREFIX = '# Feedback: ';
|
|
14
|
+
const SOURCE_PREFIX = 'Source: ';
|
|
15
|
+
const HEAD_PREFIX = 'Head: ';
|
|
16
|
+
const CLAIMS_HEADING = '## Claims';
|
|
17
|
+
const NOTES_HEADING = '## Notes';
|
|
18
|
+
const TABLE_HEADER = '| # | Claim | Evidence | Verdict | Disposition |';
|
|
19
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
|
|
20
|
+
const ANCHOR = /^`([^`,\s]+):([1-9][0-9]*)(?:-([1-9][0-9]*))?`$/u;
|
|
21
|
+
const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
|
|
22
|
+
const QUEUE = new RegExp(`^queue (${ROW_ID})$`, 'u');
|
|
23
|
+
const ALREADY_QUEUED = new RegExp(`^already-queued (${ROW_ID})$`, 'u');
|
|
24
|
+
const REASONED_DISPOSITION = /^(?:declined|folded): (.+)$/u;
|
|
25
|
+
const LINE_JOINER = '\n';
|
|
26
|
+
|
|
27
|
+
const makeRefusal = (name, line, message) => ({ name, line, message });
|
|
28
|
+
const getLineNumber = (lines, index) => Math.min(index + 1, Math.max(lines.length, 1));
|
|
29
|
+
const findNextContent = (lines, start) => {
|
|
30
|
+
const index = lines.findIndex((line, offset) => offset >= start && line.trim() !== '');
|
|
31
|
+
return index === -1 ? lines.length : index;
|
|
32
|
+
};
|
|
33
|
+
const makeFatalResult = (record, name, line, message) => ({
|
|
34
|
+
...record, claims: [], refusals: [makeRefusal(name, line, message)],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const splitCells = (line) => {
|
|
38
|
+
if (!line.startsWith('|') || !line.endsWith('|')) return null;
|
|
39
|
+
const body = line.slice(1, -1);
|
|
40
|
+
const parsed = Array.from(body).reduce((state, byte, index) => {
|
|
41
|
+
if (state.skip) state.skip = false;
|
|
42
|
+
else if (byte === '\\' && body[index + 1] === '|') { state.cell += '|'; state.skip = true; }
|
|
43
|
+
else if (byte === '|') { state.cells.push(state.cell.trim()); state.cell = ''; }
|
|
44
|
+
else state.cell += byte;
|
|
45
|
+
return state;
|
|
46
|
+
}, { cells: [], cell: '', skip: false });
|
|
47
|
+
return [...parsed.cells, parsed.cell.trim()];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const parsesDelimiter = (line) => {
|
|
51
|
+
const cells = splitCells(line);
|
|
52
|
+
return cells?.length === 5 && cells.every((cell) => /^-{3,}$/u.test(cell));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const RECORD_STATES = Object.freeze([
|
|
56
|
+
{ name: REFUSAL_NAMES.title, fixed: true, accepts: (line) => line.startsWith(TITLE_PREFIX) && line.slice(TITLE_PREFIX.length).trim() !== '', assigns: (record, line) => { record.title = line.slice(TITLE_PREFIX.length); }, message: `line 1 must be ${TITLE_PREFIX}<title>` },
|
|
57
|
+
{ name: REFUSAL_NAMES.source, accepts: (line) => line.startsWith(SOURCE_PREFIX) && line.slice(SOURCE_PREFIX.length).trim() !== '', assigns: (record, line) => { record.source = line.slice(SOURCE_PREFIX.length); }, message: `expected ${SOURCE_PREFIX}<one line>` },
|
|
58
|
+
{ name: REFUSAL_NAMES.head, accepts: (line) => line.startsWith(HEAD_PREFIX) && OBJECT_ID.test(line.slice(HEAD_PREFIX.length)), assigns: (record, line) => { record.head = line.slice(HEAD_PREFIX.length); }, message: `expected ${HEAD_PREFIX}<object id>` },
|
|
59
|
+
{ name: REFUSAL_NAMES.table, accepts: (line) => line === CLAIMS_HEADING, assigns: () => {}, message: `expected ${CLAIMS_HEADING}` },
|
|
60
|
+
{ name: REFUSAL_NAMES.table, accepts: (line) => line === TABLE_HEADER, assigns: () => {}, message: `expected ${TABLE_HEADER}` },
|
|
61
|
+
{ name: REFUSAL_NAMES.table, accepts: parsesDelimiter, assigns: () => {}, message: 'expected a five-cell table delimiter' },
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const hasUnsafePath = (path) =>
|
|
65
|
+
path.startsWith('/') || path.includes('\\') || path.split('/').includes('..');
|
|
66
|
+
|
|
67
|
+
const parseAnchors = (value, line) => {
|
|
68
|
+
const tokens = value.split(/,\s*/u);
|
|
69
|
+
if (tokens.length === 0 || tokens.some((token) => token === '')) {
|
|
70
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, 'Evidence must name one or more backticked anchors') };
|
|
71
|
+
}
|
|
72
|
+
const anchors = [];
|
|
73
|
+
for (const token of tokens) {
|
|
74
|
+
const match = ANCHOR.exec(token);
|
|
75
|
+
if (!match || match[1].includes('|')) {
|
|
76
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, `invalid anchor grammar: ${token}`) };
|
|
77
|
+
}
|
|
78
|
+
const [, path, startText, endText] = match;
|
|
79
|
+
if (hasUnsafePath(path)) {
|
|
80
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.anchorPath, line, `anchor path is not repository-relative: ${path}`) };
|
|
81
|
+
}
|
|
82
|
+
const start = Number(startText);
|
|
83
|
+
const end = endText === undefined ? start : Number(endText);
|
|
84
|
+
if (start > end) {
|
|
85
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, `anchor range starts after it ends: ${token}`) };
|
|
86
|
+
}
|
|
87
|
+
anchors.push({ path, start, end, line });
|
|
88
|
+
}
|
|
89
|
+
return { anchors };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const parsesDisposition = (value) =>
|
|
93
|
+
QUEUE.test(value) || ALREADY_QUEUED.test(value) || REASONED_DISPOSITION.test(value);
|
|
94
|
+
|
|
95
|
+
const parseClaim = (lineText, line, expectedId) => {
|
|
96
|
+
const cells = splitCells(lineText);
|
|
97
|
+
if (cells?.length !== 5) {
|
|
98
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.rowCells, line, 'claim row must contain exactly five cells') };
|
|
99
|
+
}
|
|
100
|
+
const [idText, claim, evidence, verdict, disposition] = cells;
|
|
101
|
+
if (idText !== String(expectedId)) {
|
|
102
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.claimId, line, `claim id must be ${expectedId}, got ${idText || '(empty)'}`) };
|
|
103
|
+
}
|
|
104
|
+
const parsedAnchors = parseAnchors(evidence, line);
|
|
105
|
+
if (parsedAnchors.refusal) return parsedAnchors;
|
|
106
|
+
if (!VERDICTS.includes(verdict)) {
|
|
107
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.verdict, line, `unknown verdict: ${verdict || '(empty)'}`) };
|
|
108
|
+
}
|
|
109
|
+
if (!parsesDisposition(disposition)) {
|
|
110
|
+
return { refusal: makeRefusal(REFUSAL_NAMES.disposition, line, `invalid disposition: ${disposition || '(empty)'}`) };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
claim: { id: expectedId, claim, anchors: parsedAnchors.anchors, verdict, disposition, line },
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const parseRecord = (text) => {
|
|
118
|
+
const lines = String(text).split('\n').map((line) => line.replace(/\r$/u, ''));
|
|
119
|
+
const record = { head: null, title: null, source: null, claims: [], refusals: [] };
|
|
120
|
+
const structure = RECORD_STATES.reduce((state, spec) => {
|
|
121
|
+
if (state.refusal !== null) return state;
|
|
122
|
+
const index = spec.fixed ? 0 : findNextContent(lines, state.index + 1);
|
|
123
|
+
const line = lines[index] ?? '';
|
|
124
|
+
if (!spec.accepts(line)) return { index, refusal: makeRefusal(spec.name, getLineNumber(lines, index), spec.message) };
|
|
125
|
+
spec.assigns(record, line);
|
|
126
|
+
return { index, refusal: null };
|
|
127
|
+
}, { index: -1, refusal: null });
|
|
128
|
+
if (structure.refusal !== null) {
|
|
129
|
+
return makeFatalResult(record, structure.refusal.name, structure.refusal.line, structure.refusal.message);
|
|
130
|
+
}
|
|
131
|
+
const delimiterIndex = structure.index;
|
|
132
|
+
const table = lines.slice(delimiterIndex + 1).reduce((state, lineText, offset) => {
|
|
133
|
+
const line = delimiterIndex + offset + 2;
|
|
134
|
+
if (state.notes || state.fatal !== null) return state;
|
|
135
|
+
if (state.done) {
|
|
136
|
+
if (lineText.trim() === '') return state;
|
|
137
|
+
if (lineText === NOTES_HEADING) return { ...state, notes: true };
|
|
138
|
+
return {
|
|
139
|
+
...state,
|
|
140
|
+
fatal: makeRefusal(
|
|
141
|
+
REFUSAL_NAMES.table,
|
|
142
|
+
line,
|
|
143
|
+
`the claims table ended at line ${state.endedLine}; only ${NOTES_HEADING} or the end of the record may follow`,
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (lineText === NOTES_HEADING) return { ...state, done: true, notes: true };
|
|
148
|
+
if (lineText.trim() === '') return { ...state, done: true, endedLine: line };
|
|
149
|
+
const parsed = parseClaim(lineText, delimiterIndex + offset + 2, state.expectedId);
|
|
150
|
+
return {
|
|
151
|
+
expectedId: state.expectedId + 1,
|
|
152
|
+
done: false,
|
|
153
|
+
notes: false,
|
|
154
|
+
endedLine: null,
|
|
155
|
+
fatal: null,
|
|
156
|
+
claims: parsed.claim ? [...state.claims, parsed.claim] : state.claims,
|
|
157
|
+
refusals: parsed.refusal ? [...state.refusals, parsed.refusal] : state.refusals,
|
|
158
|
+
};
|
|
159
|
+
}, { expectedId: 1, done: false, notes: false, endedLine: null, fatal: null, claims: [], refusals: [] });
|
|
160
|
+
if (table.fatal !== null) {
|
|
161
|
+
return makeFatalResult(record, table.fatal.name, table.fatal.line, table.fatal.message);
|
|
162
|
+
}
|
|
163
|
+
if (table.claims.length === 0 && table.refusals.length === 0) {
|
|
164
|
+
return makeFatalResult(record, REFUSAL_NAMES.table, delimiterIndex + 1, 'the claims table carries no row');
|
|
165
|
+
}
|
|
166
|
+
record.claims = table.claims;
|
|
167
|
+
record.refusals = table.refusals;
|
|
168
|
+
return record;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const judgeAnchors = (claims, facts) => {
|
|
172
|
+
const findings = [];
|
|
173
|
+
for (const claim of claims) {
|
|
174
|
+
for (const anchor of claim.anchors) {
|
|
175
|
+
const fact = Object.hasOwn(facts, anchor.path) ? facts[anchor.path] : { kind: 'absent' };
|
|
176
|
+
if (fact.kind === 'absent' || fact.kind === 'other') {
|
|
177
|
+
findings.push({ name: REFUSAL_NAMES.anchorAbsent, line: anchor.line, path: anchor.path, message: `anchor is absent or not a regular file: ${anchor.path}` });
|
|
178
|
+
} else if (fact.kind === 'unreadable') {
|
|
179
|
+
findings.push({ name: REFUSAL_NAMES.anchorUnreadable, line: anchor.line, path: anchor.path, message: `anchor cannot be read: ${anchor.path} (${fact.reason ?? 'unknown reason'})` });
|
|
180
|
+
} else if (anchor.end > fact.lines) {
|
|
181
|
+
findings.push({ name: REFUSAL_NAMES.anchorLine, line: anchor.line, path: anchor.path, message: `anchor line ${anchor.end} exceeds ${fact.lines} lines in ${anchor.path}` });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return findings;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const rendersAnchor = (anchor) => `${anchor.path}:${anchor.start}${anchor.end === anchor.start ? '' : `-${anchor.end}`}`;
|
|
189
|
+
|
|
190
|
+
const makeRange = (start, end) => ({
|
|
191
|
+
[Symbol.iterator]: () => {
|
|
192
|
+
const state = { next: start };
|
|
193
|
+
return {
|
|
194
|
+
next: () => state.next > end
|
|
195
|
+
? { done: true }
|
|
196
|
+
: { done: false, value: state.next++ },
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
export const renderExcerpts = (anchors, texts, budget) => {
|
|
202
|
+
const rendered = [];
|
|
203
|
+
const state = { bytes: 0 };
|
|
204
|
+
const sources = new Map();
|
|
205
|
+
for (const anchor of anchors) {
|
|
206
|
+
if (!sources.has(anchor.path)) {
|
|
207
|
+
const text = Object.hasOwn(texts, anchor.path) ? texts[anchor.path] : '';
|
|
208
|
+
sources.set(anchor.path, String(text ?? '').split(LINE_JOINER));
|
|
209
|
+
}
|
|
210
|
+
const sourceLines = sources.get(anchor.path);
|
|
211
|
+
for (const number of makeRange(anchor.start, anchor.end)) {
|
|
212
|
+
const line = `${anchor.path}:${number}: ${sourceLines[number - 1] ?? ''}`;
|
|
213
|
+
state.bytes += Buffer.byteLength(line, 'utf8') + (rendered.length === 0 ? 0 : Buffer.byteLength(LINE_JOINER));
|
|
214
|
+
rendered.push(line);
|
|
215
|
+
if (state.bytes > budget) return rendered.join(LINE_JOINER);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return rendered.join(LINE_JOINER);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export const renderRows = (record, { date, recordPath }) => {
|
|
222
|
+
const ids = [];
|
|
223
|
+
for (const claim of record.claims) {
|
|
224
|
+
const id = QUEUE.exec(claim.disposition)?.[1];
|
|
225
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
226
|
+
}
|
|
227
|
+
return ids.map((id) => {
|
|
228
|
+
const claims = record.claims.filter((claim) => QUEUE.exec(claim.disposition)?.[1] === id);
|
|
229
|
+
return [
|
|
230
|
+
`- [ ] ${id}: ${claims[0].claim} (${date})`,
|
|
231
|
+
...claims.map((claim) => ` - ${claim.claim}; verdict ${claim.verdict}; evidence ${claim.anchors.map(rendersAnchor).join(', ')}`),
|
|
232
|
+
` - record: ${recordPath}`,
|
|
233
|
+
` - head: ${record.head}`,
|
|
234
|
+
].join('\n');
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const ratchetLine = (gateCmd, newRows) => {
|
|
239
|
+
const match = /(?:^|\s)--max-rows\s+([0-9]+)(?=\s|$)/u.exec(String(gateCmd));
|
|
240
|
+
if (!match) return 'ratchet: queue-audit --max-rows is absent from the gate command';
|
|
241
|
+
const current = Number(match[1]);
|
|
242
|
+
return `ratchet: queue-audit --max-rows ${current} \u2192 ${current + newRows.length}`;
|
|
243
|
+
};
|
|
@@ -88,6 +88,8 @@ export const KNOWN_PRIOR_METHODOLOGY_SLOT = [
|
|
|
88
88
|
'> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
|
|
89
89
|
// engine 4.2.0 — the two-activity pointer, before `routine` joined the named activities.
|
|
90
90
|
'> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. The plan shape, its caps and lifecycle live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
|
|
91
|
+
// engine 4.6.0 — the three-activity pointer, before feedback-triage joined the named activities.
|
|
92
|
+
'> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. The plan shape, its caps and lifecycle live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution, routine) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
|
|
91
93
|
];
|
|
92
94
|
export const KNOWN_PRIOR_ORCH_SLOT = [
|
|
93
95
|
// v1.3.0 — pre-read-at-start orchestration pointer (recipes vocabulary, no orchestration.json clause).
|
|
@@ -50,12 +50,22 @@ const ROSTER_README = V3_README.replace(
|
|
|
50
50
|
"a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form",
|
|
51
51
|
);
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
const FOLD_README = ROSTER_README
|
|
54
54
|
.replace("'plan-authoring' (slots author, review)", "'plan-authoring' (slots author, fold, review)")
|
|
55
55
|
.replace("the carrier slots 'plan-authoring.author' and 'routine.carrier'", "the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier'")
|
|
56
56
|
.replace('solo for author, execute and carrier', 'solo for author, fold, execute and carrier');
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
const FOUR_README = FOLD_README.replace(
|
|
59
|
+
"Three activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review) and 'routine' (slots carrier, parallel).",
|
|
60
|
+
"Four activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review), 'routine' (slots carrier, parallel) and 'feedback-triage' (slot review).",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const CANON_README = FOUR_README.replace(
|
|
64
|
+
"The default below is 'solo' for every recipe and carrier slot, and 'on' for the parallel switch — no execution backend required.",
|
|
65
|
+
"Every slot seeded below is 'solo' — no execution backend required. A slot the seed leaves silent takes the computed default stated further down: 'feedback-triage.review' is reviewed as soon as a review backend is ready (solo until then); 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' stay 'solo', and 'routine.parallel' stays 'on'.",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export const KNOWN_PRIOR_README = Object.freeze([V1_README, V2_README, V3_README, ROSTER_README, FOLD_README, FOUR_README]);
|
|
59
69
|
|
|
60
70
|
export const refreshReadme = (config) => {
|
|
61
71
|
if (config == null || typeof config !== 'object' || Array.isArray(config)) return { config, changed: false };
|