simt-dev 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -0,0 +1,346 @@
1
+ // The packet, assembled — one implementation behind three doors.
2
+ //
3
+ // `simt brief` prints it, MCP's `brief` tool returns it as a payload, and a prompt-submit
4
+ // hook delivers it into a conversation. The three differ in how the bytes leave — stdout,
5
+ // a JSON result, a harness's own context key — and in nothing else, so the assembly lives
6
+ // here and each caller renders. A second copy of this logic would be a second answer to
7
+ // "what does this repository know about this task", and the two would drift the first time
8
+ // a section gained a caution.
9
+ //
10
+ // The two properties that make a packet worth reading belong to the assembly, not to any
11
+ // one renderer:
12
+ //
13
+ // * Deterministic, until the repository names a server. No clock, no locale, no network,
14
+ // no environment beyond the paths git reports, so the same tree gives the same items —
15
+ // which is what makes a packet reviewable: a diff in a packet is a diff in what every
16
+ // agent reads next. The one exception is opt-in and declared: a `federation` block in
17
+ // the manifest lets the packet cite an external server's answer, and that answer is
18
+ // cited precisely because it is the one item a reader cannot reproduce from the tree.
19
+ // * Cheap. Warm, this reads the index, the command card, the decisions, the lesson
20
+ // scopes and the skill registry, and walks nothing else. `simt tests <path>` is the
21
+ // authoritative selector for the tests of a file — it reads test blobs and commit
22
+ // history to find multi-line and dynamic imports — and this deliberately does not,
23
+ // because a brief is delivered on every prompt submit and 300 ms is the bar for that
24
+ // (plan 0003 R29).
25
+ //
26
+ // `build` is the one behaviour that depends on the caller. A delivered packet passes
27
+ // `build: false`, so the first prompt in a fresh clone reports a cold index instead of
28
+ // waiting on a repository walk; the command and the MCP tool, which a person asked for,
29
+ // build the index they need.
30
+ //
31
+ // `federate` is the second, and it exists to keep the delivery's latency promise honest: a
32
+ // packet delivered into a prompt submits within 300 ms, and starting someone else's server
33
+ // is not a wait a prompt submit may take, so that one caller turns federation off. The
34
+ // command and the tool leave it on. It is inert either way in a repository that declared no
35
+ // servers.
36
+
37
+ import { CLI } from '../name.mjs';
38
+ import { DEFAULT_BRIEF_BYTES, buildPacket, collect } from './packet.mjs';
39
+ import { classifyTask, cardVerbs } from './kind.mjs';
40
+ import { commandCard } from '../kernel/card.mjs';
41
+ import { decisionHits, scopeHits, sourceRoots } from '../index/govern.mjs';
42
+ import { embedTaskQuery, rankFiles, renderMap } from '../index/map.mjs';
43
+ import { SQLITE_FILE } from '../index/persist.mjs';
44
+ import { searchFtsBm25 } from '../index/store.mjs';
45
+ import { testEdges, testsFor } from '../index/tests.mjs';
46
+ import { loadRegistry } from '../graph/load.mjs';
47
+ import { fingerprint } from '../resolve/fingerprint.mjs';
48
+ import { closure, rankSkills, selectWithinBudget, tokenize } from '../resolve/resolve.mjs';
49
+ import { loadGraph } from '../commands/map.mjs';
50
+ import { dirtyPaths, parsePathList } from '../commands/skills.mjs';
51
+ import { decisionRecords, lessonScopes } from '../commands/why.mjs';
52
+ import { readFederation } from '../federation/config.mjs';
53
+ import { consult } from '../federation/query.mjs';
54
+
55
+ /**
56
+ * How many ranked files the packet may consider as candidates for other sections.
57
+ *
58
+ * The FILES section is bounded by the budget, but TESTS and DECISIONS are asked "what
59
+ * covers the likely files" — a question a packet of any size can only answer about a
60
+ * handful of files before the answer stops being about the task.
61
+ */
62
+ const LIKELY_FILES = 3;
63
+
64
+ /**
65
+ * How generously the map is allowed to render before the packet's own budget trims it.
66
+ *
67
+ * The packer can only drop what it is given, so the candidate list needs a bound. Eight
68
+ * times the ceiling is that bound: large enough that the trimmed packet is a choice about
69
+ * relevance rather than an artefact of the cut, and small enough that a brief on an
70
+ * enormous repository never renders the whole map.
71
+ */
72
+ const MAP_FACTOR = 8;
73
+
74
+ /**
75
+ * The tool's own state directory, read from the path the index is written to.
76
+ *
77
+ * A brief asks git what a task touches, and the index it just read the answer from is a
78
+ * write: in a repository that has not gitignored this directory, the second run of the
79
+ * same brief would report a dirty path the first one did not, and a packet that changes
80
+ * between two identical commands is not the packet this promises. The tool's own scratch
81
+ * space is never part of a task's scope.
82
+ */
83
+ const STATE_DIR = `${SQLITE_FILE.split('/')[0]}/`;
84
+
85
+ /** Every file git tracks that carries a generated region, one `git grep` away. */
86
+ async function generatedRegions(root) {
87
+ const { execFile } = await import('node:child_process');
88
+ const { promisify } = await import('node:util');
89
+ try {
90
+ const { stdout } = await promisify(execFile)(
91
+ 'git',
92
+ ['grep', '-lF', '--', `${'simt'}:begin`],
93
+ { cwd: root, maxBuffer: 8 << 20 },
94
+ );
95
+ return stdout.split('\n').filter((line) => line.length > 0).sort();
96
+ } catch {
97
+ // No match is exit 1, and a directory that is not a repository fails too. Both mean
98
+ // the same thing to a reader: no generated region was found to warn about.
99
+ return [];
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Assemble one packet for a task.
105
+ *
106
+ * @param {object} [options]
107
+ * @param {string} [options.root] the repository
108
+ * @param {string} [options.task] the task text; the empty string is a legitimate task
109
+ * @param {string|null} [options.paths] a `--paths` string, or null to take the scope from
110
+ * the files git reports as modified
111
+ * @param {number} [options.budget] the byte ceiling; 0 means the whole packet, which no
112
+ * delivered brief may ask for
113
+ * @param {object} [options.flags] passed through to the skill registry loader
114
+ * @param {boolean} [options.build] whether an empty index may be built here
115
+ * @param {boolean} [options.federate] whether a declared external server may be asked one
116
+ * question; a delivered packet turns it off to keep its 300 ms bar
117
+ * @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
118
+ * `{ cold: true }` when the index is empty and this caller may not build one
119
+ */
120
+ export async function assembleBrief({
121
+ root = process.cwd(),
122
+ task = '',
123
+ paths = null,
124
+ budget = DEFAULT_BRIEF_BYTES,
125
+ flags = {},
126
+ build = true,
127
+ federate = true,
128
+ } = {}) {
129
+ const named = paths !== null;
130
+ const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(STATE_DIR));
131
+ const scope = named ? parsePathList(paths) : dirty;
132
+
133
+ const graph = await loadGraph(root, { build });
134
+ if (graph.error) return { error: graph.error };
135
+ if (graph.cold) return { cold: true };
136
+ const { files, symbols, edges, fts = [], embeddings = [] } = graph;
137
+ const filePaths = new Set(files.map((file) => file.path));
138
+
139
+ const { kind, basis } = classifyTask(task, { paths: scope });
140
+ const packet = collect();
141
+ packet.add('TASK', `kind ${kind} — ${basis}`, {
142
+ hard: true,
143
+ why: ['the kind chooses the commands, and the commands are the part a reader cannot recover from a wrong guess'],
144
+ });
145
+ packet.add('TASK', `task ${task.length > 0 ? task : '(none given)'}`, { hard: true, why: ['the packet is an answer to this'] });
146
+ packet.add('TASK', `scope ${scope.length > 0 ? scope.join(', ') : 'the whole repository'}`, {
147
+ why: [named ? 'named with --paths' : 'taken from the files git reports as modified'],
148
+ });
149
+ if (task.length === 0 && !named) {
150
+ packet.add('CAUTIONS', 'no task was given, so the packet describes the working tree rather than a task', {
151
+ why: ['an unasked question has no answer to be short of'],
152
+ });
153
+ }
154
+
155
+ const repo = await fingerprint(root);
156
+ const card = await commandCard({ root, manifest: repo.manifest ?? null, stack: repo.stack, facts: repo.facts });
157
+ const wanted = cardVerbs(kind);
158
+ const chosen = card.lines.filter((line) => wanted.includes(line.verb));
159
+ for (const line of chosen.length > 0 ? chosen : card.lines) {
160
+ packet.add('COMMANDS', `${line.label} ${line.command}`, {
161
+ hard: line.verb === 'test' || line.verb === 'focused',
162
+ why: [`the ${kind} kind needs ${line.verb}`],
163
+ });
164
+ }
165
+ if (card.lines.length === 0) {
166
+ packet.add('CAUTIONS', 'the repository declares no evidence of how it is built, so no command was offered', {
167
+ why: ['a card is read from committed evidence, never invented from a guess'],
168
+ });
169
+ }
170
+ for (const verb of card.dropped) {
171
+ packet.add('COMMANDS', `dropped by the card's own 600-byte ceiling: ${verb}`, {
172
+ why: ['the card keeps its own budget before this packet spends any of it'],
173
+ });
174
+ }
175
+
176
+ const bm25 = task.trim().length > 0 ? await searchFtsBm25(root, task, { fts }) : [];
177
+ const queryVector =
178
+ flags?.embed === true && embeddings.length > 0
179
+ ? await embedTaskQuery(root, task, {
180
+ embed: true,
181
+ allowRemote: flags?.allowRemote === true,
182
+ })
183
+ : null;
184
+ const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
185
+ const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
186
+ for (const line of rendered.text.split('\n')) {
187
+ packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
188
+ }
189
+ if (rendered.truncated) {
190
+ packet.add('CAUTIONS', `the map held back ${rendered.total - rendered.shown} of ${rendered.total} files`, {
191
+ why: [`\`${CLI} map --budget 0\` lists them all`],
192
+ });
193
+ }
194
+
195
+ const likely = ranked.slice(0, LIKELY_FILES).map((row) => row.path);
196
+ const known = testEdges({ files, imports: edges.filter((edge) => edge.kind === 'import') });
197
+ const untested = [];
198
+ packet.add('TESTS', `the index knows direct importers; \`${CLI} tests <path>\` is the authority for multi-line and dynamic imports`, {
199
+ why: ['a cheap packet and an exact answer are different questions'],
200
+ });
201
+ for (const path of likely) {
202
+ const found = testsFor(path, known);
203
+ if (found.length === 0) untested.push(path);
204
+ else packet.add('TESTS', `${path} -> ${found.join(' ')}`, { why: ['the test imports it, or sits beside it'] });
205
+ }
206
+
207
+ const records = await decisionRecords(root);
208
+ const ctx = {
209
+ files: filePaths,
210
+ commands: new Set(card.lines.map((line) => line.command)),
211
+ roots: sourceRoots([...filePaths]),
212
+ };
213
+ const decisions = new Map();
214
+ const unresolved = new Set();
215
+ for (const path of likely) {
216
+ const hit = decisionHits(records, path, ctx);
217
+ for (const entry of hit.hits) if (!decisions.has(entry.id)) decisions.set(entry.id, { ...entry, path });
218
+ for (const entry of hit.unresolved) unresolved.add(entry.token);
219
+ }
220
+ for (const id of [...decisions.keys()].sort()) {
221
+ const entry = decisions.get(id);
222
+ packet.add('DECISIONS', `${entry.id} — ${entry.detail}`, { why: [`names ${entry.path}`] });
223
+ }
224
+ if (unresolved.size > 0) {
225
+ packet.add('CAUTIONS', `decisions name tokens nothing resolves: ${[...unresolved].sort().join(', ')}`, {
226
+ why: [`\`${CLI} why\` reports them`],
227
+ });
228
+ }
229
+
230
+ if (federate) {
231
+ const { servers, findings } = await readFederation(root);
232
+ for (const finding of findings) {
233
+ packet.add('CAUTIONS', `federation: ${finding}`, {
234
+ why: ['a server the manifest names is skipped rather than guessed at'],
235
+ });
236
+ }
237
+ if (servers.length > 0) {
238
+ for (const server of servers) {
239
+ if (server.languages.length > 0) {
240
+ packet.add('FEDERATION', `${server.name} covers ${[...server.languages].sort().join(', ')}`, {
241
+ why: ['the answer for a covered language can come from this server'],
242
+ });
243
+ }
244
+ }
245
+ // One question, and the task is the question. With no task text there is still a
246
+ // question worth asking — the file the index ranks highest — and with neither there
247
+ // is nothing to ask, which is a caution rather than a fabricated query.
248
+ const question = task.trim().length > 0 ? task.trim() : (likely[0] ?? '');
249
+ if (question.length === 0) {
250
+ packet.add('CAUTIONS', 'federation is declared but this packet has no task to ask about', {
251
+ why: ['a structural question needs something to be about'],
252
+ });
253
+ } else {
254
+ const asked = await consult({ root, query: question, servers });
255
+ for (const answer of asked.answers) {
256
+ packet.add('FEDERATION', `${answer.server} — ${answer.text}`, {
257
+ why: [`\`${answer.server}\`, declared under \`federation.servers\`, answered \`${answer.tool}\``],
258
+ });
259
+ }
260
+ for (const finding of asked.findings) {
261
+ packet.add('CAUTIONS', `federation: ${finding}`, {
262
+ why: ['the brief carries on; a server that is down is not a task that cannot start'],
263
+ });
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ const loaded = await loadRegistry(null, flags);
270
+ if (!loaded.ok) {
271
+ packet.add('CAUTIONS', `the skill registry did not load (${loaded.errors[0]}), so no skill was offered`, {
272
+ why: ['every other section is independent of the registry, which is why this is a caution and not a failure'],
273
+ });
274
+ } else {
275
+ const rankedSkills = rankSkills(
276
+ loaded.registry.skills,
277
+ { repoStack: repo.signals, taskTokens: tokenize(task), paths: scope },
278
+ { forced: [] },
279
+ );
280
+ const expanded = closure(rankedSkills.selected, loaded.registry.skills);
281
+ const allowance = budget > 0 ? Math.floor(budget / 4) : 0;
282
+ const selection = selectWithinBudget(expanded, { budget: allowance, all: false });
283
+ for (const entry of selection.picked) {
284
+ const reasons = [...entry.reasons];
285
+ packet.add('SKILLS', `${entry.skill.name} — ${entry.skill.bytes} B, ${reasons.join(' ')}`, {
286
+ why: [`\`${CLI} skills show ${entry.skill.name}\` reads the body`],
287
+ });
288
+ }
289
+ if (selection.deferred.length > 0) {
290
+ packet.add('CAUTIONS', `skills held back for the budget: ${selection.deferred.map((entry) => entry.skill.name).sort().join(', ')}`, {
291
+ why: ['a body is not worth a command that cannot be run'],
292
+ });
293
+ }
294
+ }
295
+
296
+ const lessonTargets = [...new Set([...scope, ...likely])];
297
+ const taskTokenSet = new Set(tokenize(task));
298
+ for (const lesson of await lessonScopes(root)) {
299
+ const pathHits = lessonTargets.flatMap((path) => scopeHits(path, { lessons: [lesson] }));
300
+ const keywordMatch =
301
+ pathHits.length === 0 &&
302
+ Array.isArray(lesson.keywords) &&
303
+ lesson.keywords.some((kw) => taskTokenSet.has(String(kw).toLowerCase())) &&
304
+ [...filePaths].some((path) => scopeHits(path, { lessons: [lesson] }).length > 0);
305
+ if (pathHits.length > 0 || keywordMatch) {
306
+ const body =
307
+ lesson.paragraph && lesson.paragraph !== lesson.detail
308
+ ? `${lesson.detail}: ${lesson.paragraph.replace(/\s+/g, ' ').trim()}`
309
+ : lesson.detail;
310
+ packet.add('LESSONS', `${lesson.id} — ${body}`, {
311
+ why: [pathHits.length > 0 ? 'a lesson names a path this task will touch' : 'a lesson trigger matches this task'],
312
+ });
313
+ }
314
+ }
315
+
316
+ const generated = await generatedRegions(root);
317
+ if (generated.length > 0) {
318
+ packet.add('CAUTIONS', `generated — edit the source, not these: ${generated.join(', ')}`, {
319
+ why: ['a hand edit inside a generated region is overwritten by the next projection'],
320
+ });
321
+ }
322
+ if (untested.length > 0) {
323
+ packet.add('CAUTIONS', `the index knows no test for ${untested.join(', ')}`, {
324
+ why: [`\`${CLI} tests <path>\` reads the blobs and may know one`],
325
+ });
326
+ }
327
+ if (named && dirty.length > 0) {
328
+ const outside = dirty.filter((path) => !scope.includes(path));
329
+ if (outside.length > 0) {
330
+ packet.add('CAUTIONS', `dirty and outside the scope, leave alone: ${outside.join(', ')}`, {
331
+ why: ['a change nobody asked for is a change nobody reviews'],
332
+ });
333
+ }
334
+ }
335
+
336
+ const packed = buildPacket(packet.items, { budget });
337
+ const cut = new Set(packed.cut);
338
+ const sections = {};
339
+ for (const item of packet.items) {
340
+ if (cut.has(item.id)) continue;
341
+ sections[item.section] ??= [];
342
+ sections[item.section].push(item.text);
343
+ }
344
+
345
+ return { root, kind, basis, task, scope, budget, items: packet.items, packed, sections };
346
+ }
@@ -0,0 +1,134 @@
1
+ // What a task is, and which part of the command card that kind needs (plan 0003 R27).
2
+ //
3
+ // A classification nobody can audit is an opinion. Every branch below names the pattern
4
+ // it matched and the field it matched in, and `--explain` prints exactly that, so "why is
5
+ // this a bugfix" has an answer that is a string rather than a shrug.
6
+ //
7
+ // Nothing here reads a clock, the environment or the network. The same task text and the
8
+ // same paths give the same kind on every machine, which is the first property the packet
9
+ // needs: byte-stable input, byte-stable output (plan 0003 P4).
10
+
11
+ /** The four kinds a brief knows. */
12
+ const KINDS = ['bugfix', 'feature', 'refactor', 'docs'];
13
+
14
+ /**
15
+ * The default kind is the widest one, and that is deliberate.
16
+ *
17
+ * Guessing `feature` for a task that was really a docs change costs the reader a couple
18
+ * of commands they did not need. Guessing `docs` for a task that was really a feature
19
+ * costs them the build command they did need, and they have no way to notice the
20
+ * absence — the brief looks complete either way.
21
+ */
22
+ const DEFAULT_KIND = 'feature';
23
+
24
+ /**
25
+ * Patterns per kind, tried in this order.
26
+ *
27
+ * The order is the decision. A task that says "fix the docs typo" is a bugfix, not a docs
28
+ * change, because the first pattern that matches wins and `fix` is tried before `docs`.
29
+ * `refactor` is tried before `docs` for the same reason: a rename is a rename even when
30
+ * the file it moves is markdown.
31
+ */
32
+ const HINTS = [
33
+ [
34
+ 'bugfix',
35
+ [
36
+ /\bfix(?:es|ed|ing)?\b/i,
37
+ /\bbug\b/i,
38
+ /\bregressions?\b/i,
39
+ /\bflaky\b/i,
40
+ /\bcrash(?:es|ed|ing)?\b/i,
41
+ /\bbroken\b/i,
42
+ /\bfail(?:s|ed|ing|ure)?\b/i,
43
+ /\bwrong\b/i,
44
+ ],
45
+ ],
46
+ [
47
+ 'refactor',
48
+ [
49
+ /\brefactor(?:s|ed|ing)?\b/i,
50
+ /\brename(?:s|d)?\b/i,
51
+ /\bextract(?:s|ed|ing)?\b/i,
52
+ /\bdeduplicat(?:e|es|ed|ion)\b/i,
53
+ /\brewrite\b/i,
54
+ /\bclean ?up\b/i,
55
+ /\bmove(?:s|d)?\b/i,
56
+ /\bsplit\b/i,
57
+ ],
58
+ ],
59
+ [
60
+ 'docs',
61
+ [
62
+ /\bdocs?\b/i,
63
+ /\bdocument(?:s|ed|ing|ation)?\b/i,
64
+ /\breadme\b/i,
65
+ /\bchangelog\b/i,
66
+ /\btyp(?:o|os)\b/i,
67
+ /\bcomments?\b/i,
68
+ /\bwording\b/i,
69
+ /\bprose\b/i,
70
+ ],
71
+ ],
72
+ ];
73
+
74
+ /** Extensions that are prose rather than code. */
75
+ const DOC_EXT = new Set(['md', 'mdx', 'markdown', 'txt', 'rst', 'adoc']);
76
+
77
+ /** Files whose whole name is prose. `LICENSE` has no extension to look at. */
78
+ const DOC_NAMES = new Set(['LICENSE', 'NOTICE', 'COPYING', 'AUTHORS', 'CONTRIBUTING']);
79
+
80
+ /** Whether a path is documentation. */
81
+ function isDocPath(path) {
82
+ const name = String(path ?? '').split('/').pop() ?? '';
83
+ if (DOC_NAMES.has(name)) return true;
84
+ const dot = name.lastIndexOf('.');
85
+ return dot > 0 && DOC_EXT.has(name.slice(dot + 1).toLowerCase());
86
+ }
87
+
88
+ /**
89
+ * The card verbs a kind needs, in the card's own order.
90
+ *
91
+ * `test` is in every list. The command card drops `format`, `typecheck`, `lint` and
92
+ * `build` before it drops `test`, for the reason written in `kernel/card.mjs`: a card
93
+ * that dropped the test command would have dropped the one command it exists to carry. A
94
+ * brief inherits that ruling rather than restating it.
95
+ *
96
+ * A docs change is the narrowest list — there is nothing to build and nothing to
97
+ * type-check — and it still keeps `test`, because a documentation change is not exempt
98
+ * from the command that says the repository still works.
99
+ */
100
+ const VERBS_FOR_KIND = {
101
+ bugfix: ['test', 'focused', 'lint', 'format'],
102
+ feature: ['test', 'focused', 'lint', 'format', 'typecheck', 'build'],
103
+ refactor: ['test', 'focused', 'lint', 'format', 'typecheck'],
104
+ docs: ['format', 'lint', 'test'],
105
+ };
106
+
107
+ /** The card verbs a kind needs. An unknown kind gets the widest list. */
108
+ export function cardVerbs(kind) {
109
+ return VERBS_FOR_KIND[kind] ?? VERBS_FOR_KIND[DEFAULT_KIND];
110
+ }
111
+
112
+ /**
113
+ * Classify a task.
114
+ *
115
+ * The evidence comes back with the kind, always. A caller that has to re-derive why a
116
+ * task is a bugfix will disagree with this function eventually, and the disagreement
117
+ * will be invisible — which is the failure mode a reasoning string prevents.
118
+ */
119
+ export function classifyTask(task, { paths = [] } = {}) {
120
+ const text = String(task ?? '');
121
+ for (const [kind, patterns] of HINTS) {
122
+ for (const pattern of patterns) {
123
+ if (pattern.test(text)) return { kind, basis: `the task matches ${pattern}` };
124
+ }
125
+ }
126
+ const named = paths.filter((path) => typeof path === 'string' && path.length > 0);
127
+ if (named.length > 0 && named.every(isDocPath)) {
128
+ return { kind: 'docs', basis: `every path named is documentation (${named.length})` };
129
+ }
130
+ return {
131
+ kind: DEFAULT_KIND,
132
+ basis: 'neither the task nor the paths chose a kind, so the widest one is the default',
133
+ };
134
+ }
@@ -0,0 +1,197 @@
1
+ // The brief packet: one document inside a byte ceiling (plan 0003 R27).
2
+ //
3
+ // This module is pure. It reads no filesystem, no environment, no clock and no locale —
4
+ // a packet built twice from the same items is the same bytes, which is the property the
5
+ // accept arm needs and the one a test can check.
6
+ //
7
+ // The packing rule is the kernel's, not a second one invented here: items go to
8
+ // `packToBudget` in the order they should be LOST, and the class marked `hard` is the
9
+ // floor. A brief is not a packet silently cut to size; it is a packet that says, in the
10
+ // packet, what it did not get.
11
+
12
+ import { packToBudget } from '../kernel/budget.mjs';
13
+
14
+ /** The ceiling a brief packs to unless it is told otherwise. */
15
+ export const DEFAULT_BRIEF_BYTES = 2048;
16
+
17
+ /**
18
+ * The most this tool will ever inject into somebody else's prompt (plan 0003 R29).
19
+ *
20
+ * The same number as the default and a different fact: the default is what a packet packs
21
+ * to, and this is the bar the delivery slice was accepted against. A repository may lower
22
+ * its own ceiling; it may not raise it past a number that was measured, because a manifest
23
+ * that could raise it would make an unmeasured figure the promise the tool keeps.
24
+ */
25
+ export const PROMPT_CAP_BYTES = 2048;
26
+
27
+ /**
28
+ * The sections, in the order a reader meets them.
29
+ *
30
+ * The order is fixed and identical in every packet. A reader who has to search for the
31
+ * command is paying the cost the packet exists to remove.
32
+ */
33
+ const SECTIONS = ['TASK', 'COMMANDS', 'FILES', 'TESTS', 'DECISIONS', 'FEDERATION', 'SKILLS', 'LESSONS', 'CAUTIONS'];
34
+
35
+ /**
36
+ * What each section is worth when something has to go.
37
+ *
38
+ * The numbers are a decision, so they are written here where a reader can argue with
39
+ * them, rather than spread through the packing code as conditions. They answer one
40
+ * question: what would a reader rather lose? Cautions they can rediscover, then lessons,
41
+ * then skill pointers (one command answers all three), then decisions, then tests, then
42
+ * files. Commands and the task are why the packet exists.
43
+ *
44
+ * Federation sits with the decisions, and for the same reason: an external server's answer
45
+ * names a source a reader can go back to, so losing it costs a question rather than a fact.
46
+ * A section added later takes a value beside its neighbours rather than renumbering them —
47
+ * the numbers are a standing answer to "what would a reader rather lose", and a new section
48
+ * is not a reason to change what an old one is worth.
49
+ */
50
+ const SECTION_VALUE = {
51
+ CAUTIONS: 1,
52
+ LESSONS: 2,
53
+ SKILLS: 3,
54
+ DECISIONS: 4,
55
+ FEDERATION: 4,
56
+ TESTS: 5,
57
+ FILES: 6,
58
+ COMMANDS: 7,
59
+ TASK: 8,
60
+ };
61
+
62
+ /**
63
+ * The floor. An item marked `hard` is never cut, whatever the ceiling.
64
+ *
65
+ * The list is short on purpose: the task itself and the commands that say whether the
66
+ * change worked. Everything else is recoverable with one more command; these are the ones
67
+ * whose absence turns the packet into a quiet lie about what the repository needs.
68
+ */
69
+ const HARD = 'hard';
70
+ const SOFT = 'soft';
71
+
72
+ /**
73
+ * A packet under construction.
74
+ *
75
+ * Within a section the insertion order IS the relevance order — the packet loses from the
76
+ * end of a section — so a caller adds the most valuable item first instead of inventing a
77
+ * score. A number per item would be a second thing to keep consistent with the order, and
78
+ * the two would drift.
79
+ */
80
+ export function collect() {
81
+ const items = [];
82
+ return {
83
+ items,
84
+ /**
85
+ * Add one line. An empty or non-string line is not added: a blank item is a byte the
86
+ * packet spends saying nothing.
87
+ */
88
+ add(section, text, { hard = false, why = [] } = {}) {
89
+ if (!SECTIONS.includes(section)) throw new Error(`unknown brief section ${section}`);
90
+ if (typeof text !== 'string' || text.length === 0) return this;
91
+ const order = items.length;
92
+ items.push({ id: String(order), section, text, priority: hard ? HARD : SOFT, order, why });
93
+ return this;
94
+ },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The order items are lost in: least valuable first.
100
+ *
101
+ * Between sections by `SECTION_VALUE`; inside a section the last one added goes first.
102
+ * `hard` items sort to the end, where `packToBudget` refuses to cut them. The order is
103
+ * made total by the insertion index, because a comparator that returns 0 for two
104
+ * different items leaves the result to the sort implementation — a determinism hole in a
105
+ * command whose whole claim is determinism.
106
+ */
107
+ function cutOrder(items) {
108
+ const ranked = [...items].sort((a, b) => {
109
+ if (a.priority !== b.priority) return a.priority === HARD ? 1 : -1;
110
+ const value = SECTION_VALUE[a.section] - SECTION_VALUE[b.section];
111
+ if (value !== 0) return value;
112
+ return b.order - a.order;
113
+ });
114
+ return ranked.map(({ id, priority }) => ({ id, priority }));
115
+ }
116
+
117
+ /**
118
+ * Render the packet, minus the items that were cut.
119
+ *
120
+ * A section with nothing left prints nothing at all, not an empty heading. The `cut` line
121
+ * is part of the document rather than a footnote, because the reader is the one who has to
122
+ * decide whether to ask for more — and a packet that shrank without saying so is the
123
+ * failure this command was written to avoid.
124
+ */
125
+ function renderPacket(items, { cut = new Set(), budget = DEFAULT_BRIEF_BYTES } = {}) {
126
+ const lines = [];
127
+ for (const section of SECTIONS) {
128
+ const kept = items.filter((item) => item.section === section && !cut.has(item.id));
129
+ if (kept.length === 0) continue;
130
+ lines.push(section);
131
+ for (const item of kept) for (const line of item.text.split('\n')) lines.push(` ${line}`);
132
+ }
133
+ const lost = items.filter((item) => cut.has(item.id));
134
+ if (lost.length > 0) {
135
+ const where = SECTIONS.filter((section) => lost.some((item) => item.section === section)).map(
136
+ (section) => `${section.toLowerCase()} ${lost.filter((item) => item.section === section).length}`,
137
+ );
138
+ lines.push(`cut ${lost.length} of ${items.length} items to fit ${budget}: ${where.join(', ')}`);
139
+ }
140
+ return lines.join('\n');
141
+ }
142
+
143
+ /** The byte length of a rendering. The ceiling is measured in these, not in characters. */
144
+ function packetBytes(items, { cut = new Set(), budget = DEFAULT_BRIEF_BYTES } = {}) {
145
+ return Buffer.byteLength(renderPacket(items, { cut, budget }));
146
+ }
147
+
148
+ /**
149
+ * Pack the items into a packet.
150
+ *
151
+ * Returns the text, the byte count before and after, and whether the floor itself is over
152
+ * the line. `floorHeld` is reported to the caller rather than printed: it is the one fact
153
+ * the packet cannot state about itself without the statement changing the thing it
154
+ * describes, so it belongs to `--explain` and the JSON payload.
155
+ *
156
+ * A budget of 0 means no ceiling at all — the reader asked for the whole packet — and the
157
+ * packing step is skipped rather than run against a number that means "unlimited".
158
+ */
159
+ export function buildPacket(items, { budget = DEFAULT_BRIEF_BYTES } = {}) {
160
+ const before = packetBytes(items, { budget });
161
+ if (!(budget > 0)) {
162
+ return { cut: [], before, after: before, floorHeld: false, text: renderPacket(items, { budget }) };
163
+ }
164
+ const result = packToBudget({
165
+ candidates: cutOrder(items),
166
+ ceiling: budget,
167
+ costs: (cut) => packetBytes(items, { cut: new Set(cut), budget }),
168
+ });
169
+ const cut = new Set(result.cut);
170
+ return { ...result, text: renderPacket(items, { cut, budget }) };
171
+ }
172
+
173
+ /**
174
+ * One line per item, for `--explain`: what was kept, what was cut, and the two facts that
175
+ * decided it.
176
+ *
177
+ * The explanation is not part of the packet and is never measured against the ceiling —
178
+ * it exists so a reader can tell a packet that is complete from one that was trimmed,
179
+ * which is the question the packet's own byte count cannot answer for them.
180
+ */
181
+ export function explainPacket(items, { cut = new Set() } = {}) {
182
+ const counts = new Map();
183
+ for (const item of items) counts.set(item.section, (counts.get(item.section) ?? 0) + 1);
184
+ const seen = new Map();
185
+ const lines = [];
186
+ for (const item of items) {
187
+ const position = (seen.get(item.section) ?? 0) + 1;
188
+ seen.set(item.section, position);
189
+ const verdict = cut.has(item.id) ? 'cut' : 'keep';
190
+ const reason = item.why.length > 0 ? item.why.join('; ') : 'carried by its section';
191
+ lines.push(
192
+ `${verdict} ${item.section} ${position}/${counts.get(item.section)} worth ${SECTION_VALUE[item.section]}: ${item.text.split('\n')[0]}`,
193
+ );
194
+ lines.push(` ${reason}`);
195
+ }
196
+ return lines;
197
+ }