@ikon85/agent-workflow-kit 0.44.2 → 0.45.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.
Files changed (64) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +7 -4
  2. package/.agents/skills/code-review/SKILL.md +7 -4
  3. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  4. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  5. package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
  6. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  7. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  8. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  9. package/.agents/skills/research/SKILL.md +7 -4
  10. package/.agents/skills/to-issues/SKILL.md +25 -4
  11. package/.claude/skills/audit-skills/SKILL.md +7 -4
  12. package/.claude/skills/code-review/SKILL.md +7 -4
  13. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  14. package/.claude/skills/codex-build/SKILL.md +13 -0
  15. package/.claude/skills/codex-review/SKILL.md +13 -0
  16. package/.claude/skills/grill-me-codex/SKILL.md +14 -0
  17. package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
  18. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  19. package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
  20. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  21. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  22. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  23. package/.claude/skills/research/SKILL.md +7 -4
  24. package/.claude/skills/skill-manifest.json +34 -23
  25. package/.claude/skills/to-issues/SKILL.md +25 -4
  26. package/README.md +64 -0
  27. package/agent-workflow-kit.package.json +149 -45
  28. package/package.json +1 -1
  29. package/scripts/doctrine-migration/index.mjs +296 -0
  30. package/scripts/kit-release.mjs +41 -9
  31. package/src/cli.mjs +515 -79
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/lib/bundle.mjs +158 -2
  34. package/src/lib/dispatchJournal.mjs +300 -0
  35. package/src/lib/dispatchPlan.mjs +286 -0
  36. package/src/lib/dispatchReceipt.mjs +226 -89
  37. package/src/lib/frontendWorkloads.mjs +35 -33
  38. package/src/lib/routeDispatcher.mjs +367 -70
  39. package/src/lib/routingAccessGraph.mjs +265 -20
  40. package/src/lib/routingAccessGraphStore.mjs +300 -0
  41. package/src/lib/routingAdapters/claude.mjs +104 -4
  42. package/src/lib/routingAdapters/codex.mjs +132 -7
  43. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  44. package/src/lib/routingCatalog.mjs +201 -24
  45. package/src/lib/routingDispatchLease.mjs +253 -0
  46. package/src/lib/routingEvidenceCache.mjs +78 -0
  47. package/src/lib/routingIntent.mjs +176 -10
  48. package/src/lib/routingIntentClassifier.mjs +137 -0
  49. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  50. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  51. package/src/lib/routingInventory.mjs +182 -0
  52. package/src/lib/routingPolicy.mjs +193 -6
  53. package/src/lib/routingProfile.mjs +1251 -123
  54. package/src/lib/routingProfilePolicy.mjs +156 -0
  55. package/src/lib/routingProfileStorage.mjs +299 -0
  56. package/src/lib/routingResolver.mjs +369 -86
  57. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  58. package/src/lib/routingSources/benchlm.mjs +5 -0
  59. package/src/lib/routingSources/codeArena.mjs +13 -3
  60. package/src/lib/routingSources/deepswe.mjs +19 -5
  61. package/src/lib/routingSources/openhands.mjs +17 -4
  62. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  63. package/src/lib/safeText.mjs +26 -0
  64. package/src/lib/updateCandidate.mjs +36 -3
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Doctrine migration — retire the hand-maintained model-and-effort routing
4
+ * table from the user-global instruction file.
5
+ *
6
+ * The table competes with the configuration that now holds the same knowledge:
7
+ * the Routing profile carries the Model roster and the three Standard routes,
8
+ * the Routing policy is derived from it, and the Route decision names the model
9
+ * and effort one dispatch applies. What the table never was is judgment — when
10
+ * delegation pays for itself, the escalation rule, and one worktree per
11
+ * parallel writing agent are decisions no roster can hold, so they stay.
12
+ *
13
+ * The file is the user's own, outside this repository and outside the consumer
14
+ * manifest, and may carry unrelated instructions. Every step is therefore
15
+ * fail-closed: an unplaceable bullet blocks the whole migration instead of
16
+ * being guessed away, and so does a judgment surviving only inside a
17
+ * data-bearing bullet; the removal is previewed as an exact diff, the original
18
+ * copied to a named backup before a byte is written, the destination re-read
19
+ * against the previewed fingerprint, and nothing written without acceptance.
20
+ *
21
+ * Runtime precedence does not wait for any of that. Where a Routing profile
22
+ * decides model and effort, it decides them whether or not the old table is
23
+ * still in the file — `resolveDoctrinePrecedence` reads the profile, never the
24
+ * doctrine text — so deferring the migration forever still yields the profile's
25
+ * answer. The table is only ever the fallback for a profile that cannot decide,
26
+ * which is why applying the removal is refused while it still is one.
27
+ *
28
+ * Run: node scripts/doctrine-migration/index.mjs [--file <path>] [--apply --accept]
29
+ */
30
+ import { readFile } from 'node:fs/promises';
31
+ import { createHash } from 'node:crypto';
32
+ import { homedir } from 'node:os';
33
+ import { join } from 'node:path';
34
+
35
+ import { backupFile, lineDiff, writeAtomic } from '../../src/lib/atomicWrite.mjs';
36
+ import { readComposedRoutingProfile } from '../../src/lib/routingProfile.mjs';
37
+
38
+ /** The section that owns the doctrine, and the heading level that ends it. */
39
+ export const DOCTRINE_SECTION_PATTERN = /^##\s+Task-Routing\b/;
40
+ const SECTION_END_PATTERN = /^##\s/;
41
+
42
+ /** The one sentence that replaces the table, and the substring that proves it is there. */
43
+ export const PRECEDENCE_MARKER = 'decides model and effort';
44
+ export const PRECEDENCE_BULLET = '- Where a Routing profile exists, it decides model and effort;\n'
45
+ + ' what stays here is the judgment that is not data.';
46
+
47
+ /**
48
+ * The judgment that is not data. Each id must survive in a retained bullet or
49
+ * the migration blocks: losing one of these is the failure this tool exists to
50
+ * prevent, and "it was probably still somewhere" is not a check.
51
+ */
52
+ export const RETAINED_JUDGMENTS = Object.freeze([
53
+ { id: 'delegation-pays-for-itself', pattern: /delegat/i },
54
+ { id: 'parallel-writes-need-a-worktree', pattern: /worktree/i },
55
+ { id: 'escalation-after-repeated-failure', pattern: /eskalat|escalat/i },
56
+ ]);
57
+
58
+ /**
59
+ * What makes a bullet table content. A data marker outranks a judgment marker:
60
+ * a bullet that names a model, an effort level or the table's own companion
61
+ * file is routing data even when it also mentions delegation, and the judgment
62
+ * coverage check is what proves nothing was lost by removing it.
63
+ */
64
+ export const ROUTING_DATA_MARKERS = Object.freeze([
65
+ { id: 'model-name', pattern: /\b(sonnet|opus|haiku|fable|gpt-[\w.-]+|claude-[\w[\].-]+)\b/i },
66
+ { id: 'effort-level', pattern: /\beffort\b|\b(low|medium|high|xhigh)\b/i },
67
+ { id: 'routing-table-pointer', pattern: /task-routing\.md/i },
68
+ ]);
69
+
70
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
71
+
72
+ const stampOf = (now) => now.toISOString().replace(/[:.]/g, '-');
73
+
74
+ const blocked = (status, reasons) => Object.freeze({
75
+ status, reasons: Object.freeze(reasons), retained: [], removed: [], migrated: null,
76
+ });
77
+
78
+ /** Where the doctrine section starts and ends, or `null` when the file has none. */
79
+ function sectionRange(lines) {
80
+ const start = lines.findIndex((line) => DOCTRINE_SECTION_PATTERN.test(line));
81
+ if (start < 0) return null;
82
+ const rest = lines.slice(start + 1).findIndex((line) => SECTION_END_PATTERN.test(line));
83
+ return { start, end: rest < 0 ? lines.length : start + 1 + rest };
84
+ }
85
+
86
+ /**
87
+ * The section body as blocks: one per bullet, wrapped continuation lines kept
88
+ * with the bullet they belong to, and any non-bullet prose as its own block so
89
+ * it is classified rather than swept along.
90
+ */
91
+ export function doctrineBlocks(bodyLines) {
92
+ const blocks = [];
93
+ let open = null;
94
+ for (const line of bodyLines) {
95
+ if (line.trim() === '') { open = null; continue; }
96
+ const bullet = /^-\s/.test(line);
97
+ if (bullet || open === null) {
98
+ open = { kind: bullet ? 'bullet' : 'prose', lines: [] };
99
+ blocks.push(open);
100
+ }
101
+ open.lines.push(line);
102
+ }
103
+ return blocks;
104
+ }
105
+
106
+ const matched = (text, markers) => markers.filter(({ pattern }) => pattern.test(text))
107
+ .map(({ id }) => id);
108
+
109
+ /** Split the section's blocks into table data, retained judgment, and the unplaceable rest. */
110
+ export function classifyDoctrine(blocks) {
111
+ const retained = [];
112
+ const removed = [];
113
+ const unclassified = [];
114
+ for (const block of blocks) {
115
+ const text = block.lines.join(' ');
116
+ const markers = matched(text, ROUTING_DATA_MARKERS);
117
+ const judgments = matched(text, RETAINED_JUDGMENTS);
118
+ if (markers.length) removed.push({ ...block, markers });
119
+ else if (judgments.length) retained.push({ ...block, judgments });
120
+ else unclassified.push(block);
121
+ }
122
+ return { retained, removed, unclassified };
123
+ }
124
+
125
+ const rebuild = (lines, range, retained) => [
126
+ ...lines.slice(0, range.start), lines[range.start], '', ...PRECEDENCE_BULLET.split('\n'),
127
+ ...retained.flatMap((block) => block.lines), '', ...lines.slice(range.end),
128
+ ].join('\n');
129
+
130
+ /**
131
+ * The exact removal, decided from the file text alone. Returns the migrated
132
+ * text only when every block was placed and every promised judgment survived.
133
+ */
134
+ export function planDoctrineMigration(text) {
135
+ const lines = text.split('\n');
136
+ const range = sectionRange(lines);
137
+ if (!range) return blocked('section-missing', ['no `## Task-Routing` section']);
138
+ const body = lines.slice(range.start + 1, range.end);
139
+ if (body.some((line) => line.includes(PRECEDENCE_MARKER))) {
140
+ return Object.freeze({
141
+ status: 'already-migrated', reasons: [], retained: [], removed: [], migrated: text,
142
+ });
143
+ }
144
+ const { retained, removed, unclassified } = classifyDoctrine(doctrineBlocks(body));
145
+ if (unclassified.length) {
146
+ return blocked('blocked', unclassified.map((block) => `unclassified: ${block.lines[0]}`));
147
+ }
148
+ const kept = new Set(retained.flatMap((block) => block.judgments));
149
+ const lost = RETAINED_JUDGMENTS.filter(({ id }) => !kept.has(id));
150
+ if (lost.length) {
151
+ return blocked('blocked', lost.map(({ id }) => `judgment-not-retained: ${id}`));
152
+ }
153
+ return Object.freeze({
154
+ status: 'ready',
155
+ reasons: [],
156
+ retained: Object.freeze(retained),
157
+ removed: Object.freeze(removed),
158
+ migrated: rebuild(lines, range, retained),
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Who decides model and effort right now. The doctrine text is deliberately not
164
+ * read: precedence is a property of the configuration, so the answer cannot
165
+ * depend on whether the old table has been removed yet. `tablePresent` only
166
+ * records whether the removal still has anything to remove.
167
+ */
168
+ export function resolveDoctrinePrecedence({ composed, tablePresent = false, reasons = [] }) {
169
+ const routes = composed?.standardRoutes ?? {};
170
+ const decides = Object.entries(routes)
171
+ .filter(([, route]) => route?.state === 'configured')
172
+ .map(([workload, route]) => Object.freeze({
173
+ workload, model: route.model, effort: route.effort ?? null,
174
+ }));
175
+ return Object.freeze({
176
+ source: decides.length ? 'routing-profile' : 'doctrine',
177
+ decides: Object.freeze(decides),
178
+ tablePresent: Boolean(tablePresent),
179
+ supersededTable: Boolean(decides.length && tablePresent),
180
+ reasons: Object.freeze([...reasons]),
181
+ });
182
+ }
183
+
184
+ /** The same answer read from the real two-level Routing profile store. */
185
+ export async function readDoctrinePrecedence({ tablePresent = false, ...options } = {}) {
186
+ try {
187
+ const snapshot = await readComposedRoutingProfile({
188
+ projectRoot: process.cwd(), ...options,
189
+ });
190
+ return resolveDoctrinePrecedence({
191
+ composed: snapshot.composed, tablePresent, reasons: snapshot.reasons,
192
+ });
193
+ } catch (error) {
194
+ return resolveDoctrinePrecedence({
195
+ composed: null, tablePresent, reasons: [`profile-unreadable: ${error.message}`],
196
+ });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Read the file, plan the removal, report it — the fingerprint the apply step
202
+ * re-checks, the backup path it would write, the exact diff. Writes nothing.
203
+ */
204
+ export async function previewDoctrineMigration({
205
+ path, now = new Date(), resolvePrecedence = readDoctrinePrecedence,
206
+ }) {
207
+ const text = await readFile(path, 'utf8');
208
+ const plan = planDoctrineMigration(text);
209
+ const stamp = stampOf(now);
210
+ return Object.freeze({
211
+ path,
212
+ status: plan.status,
213
+ reasons: plan.reasons,
214
+ retained: plan.retained,
215
+ removed: plan.removed,
216
+ migrated: plan.migrated,
217
+ fingerprint: sha256(text),
218
+ stamp,
219
+ backupPath: `${path}.${stamp}.bak`,
220
+ diff: plan.migrated === null || plan.migrated === text ? '' : lineDiff(text, plan.migrated),
221
+ precedence: await resolvePrecedence({ tablePresent: plan.removed.length > 0 }),
222
+ });
223
+ }
224
+
225
+ const refused = (reason, preview) => Object.freeze({
226
+ status: 'refused', reason, path: preview.path, backupPath: null,
227
+ });
228
+
229
+ /**
230
+ * Write the previewed removal — and only that. Every gate below is a separate
231
+ * reason a caller can report, because "it did not run" is not a diagnosis.
232
+ */
233
+ export async function applyDoctrineMigration({ preview, accept = false }) {
234
+ if (preview.status === 'already-migrated') {
235
+ return Object.freeze({ status: 'already-migrated', path: preview.path, backupPath: null });
236
+ }
237
+ if (accept !== true) return refused('acceptance-required', preview);
238
+ if (preview.status !== 'ready') return refused(`not-ready: ${preview.status}`, preview);
239
+ if (preview.precedence?.source !== 'routing-profile') {
240
+ return refused('doctrine-table-still-authoritative', preview);
241
+ }
242
+ const current = await readFile(preview.path, 'utf8');
243
+ if (sha256(current) !== preview.fingerprint) return refused('destination-changed', preview);
244
+ const backupPath = await backupFile(preview.path, preview.stamp);
245
+ await writeAtomic(preview.path, preview.migrated);
246
+ return Object.freeze({ status: 'applied', path: preview.path, backupPath });
247
+ }
248
+
249
+ function parseArgs(argv) {
250
+ const args = { apply: false, accept: false, file: null };
251
+ for (let i = 0; i < argv.length; i += 1) {
252
+ if (argv[i] === '--apply') args.apply = true;
253
+ else if (argv[i] === '--accept') args.accept = true;
254
+ else if (argv[i] === '--file') args.file = argv[i += 1];
255
+ else throw new Error(`unknown argument: ${argv[i]}`);
256
+ }
257
+ return args;
258
+ }
259
+
260
+ function render(preview, out) {
261
+ const { precedence } = preview;
262
+ out.log(`file: ${preview.path}`);
263
+ out.log(`status: ${preview.status}`);
264
+ for (const reason of preview.reasons) out.log(` reason: ${reason}`);
265
+ out.log(`precedence: ${precedence.source}`
266
+ + (precedence.supersededTable ? ' (the table below is already inert)' : ''));
267
+ for (const { workload, model, effort } of precedence.decides) {
268
+ out.log(` ${workload}: ${model}${effort ? ` ${effort}` : ''}`);
269
+ }
270
+ for (const reason of precedence.reasons) out.log(` profile: ${reason}`);
271
+ out.log(`backup: ${preview.backupPath}`);
272
+ out.log(preview.diff ? `\n${preview.diff}` : '\n(no change)');
273
+ }
274
+
275
+ export async function main(argv = process.argv.slice(2), out = console) {
276
+ const args = parseArgs(argv);
277
+ const path = args.file ?? join(homedir(), '.claude', 'CLAUDE.md');
278
+ const preview = await previewDoctrineMigration({ path }).catch((error) => error);
279
+ if (preview instanceof Error) {
280
+ out.log(`file: ${path}\nstatus: unreadable: ${preview.code ?? preview.message}`);
281
+ return 1;
282
+ }
283
+ render(preview, out);
284
+ if (!args.apply) {
285
+ out.log('\npreview only — re-run with --apply --accept to write it.');
286
+ return preview.status === 'ready' || preview.status === 'already-migrated' ? 0 : 1;
287
+ }
288
+ const result = await applyDoctrineMigration({ preview, accept: args.accept });
289
+ out.log(`\n${result.status}${result.reason ? `: ${result.reason}` : ''}`);
290
+ if (result.backupPath) out.log(`backup written: ${result.backupPath}`);
291
+ return result.status === 'refused' ? 1 : 0;
292
+ }
293
+
294
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
295
+ process.exitCode = await main();
296
+ }
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { verifyBundle } from '../src/lib/bundle.mjs';
8
9
  import { applyProjectRelease } from '../src/lib/release-apply.mjs';
9
10
  import { previewProjectRelease } from '../src/lib/release-preview.mjs';
10
11
  import { nextVersion } from '../src/lib/semver.mjs';
@@ -50,12 +51,31 @@ async function updateMetadata(repoRoot, targetVersion, delta) {
50
51
  return resumed;
51
52
  }
52
53
 
53
- async function freshManifest(repoRoot) {
54
+ /**
55
+ * Build the bundle and keep it on disk until it has been verified — the
56
+ * manifest is only believable together with the bytes it describes. The build
57
+ * reads the committed, provenance-hashed inventory snapshots from the
58
+ * repository; nothing here reaches the network.
59
+ */
60
+ async function freshBundle(repoRoot) {
54
61
  const distDir = await mkdtemp(join(tmpdir(), 'awkit-release-'));
55
62
  try {
56
63
  await buildKit({ repoRoot, distDir });
57
- return JSON.parse(await readFile(join(distDir, 'agent-workflow-kit.package.json'), 'utf8'));
58
- } finally { await rm(distDir, { recursive: true, force: true }); }
64
+ return {
65
+ manifest: JSON.parse(await readFile(join(distDir, 'agent-workflow-kit.package.json'), 'utf8')),
66
+ bundleRoot: distDir,
67
+ cleanup: () => rm(distDir, { recursive: true, force: true }),
68
+ };
69
+ } catch (error) {
70
+ await rm(distDir, { recursive: true, force: true });
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ function assertVerified(report) {
76
+ if (report.ok) return;
77
+ const detail = report.findings.map(({ check, detail: line }) => ` ${check}: ${line}`).join('\n');
78
+ throw new Error(`bundle verification failed:\n${detail}`);
59
79
  }
60
80
 
61
81
  async function defaultRun(command, args, repoRoot) {
@@ -66,16 +86,28 @@ export async function prepareRelease(options) {
66
86
  const { repoRoot, targetVersion, delta } = options;
67
87
  if (!/^\d+\.\d+\.\d+$/.test(targetVersion)) throw new Error(`invalid target version: ${targetVersion}`);
68
88
  const resumed = await updateMetadata(repoRoot, targetVersion, delta);
69
- const manifest = await (options.buildManifest ?? freshManifest)(repoRoot);
70
- if (manifest.kitVersion !== targetVersion) {
71
- throw new Error(`built manifest version ${manifest.kitVersion} != target ${targetVersion}`);
72
- }
73
- await writeFile(join(repoRoot, 'agent-workflow-kit.package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
89
+ const built = await (options.buildBundle ?? freshBundle)(repoRoot);
90
+ let report;
91
+ try {
92
+ const { manifest } = built;
93
+ if (manifest.kitVersion !== targetVersion) {
94
+ throw new Error(`built manifest version ${manifest.kitVersion} != target ${targetVersion}`);
95
+ }
96
+ report = await (options.verifyBundle ?? verifyBundle)({
97
+ bundleRoot: built.bundleRoot, manifest,
98
+ });
99
+ assertVerified(report);
100
+ await writeFile(join(repoRoot, 'agent-workflow-kit.package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
101
+ } finally { await built.cleanup?.(); }
74
102
  const run = options.run ?? ((command, args) => defaultRun(command, args, repoRoot));
75
103
  await run('npm', ['run', 'release:guard']);
76
104
  await run('npm', ['test']);
77
105
  await run('npm', ['pack', '--dry-run']);
78
- return { status: resumed ? 'resumed' : 'prepared', targetVersion };
106
+ return {
107
+ status: resumed ? 'resumed' : 'prepared',
108
+ targetVersion,
109
+ inventoryRevision: report.inventoryRevision,
110
+ };
79
111
  }
80
112
 
81
113
  async function main() {