@sabaiway/agent-workflow-kit 14.4.3 → 14.6.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 +94 -0
- package/README.md +2 -1
- package/SKILL.md +1 -1
- package/bin/install.mjs +16 -18
- package/capability.json +1 -1
- package/migrations/1.1.0-communication-language.md +12 -15
- package/migrations/1.2.0-agent-attribution.md +17 -21
- package/migrations/README.md +8 -2
- package/package.json +1 -1
- package/references/modes/upgrade.md +13 -9
- package/references/reference-profile.json +37 -0
- package/references/shared/composition-handoff.md +6 -4
- package/references/templates/agent_rules.md +3 -0
- package/tools/delegation.mjs +2 -2
- package/tools/lens-region.mjs +186 -308
- package/tools/migration-blocks.mjs +197 -0
- package/tools/migration-notes.mjs +218 -0
- package/tools/payload-prune.mjs +173 -0
- package/tools/profile-gaps.mjs +90 -0
- package/tools/rules-insert.mjs +203 -0
- package/tools/rules-regions.mjs +167 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { AGENTS_MD_CAP } from './inject-methodology.mjs';
|
|
6
|
+
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
7
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
8
|
+
|
|
9
|
+
const LF = String.fromCharCode(10);
|
|
10
|
+
const CRLF = String.fromCharCode(13) + LF;
|
|
11
|
+
const ANCHOR = '## 🧭 Memory Map';
|
|
12
|
+
const TEMPLATE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'AGENTS.md');
|
|
13
|
+
const REINSTALL = 'npx @sabaiway/agent-workflow-kit@latest init';
|
|
14
|
+
const BLOCKS = [
|
|
15
|
+
{ name: 'communication', heading: '## 🗣️ Communication language', flag: 'language', placeholder: '{{COMM_LANGUAGE}}' },
|
|
16
|
+
{ name: 'attribution', heading: '## ✍️ Attribution', flag: 'attribution', placeholder: '{{AGENT_ATTRIBUTION}}' },
|
|
17
|
+
];
|
|
18
|
+
const FLAGS = ['--cwd', '--language', '--attribution', '--apply'];
|
|
19
|
+
const USAGE = 'usage: migration-blocks.mjs --cwd <project> [--language <text>] [--attribution on|off] [--apply]';
|
|
20
|
+
const CONTROL_BYTES = /[\x00-\x1f\x7f\u2028\u2029]/u;
|
|
21
|
+
|
|
22
|
+
const makeResult = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
|
|
23
|
+
const renderDetail = (detail) => String(detail).replace(/[\r\n\u2028\u2029]+/gu, ' ');
|
|
24
|
+
const refuse = (name, detail) => makeResult(1, '', detail ? `${name}: ${renderDetail(detail)}` : name);
|
|
25
|
+
const countLines = (text) => text.split(LF).length - Number(text.endsWith(LF));
|
|
26
|
+
const findHeadings = (lines, heading) => lines.flatMap((line, index) => line.trimEnd() === heading ? [index] : []);
|
|
27
|
+
|
|
28
|
+
const parseArgs = (argv) => {
|
|
29
|
+
const parsed = argv.reduce((state, argument) => {
|
|
30
|
+
if (state.pending) {
|
|
31
|
+
if (argument.startsWith('--')) throw new Error(`${state.pending} needs a value`);
|
|
32
|
+
return { options: { ...state.options, [state.pending.slice(2)]: argument }, pending: null };
|
|
33
|
+
}
|
|
34
|
+
if (!FLAGS.includes(argument)) throw new Error(`unknown argument ${argument}`);
|
|
35
|
+
const key = argument.slice(2);
|
|
36
|
+
if (Object.hasOwn(state.options, key)) throw new Error(`${argument} repeated`);
|
|
37
|
+
if (argument === '--apply') return { options: { ...state.options, apply: true }, pending: null };
|
|
38
|
+
return { options: state.options, pending: argument };
|
|
39
|
+
}, { options: {}, pending: null });
|
|
40
|
+
if (parsed.pending) throw new Error(`${parsed.pending} needs a value`);
|
|
41
|
+
if (!parsed.options.cwd) throw new Error('--cwd is required');
|
|
42
|
+
return parsed.options;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const checkAnswers = ({ language, attribution }) => {
|
|
46
|
+
if (language !== undefined && (!language.trim() || CONTROL_BYTES.test(language))) {
|
|
47
|
+
return refuse('language', 'provide one nonempty line without control bytes');
|
|
48
|
+
}
|
|
49
|
+
if (attribution !== undefined && !['on', 'off'].includes(attribution)) {
|
|
50
|
+
return refuse('attribution', 'expected on or off');
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const extractSpans = (template) => {
|
|
56
|
+
const lines = template.split(CRLF).join(LF).split(LF);
|
|
57
|
+
return BLOCKS.map((block) => {
|
|
58
|
+
const headings = findHeadings(lines, block.heading);
|
|
59
|
+
if (headings.length !== 1) throw new Error(`${block.name} heading must occur once`);
|
|
60
|
+
const start = headings[0];
|
|
61
|
+
const next = lines.findIndex((line, index) => index > start && line.startsWith('## '));
|
|
62
|
+
const span = next < 0 ? lines.slice(start).join(LF) : lines.slice(start, next).join(LF) + LF;
|
|
63
|
+
if (span.split(block.placeholder).length !== 2) {
|
|
64
|
+
throw new Error(`${block.name} placeholder must occur once in its span`);
|
|
65
|
+
}
|
|
66
|
+
return { ...block, span };
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const readTemplate = (path, deps) => {
|
|
71
|
+
try {
|
|
72
|
+
const read = deps.readFileSync ?? readFileSync;
|
|
73
|
+
return { spans: extractSpans(read(path, 'utf8')) };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { error: refuse('template', `${error.message}; run ${REINSTALL}`) };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const readDocument = (path, deps) => {
|
|
80
|
+
const probe = deps.lstatSync ?? lstatSync;
|
|
81
|
+
const read = deps.readFileSync ?? readFileSync;
|
|
82
|
+
const status = (() => {
|
|
83
|
+
try {
|
|
84
|
+
return { stat: probe(path) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const name = error.code === 'ENOENT' ? 'file-absent' : 'file-unreadable';
|
|
87
|
+
return { error: refuse(name, error.message) };
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
if (status.error) return status;
|
|
91
|
+
if (status.stat.isSymbolicLink()) return { error: refuse('file-symlink', path) };
|
|
92
|
+
if (!status.stat.isFile()) return { error: refuse('file-unreadable', `${path} is not a regular file`) };
|
|
93
|
+
try {
|
|
94
|
+
return { text: read(path, 'utf8') };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return { error: refuse('file-unreadable', error.message) };
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const classifyBlock = (block, lines, answers, newline) => {
|
|
101
|
+
const headings = findHeadings(lines, block.heading);
|
|
102
|
+
const answer = answers[block.flag];
|
|
103
|
+
if (headings.length > 1) return { error: refuse('heading-twice', block.name) };
|
|
104
|
+
if (headings.length === 1) {
|
|
105
|
+
return { ...block, state: 'present', index: headings[0], ignored: answer !== undefined };
|
|
106
|
+
}
|
|
107
|
+
if (answer === undefined) return { ...block, state: 'needs' };
|
|
108
|
+
const body = block.span.split(block.placeholder).join(answer).split(LF).join(newline);
|
|
109
|
+
return { ...block, state: 'planned', body };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const renderBlock = (block, applied) => {
|
|
113
|
+
if (block.state === 'present') return `${block.name}: present${block.ignored ? '; answer ignored' : ''}`;
|
|
114
|
+
if (block.state === 'needs') return `${block.name}: needs --${block.flag}`;
|
|
115
|
+
return `${block.name}: ${applied ? 'inserted' : 'planned'}`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const insertBlocks = (text, lines, blocks) => {
|
|
119
|
+
const planned = blocks.filter((block) => block.state === 'planned');
|
|
120
|
+
if (planned.length === 0) return { body: text };
|
|
121
|
+
const anchors = lines.flatMap((line, index) => line.replace(/\r$/u, '') === ANCHOR ? [index] : []);
|
|
122
|
+
if (anchors.length === 0) return { error: refuse('anchor-absent', ANCHOR) };
|
|
123
|
+
if (anchors.length !== 1) return { error: refuse('anchor-repeated', ANCHOR) };
|
|
124
|
+
const total = countLines(text) + planned.reduce((count, block) => count + countLines(block.body), 0);
|
|
125
|
+
if (total > AGENTS_MD_CAP) {
|
|
126
|
+
return { error: refuse('cap', `${total} lines exceed ${AGENTS_MD_CAP}; trim AGENTS.md and re-run`) };
|
|
127
|
+
}
|
|
128
|
+
const anchor = anchors[0];
|
|
129
|
+
const attribution = blocks.find((block) => block.name === 'attribution');
|
|
130
|
+
const insertions = planned.map((block) => ({
|
|
131
|
+
...block,
|
|
132
|
+
index: block.name === 'communication' && attribution.state === 'present' && attribution.index < anchor
|
|
133
|
+
? attribution.index : anchor,
|
|
134
|
+
}));
|
|
135
|
+
const body = lines.map((line, index) => {
|
|
136
|
+
const prefix = insertions.filter((block) => block.index === index).map((block) => block.body).join('');
|
|
137
|
+
return prefix + line;
|
|
138
|
+
}).join(LF);
|
|
139
|
+
return { body };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const planBlocks = ({ text, spans, language, attribution, apply = false }) => {
|
|
143
|
+
const lines = text.split(LF);
|
|
144
|
+
const newline = text.includes(CRLF) ? CRLF : LF;
|
|
145
|
+
const blocks = spans.map((block) => classifyBlock(block, lines, { language, attribution }, newline));
|
|
146
|
+
const refused = blocks.find((block) => block.error);
|
|
147
|
+
if (refused) return { result: refused.error, write: false };
|
|
148
|
+
const insertion = insertBlocks(text, lines, blocks);
|
|
149
|
+
if (insertion.error) return { result: insertion.error, write: false };
|
|
150
|
+
const missing = blocks.some((block) => block.state === 'needs');
|
|
151
|
+
const write = apply && !missing && blocks.some((block) => block.state === 'planned');
|
|
152
|
+
const stdout = blocks.map((block) => renderBlock(block, write)).join(LF);
|
|
153
|
+
const result = apply && missing ? makeResult(1, stdout, 'answer-missing') : makeResult(0, stdout);
|
|
154
|
+
return { result, write, body: insertion.body };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const runBlocks = (options, deps) => {
|
|
158
|
+
const invalid = checkAnswers(options);
|
|
159
|
+
if (invalid) return invalid;
|
|
160
|
+
const template = readTemplate(deps.templatePath ?? TEMPLATE_PATH, deps);
|
|
161
|
+
if (template.error) return template.error;
|
|
162
|
+
const root = resolve(options.cwd);
|
|
163
|
+
const destination = resolve(root, 'AGENTS.md');
|
|
164
|
+
const document = readDocument(destination, deps);
|
|
165
|
+
if (document.error) return document.error;
|
|
166
|
+
const plan = planBlocks({ ...options, text: document.text, spans: template.spans });
|
|
167
|
+
if (plan.write) {
|
|
168
|
+
try {
|
|
169
|
+
writeContainedFileAtomic(root, destination, plan.body, {
|
|
170
|
+
writeFile: deps.writeFile,
|
|
171
|
+
rename: deps.rename,
|
|
172
|
+
rm: deps.rm,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return refuse('write-failed', error.message);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return plan.result;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const main = (argv, deps = {}) => {
|
|
182
|
+
const parsed = (() => {
|
|
183
|
+
try {
|
|
184
|
+
return { options: parseArgs(argv) };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { error: makeResult(2, '', `${USAGE}; ${renderDetail(error.message)}`) };
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
return parsed.error ?? runBlocks(parsed.options, deps);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (isDirectRun(import.meta.url)) {
|
|
193
|
+
const result = await main(process.argv.slice(2));
|
|
194
|
+
if (result.stdout) console.log(result.stdout);
|
|
195
|
+
if (result.stderr) console.error(result.stderr);
|
|
196
|
+
process.exit(result.code);
|
|
197
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
|
|
5
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
6
|
+
|
|
7
|
+
const NOTES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
|
|
8
|
+
const STAMP_PATH = 'docs/ai/.workflow-version';
|
|
9
|
+
const VERSION_PATTERN = /^[0-9]+[.][0-9]+[.][0-9]+$/u;
|
|
10
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
11
|
+
const CONTROL_PATTERN = /[\p{Cc}\p{Zl}\p{Zp}]/gu;
|
|
12
|
+
const NOTE_SUFFIX = '.md';
|
|
13
|
+
const README_NAME = 'README.md';
|
|
14
|
+
const HEADLINE_PREFIX = '# ';
|
|
15
|
+
const LF = String.fromCharCode(10);
|
|
16
|
+
const CR = String.fromCharCode(13);
|
|
17
|
+
const EXIT_ACCEPTED = 0;
|
|
18
|
+
const EXIT_REFUSED = 1;
|
|
19
|
+
const EXIT_USAGE = 2;
|
|
20
|
+
|
|
21
|
+
const formatDetail = (value) => String(value).replace(CONTROL_PATTERN, ' ');
|
|
22
|
+
const makeRefusal = (name, detail) => ({
|
|
23
|
+
code: EXIT_REFUSED,
|
|
24
|
+
stdout: '',
|
|
25
|
+
stderr: `${name}: ${formatDetail(detail)}`,
|
|
26
|
+
});
|
|
27
|
+
const matchesEntire = (text, pattern) => text.match(pattern)?.[0] === text;
|
|
28
|
+
const parseVersion = (version) => version.split('.').map((field) => BigInt(field));
|
|
29
|
+
const compareVersions = (left, right) => {
|
|
30
|
+
const rightFields = parseVersion(right);
|
|
31
|
+
for (const [index, field] of parseVersion(left).entries()) {
|
|
32
|
+
if (field < rightFields[index]) {
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
if (field > rightFields[index]) {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const judgeStamp = (stamp, head) => {
|
|
43
|
+
if (!matchesEntire(stamp, VERSION_PATTERN)) {
|
|
44
|
+
return makeRefusal('stamp-unparseable', 'expected one major.minor.patch version');
|
|
45
|
+
}
|
|
46
|
+
if (compareVersions(stamp, head) > 0) {
|
|
47
|
+
return makeRefusal('stamp-above-head', `${stamp} is newer than ${head}; never downgrade`);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const shouldIgnore = (name) => name.startsWith('.') || !name.endsWith(NOTE_SUFFIX);
|
|
53
|
+
const parseNote = (entry) => {
|
|
54
|
+
const stem = entry.name.slice(0, -NOTE_SUFFIX.length);
|
|
55
|
+
const separator = stem.indexOf('-');
|
|
56
|
+
const version = stem.slice(0, separator);
|
|
57
|
+
const slug = stem.slice(separator + 1);
|
|
58
|
+
const valid = separator > 0 && matchesEntire(version, VERSION_PATTERN) && matchesEntire(slug, SLUG_PATTERN);
|
|
59
|
+
return { ...entry, version, valid };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readHeadline = (text) => {
|
|
63
|
+
const line = text.split(LF).find((candidate) => candidate.startsWith(HEADLINE_PREFIX));
|
|
64
|
+
if (line === undefined) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const content = (line.endsWith(CR) ? line.slice(0, -CR.length) : line).slice(HEADLINE_PREFIX.length);
|
|
68
|
+
return content.search(CONTROL_PATTERN) < 0 && content.trim() !== '' ? content.trim() : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const findDuplicate = (notes) => {
|
|
72
|
+
const versions = new Set();
|
|
73
|
+
for (const note of notes) {
|
|
74
|
+
const key = parseVersion(note.version).join('.');
|
|
75
|
+
if (versions.has(key)) {
|
|
76
|
+
return note;
|
|
77
|
+
}
|
|
78
|
+
versions.add(key);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// The caller has trimmed and judged the stamp; main judges every stamp cell before reading the notes directory.
|
|
84
|
+
export const selectNotes = (stamp, entries, head) => {
|
|
85
|
+
const candidates = entries.filter(({ name }) => !shouldIgnore(name));
|
|
86
|
+
const unreadable = candidates.find(({ kind }) => kind === 'unreadable');
|
|
87
|
+
if (unreadable) {
|
|
88
|
+
return makeRefusal('notes-unreadable', `${unreadable.name}: ${unreadable.detail}`);
|
|
89
|
+
}
|
|
90
|
+
const parsed = candidates.map(parseNote);
|
|
91
|
+
const invalid = parsed.find(({ kind, name, valid }) => kind !== 'file' || (name !== README_NAME && !valid));
|
|
92
|
+
if (invalid) {
|
|
93
|
+
return makeRefusal('notes-entry', invalid.name);
|
|
94
|
+
}
|
|
95
|
+
const notes = parsed.filter(({ name }) => name !== README_NAME);
|
|
96
|
+
const aboveHead = notes.find(({ version }) => compareVersions(version, head) > 0);
|
|
97
|
+
if (aboveHead) {
|
|
98
|
+
return makeRefusal('note-above-head', `${aboveHead.name} is newer than ${head}`);
|
|
99
|
+
}
|
|
100
|
+
const duplicate = findDuplicate(notes);
|
|
101
|
+
if (duplicate) {
|
|
102
|
+
return makeRefusal('note-version-twice', duplicate.version);
|
|
103
|
+
}
|
|
104
|
+
const titled = notes.map((note) => ({ ...note, headline: readHeadline(note.text) }));
|
|
105
|
+
const untitled = titled.find(({ headline }) => headline === null);
|
|
106
|
+
if (untitled) {
|
|
107
|
+
return makeRefusal('note-headline', untitled.name);
|
|
108
|
+
}
|
|
109
|
+
const selected = titled
|
|
110
|
+
.filter(({ version }) => compareVersions(version, stamp) > 0 && compareVersions(version, head) <= 0)
|
|
111
|
+
.sort((left, right) => compareVersions(left.version, right.version));
|
|
112
|
+
const none = compareVersions(stamp, head) === 0
|
|
113
|
+
? `none: stamp ${stamp} equals head ${head}; no note applies`
|
|
114
|
+
: `none: no notes newer than stamp ${stamp} through head ${head}`;
|
|
115
|
+
const lines = selected.map(({ version, path, headline }) => `note ${version} ${path} :: ${headline}`);
|
|
116
|
+
const ignored = entries.filter(({ name }) => shouldIgnore(name)).map(({ name }) => `ignored: ${formatDetail(name)}`);
|
|
117
|
+
return {
|
|
118
|
+
code: EXIT_ACCEPTED,
|
|
119
|
+
stdout: lines.length > 0 ? lines.join(LF) : none,
|
|
120
|
+
stderr: ignored.join(LF),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const probePath = (path, fs) => {
|
|
125
|
+
try {
|
|
126
|
+
const stat = fs.lstatSync(path);
|
|
127
|
+
if (stat.isSymbolicLink()) {
|
|
128
|
+
return { kind: 'symlink' };
|
|
129
|
+
}
|
|
130
|
+
if (stat.isFile()) {
|
|
131
|
+
return { kind: 'file' };
|
|
132
|
+
}
|
|
133
|
+
return { kind: stat.isDirectory() ? 'directory' : 'other' };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { kind: error.code === 'ENOENT' ? 'absent' : 'unreadable', detail: error.message };
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const readStamp = (path, fs) => {
|
|
140
|
+
const probe = probePath(path, fs);
|
|
141
|
+
if (probe.kind === 'absent') {
|
|
142
|
+
return { refusal: makeRefusal('stamp-absent', `${path}; use step 1 conservative re-bootstrap`) };
|
|
143
|
+
}
|
|
144
|
+
if (probe.kind !== 'file') {
|
|
145
|
+
return { refusal: makeRefusal('stamp-unreadable', `${path}: ${probe.detail ?? probe.kind}`) };
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return { text: fs.readFileSync(path, 'utf8') };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return { refusal: makeRefusal('stamp-unreadable', `${path}: ${error.message}`) };
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const readEntry = (notesDir, name, fs) => {
|
|
155
|
+
const path = join(notesDir, name);
|
|
156
|
+
if (shouldIgnore(name)) {
|
|
157
|
+
return { name, path };
|
|
158
|
+
}
|
|
159
|
+
const probe = probePath(path, fs);
|
|
160
|
+
if (probe.kind !== 'file' || name === README_NAME) {
|
|
161
|
+
return { name, path, ...probe };
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
return { name, path, kind: 'file', text: fs.readFileSync(path, 'utf8') };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { name, path, kind: 'unreadable', detail: error.message };
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const readNotes = (notesDir, fs) => {
|
|
171
|
+
const probe = probePath(notesDir, fs);
|
|
172
|
+
if (probe.kind === 'absent') {
|
|
173
|
+
return { refusal: makeRefusal('notes-absent', notesDir) };
|
|
174
|
+
}
|
|
175
|
+
if (probe.kind !== 'directory') {
|
|
176
|
+
return { refusal: makeRefusal('notes-unreadable', `${notesDir}: ${probe.detail ?? probe.kind}`) };
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
return { entries: fs.readdirSync(notesDir).map((name) => readEntry(notesDir, name, fs)) };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return { refusal: makeRefusal('notes-unreadable', `${notesDir}: ${error.message}`) };
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const main = (argv, deps = {}) => {
|
|
186
|
+
const validUsage = argv.length === 2 && argv[0] === '--cwd'
|
|
187
|
+
&& typeof argv[1] === 'string' && argv[1].trim() !== '' && !argv[1].startsWith('--');
|
|
188
|
+
if (!validUsage) {
|
|
189
|
+
return { code: EXIT_USAGE, stdout: '', stderr: 'usage: migration-notes.mjs --cwd <project>' };
|
|
190
|
+
}
|
|
191
|
+
const fs = {
|
|
192
|
+
lstatSync: deps.lstatSync ?? lstatSync,
|
|
193
|
+
readFileSync: deps.readFileSync ?? readFileSync,
|
|
194
|
+
readdirSync: deps.readdirSync ?? readdirSync,
|
|
195
|
+
};
|
|
196
|
+
const stamp = readStamp(resolve(argv[1], STAMP_PATH), fs);
|
|
197
|
+
if (stamp.refusal) {
|
|
198
|
+
return stamp.refusal;
|
|
199
|
+
}
|
|
200
|
+
const stampText = stamp.text.trim();
|
|
201
|
+
const stampRefusal = judgeStamp(stampText, EXPECTED_WORKFLOW_VERSION);
|
|
202
|
+
if (stampRefusal) {
|
|
203
|
+
return stampRefusal;
|
|
204
|
+
}
|
|
205
|
+
const notes = readNotes(resolve(deps.notesDir ?? NOTES_DIR), fs);
|
|
206
|
+
return notes.refusal ?? selectNotes(stampText, notes.entries, EXPECTED_WORKFLOW_VERSION);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (isDirectRun(import.meta.url)) {
|
|
210
|
+
const result = await main(process.argv.slice(2));
|
|
211
|
+
if (result.stdout) {
|
|
212
|
+
console.log(result.stdout);
|
|
213
|
+
}
|
|
214
|
+
if (result.stderr) {
|
|
215
|
+
console.error(result.stderr);
|
|
216
|
+
}
|
|
217
|
+
process.exit(result.code);
|
|
218
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
4
|
+
import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
|
|
5
|
+
|
|
6
|
+
const PREFIX = '[agent-workflow-kit]';
|
|
7
|
+
const NON_CONVERGENCE = `${PREFIX} the kit home did not converge to the package (see the lines above) `
|
|
8
|
+
+ '— fix them, then re-run npx @sabaiway/agent-workflow-kit@latest init';
|
|
9
|
+
|
|
10
|
+
export const readHomeOwner = (home) => {
|
|
11
|
+
try {
|
|
12
|
+
const stat = lstatSync(home, { throwIfNoEntry: false });
|
|
13
|
+
if (stat === undefined) {
|
|
14
|
+
return 'absent';
|
|
15
|
+
}
|
|
16
|
+
if (!stat.isDirectory()) {
|
|
17
|
+
return 'other';
|
|
18
|
+
}
|
|
19
|
+
if (readdirSync(home).length === 0) {
|
|
20
|
+
return 'empty';
|
|
21
|
+
}
|
|
22
|
+
const skill = resolve(home, 'SKILL.md');
|
|
23
|
+
if (!lstatSync(skill).isFile()) {
|
|
24
|
+
return 'other';
|
|
25
|
+
}
|
|
26
|
+
const lines = readFileSync(skill, 'utf8').split('\n').map((line) => line.replace(/\r$/, ''));
|
|
27
|
+
if (lines[0] !== '---') {
|
|
28
|
+
return 'other';
|
|
29
|
+
}
|
|
30
|
+
const end = lines.findIndex((line, index) => index > 0 && line === '---');
|
|
31
|
+
if (end < 0) {
|
|
32
|
+
return 'other';
|
|
33
|
+
}
|
|
34
|
+
return lines.slice(1, end).some((line) => line.trimEnd() === 'name: agent-workflow-kit') ? 'kit' : 'other';
|
|
35
|
+
} catch {
|
|
36
|
+
return 'other';
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const assertInstallableHome = (home) => {
|
|
41
|
+
const owner = readHomeOwner(home);
|
|
42
|
+
if (owner === 'other') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${PREFIX} refusing to install into ${home}: it is not empty and holds no agent-workflow-kit SKILL.md; `
|
|
45
|
+
+ 'choose a new or empty folder, or an existing kit home',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return owner;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const describeKind = (stat) => {
|
|
52
|
+
if (stat.isSymbolicLink()) {
|
|
53
|
+
return 'symlink';
|
|
54
|
+
}
|
|
55
|
+
if (stat.isDirectory()) {
|
|
56
|
+
return 'directory';
|
|
57
|
+
}
|
|
58
|
+
return stat.isFile() ? 'file' : 'non-regular object';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const reportFailure = (path, error) => ({
|
|
62
|
+
ok: false,
|
|
63
|
+
line: `${PREFIX} failed ${path}: ${error.message}`,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const finishReport = (reports) => {
|
|
67
|
+
const ok = reports.every((report) => report.ok);
|
|
68
|
+
return {
|
|
69
|
+
ok,
|
|
70
|
+
lines: reports.map((report) => report.line),
|
|
71
|
+
nonConvergence: ok ? null : NON_CONVERGENCE,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const readTree = (root, entry, reports, directoryOnly = false) => {
|
|
76
|
+
const entries = new Map();
|
|
77
|
+
const visitEntry = (path, first = false) => {
|
|
78
|
+
try {
|
|
79
|
+
const absolute = resolve(root, path);
|
|
80
|
+
const stat = lstatSync(absolute, { throwIfNoEntry: !first });
|
|
81
|
+
if (!stat || (first && directoryOnly && !stat.isDirectory())) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const kind = describeKind(stat);
|
|
85
|
+
entries.set(path, kind);
|
|
86
|
+
if (kind === 'directory') {
|
|
87
|
+
for (const name of readdirSync(absolute).sort()) {
|
|
88
|
+
visitEntry(`${path}/${name}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reports.push(reportFailure(path, error));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
const stat = lstatSync(root, { throwIfNoEntry: false });
|
|
97
|
+
if (!stat) {
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
if (!stat.isDirectory()) {
|
|
101
|
+
throw new Error(`the root is a ${describeKind(stat)}, not a real directory`);
|
|
102
|
+
}
|
|
103
|
+
visitEntry(entry, true);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
reports.push(reportFailure(entry, error));
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const planSubtree = (carried, installed, orphans, reports) => {
|
|
111
|
+
for (const [path, kind] of installed) {
|
|
112
|
+
if (carried.has(path)) {
|
|
113
|
+
const packageKind = carried.get(path);
|
|
114
|
+
if (kind !== packageKind) {
|
|
115
|
+
reports.push({
|
|
116
|
+
ok: false,
|
|
117
|
+
line: `${PREFIX} kind-mismatch ${path}: the home holds a ${kind}, the package a ${packageKind}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} else if (kind === 'file') {
|
|
121
|
+
orphans.push(path);
|
|
122
|
+
} else {
|
|
123
|
+
reports.push({
|
|
124
|
+
ok: true,
|
|
125
|
+
line: `${PREFIX} kept ${path} (a ${kind} the package does not carry — yours to remove by hand)`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const removeOrphan = (home, path, remove) => {
|
|
132
|
+
const absolute = resolve(home, path);
|
|
133
|
+
try {
|
|
134
|
+
remove.assertContainedRealPath(home, absolute);
|
|
135
|
+
const stat = remove.lstatNoFollowRead(absolute);
|
|
136
|
+
if (stat === null) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
if (!stat.isFile()) {
|
|
140
|
+
throw new Error('the leaf is no longer a regular file');
|
|
141
|
+
}
|
|
142
|
+
remove.unlinkSync(absolute);
|
|
143
|
+
return [{ ok: true, line: `${PREFIX} removed ${path} (not in the package)` }];
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return [reportFailure(path, error)];
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const prunePayload = ({ packageRoot, home, payload }, deps = {}) => {
|
|
150
|
+
const reports = [];
|
|
151
|
+
const orphans = [];
|
|
152
|
+
for (const entry of new Set(payload)) {
|
|
153
|
+
const path = entry.split(sep).join('/');
|
|
154
|
+
const carried = readTree(packageRoot, path, reports, true);
|
|
155
|
+
if (![...carried.values()].includes('file')) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const installed = readTree(home, path, reports);
|
|
159
|
+
planSubtree(carried, installed, orphans, reports);
|
|
160
|
+
}
|
|
161
|
+
if (reports.some((report) => !report.ok)) {
|
|
162
|
+
return finishReport(reports);
|
|
163
|
+
}
|
|
164
|
+
const remove = {
|
|
165
|
+
assertContainedRealPath: deps.assertContainedRealPath ?? assertContainedRealPath,
|
|
166
|
+
lstatNoFollowRead: deps.lstatNoFollowRead ?? lstatNoFollowRead,
|
|
167
|
+
unlinkSync: deps.unlinkSync ?? unlinkSync,
|
|
168
|
+
};
|
|
169
|
+
for (const path of orphans) {
|
|
170
|
+
reports.push(...removeOrphan(home, path, remove));
|
|
171
|
+
}
|
|
172
|
+
return finishReport(reports);
|
|
173
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import {
|
|
4
|
+
RULES_REGIONS,
|
|
5
|
+
extractRegionBy,
|
|
6
|
+
readTemplateSpan,
|
|
7
|
+
planInsert,
|
|
8
|
+
frontmatterMaxLines,
|
|
9
|
+
decodeUtf8Strict,
|
|
10
|
+
} from './rules-regions.mjs';
|
|
11
|
+
|
|
12
|
+
const GAP_ID = 'story-sessions-section';
|
|
13
|
+
const STORY_ID = 'story-sessions';
|
|
14
|
+
const TEMPLATE_CANON = 'template';
|
|
15
|
+
const ENCODING = 'utf8';
|
|
16
|
+
const MISSING_FILE_CODE = 'ENOENT';
|
|
17
|
+
const SINGLE_HEADING = 1;
|
|
18
|
+
const TARGET_PATH = 'docs/ai/agent_rules.md';
|
|
19
|
+
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
|
+
const VERDICTS = Object.freeze({
|
|
24
|
+
present: 'present',
|
|
25
|
+
absent: 'absent',
|
|
26
|
+
undecidable: 'undecidable',
|
|
27
|
+
});
|
|
28
|
+
const REASONS = Object.freeze({
|
|
29
|
+
template: 'template',
|
|
30
|
+
fileAbsent: 'file-absent',
|
|
31
|
+
fileSymlink: 'file-symlink',
|
|
32
|
+
fileUnreadable: 'file-unreadable',
|
|
33
|
+
});
|
|
34
|
+
const TEMPLATE_REGIONS = RULES_REGIONS.filter(({ canon }) => canon === TEMPLATE_CANON);
|
|
35
|
+
const STORY_REGION = RULES_REGIONS.find(({ id }) => id === STORY_ID);
|
|
36
|
+
|
|
37
|
+
const reportUndecidable = (reason) => ({ verdict: VERDICTS.undecidable, reason });
|
|
38
|
+
|
|
39
|
+
const readSpans = (deps) => {
|
|
40
|
+
try {
|
|
41
|
+
const template = deps.readFileSync(TEMPLATE_PATH, ENCODING);
|
|
42
|
+
const entries = TEMPLATE_REGIONS.map(({ id, headingRe }) => [
|
|
43
|
+
id, readTemplateSpan(template, headingRe),
|
|
44
|
+
]);
|
|
45
|
+
const spans = Object.fromEntries(entries);
|
|
46
|
+
if (Object.values(spans).some(({ defect }) => defect)) {
|
|
47
|
+
return reportUndecidable(REASONS.template);
|
|
48
|
+
}
|
|
49
|
+
return { spans };
|
|
50
|
+
} catch {
|
|
51
|
+
return reportUndecidable(REASONS.template);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readTarget = (root, deps) => {
|
|
56
|
+
const target = resolve(root, TARGET_PATH);
|
|
57
|
+
try {
|
|
58
|
+
const stat = deps.lstatSync(target);
|
|
59
|
+
if (stat.isSymbolicLink()) return reportUndecidable(REASONS.fileSymlink);
|
|
60
|
+
if (!stat.isFile()) return reportUndecidable(REASONS.fileUnreadable);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const reason = error?.code === MISSING_FILE_CODE ? REASONS.fileAbsent : REASONS.fileUnreadable;
|
|
63
|
+
return reportUndecidable(reason);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return { text: decodeUtf8Strict(deps.readFileSync(target)) };
|
|
67
|
+
} catch {
|
|
68
|
+
return reportUndecidable(REASONS.fileUnreadable);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const detectStorySessions = ({ root, deps }) => {
|
|
73
|
+
const template = readSpans(deps);
|
|
74
|
+
if (template.verdict) return template;
|
|
75
|
+
const target = readTarget(root, deps);
|
|
76
|
+
if (target.verdict) return target;
|
|
77
|
+
const { text } = target;
|
|
78
|
+
if (extractRegionBy(text, STORY_REGION.headingRe).count === SINGLE_HEADING) {
|
|
79
|
+
return { verdict: VERDICTS.present };
|
|
80
|
+
}
|
|
81
|
+
const result = planInsert({ text, spans: template.spans, cap: frontmatterMaxLines(text) });
|
|
82
|
+
if (result.refusal) return reportUndecidable(result.refusal);
|
|
83
|
+
return { verdict: VERDICTS.absent };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const buildInsertPreview = (root) => COMMAND_PREFIX + INSERT_PATH + CWD_FLAG + root;
|
|
87
|
+
|
|
88
|
+
export const PROFILE_GAPS = Object.freeze([
|
|
89
|
+
Object.freeze({ id: GAP_ID, detect: detectStorySessions, apply: buildInsertPreview }),
|
|
90
|
+
]);
|