simt-dev 0.3.1 → 0.5.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 +157 -0
- package/README.md +271 -62
- package/package.json +6 -2
- package/rules/core.md +1 -1
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +28 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +88 -54
- package/src/bench/tasks.mjs +38 -0
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +368 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +200 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +72 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/distill.mjs +36 -0
- package/src/commands/help.mjs +62 -3
- package/src/commands/index.mjs +29 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +5 -50
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +23 -25
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +269 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- package/src/toolwords.mjs +6 -0
|
@@ -0,0 +1,368 @@
|
|
|
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 { briefAdvice } from '../advice/advice.mjs';
|
|
41
|
+
import { commandCard } from '../kernel/card.mjs';
|
|
42
|
+
import { decisionHits, scopeHits, sourceRoots } from '../index/govern.mjs';
|
|
43
|
+
import { embedTaskQuery, rankFiles, renderMap } from '../index/map.mjs';
|
|
44
|
+
import { SQLITE_FILE } from '../index/persist.mjs';
|
|
45
|
+
import { searchFtsBm25 } from '../index/store.mjs';
|
|
46
|
+
import { testEdges, testsFor } from '../index/tests.mjs';
|
|
47
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
48
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
49
|
+
import { closure, rankSkills, selectWithinBudget, tokenize } from '../resolve/resolve.mjs';
|
|
50
|
+
import { loadGraph } from '../commands/map.mjs';
|
|
51
|
+
import { dirtyPaths, parsePathList } from '../commands/skills.mjs';
|
|
52
|
+
import { decisionRecords, lessonScopes } from '../commands/why.mjs';
|
|
53
|
+
import { readFederation } from '../federation/config.mjs';
|
|
54
|
+
import { consult } from '../federation/query.mjs';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How many ranked files the packet may consider as candidates for other sections.
|
|
58
|
+
*
|
|
59
|
+
* The FILES section is bounded by the budget, but TESTS and DECISIONS are asked "what
|
|
60
|
+
* covers the likely files" — a question a packet of any size can only answer about a
|
|
61
|
+
* handful of files before the answer stops being about the task.
|
|
62
|
+
*/
|
|
63
|
+
const LIKELY_FILES = 3;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How generously the map is allowed to render before the packet's own budget trims it.
|
|
67
|
+
*
|
|
68
|
+
* The packer can only drop what it is given, so the candidate list needs a bound. Eight
|
|
69
|
+
* times the ceiling is that bound: large enough that the trimmed packet is a choice about
|
|
70
|
+
* relevance rather than an artefact of the cut, and small enough that a brief on an
|
|
71
|
+
* enormous repository never renders the whole map.
|
|
72
|
+
*/
|
|
73
|
+
const MAP_FACTOR = 8;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The tool's own state directory, read from the path the index is written to.
|
|
77
|
+
*
|
|
78
|
+
* A brief asks git what a task touches, and the index it just read the answer from is a
|
|
79
|
+
* write: in a repository that has not gitignored this directory, the second run of the
|
|
80
|
+
* same brief would report a dirty path the first one did not, and a packet that changes
|
|
81
|
+
* between two identical commands is not the packet this promises. The tool's own scratch
|
|
82
|
+
* space is never part of a task's scope.
|
|
83
|
+
*/
|
|
84
|
+
const STATE_DIR = `${SQLITE_FILE.split('/')[0]}/`;
|
|
85
|
+
|
|
86
|
+
/** Every file git tracks that carries a generated region, one `git grep` away. */
|
|
87
|
+
async function generatedRegions(root) {
|
|
88
|
+
const { execFile } = await import('node:child_process');
|
|
89
|
+
const { promisify } = await import('node:util');
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await promisify(execFile)(
|
|
92
|
+
'git',
|
|
93
|
+
['grep', '-lF', '--', `${'simt'}:begin`],
|
|
94
|
+
{ cwd: root, maxBuffer: 8 << 20 },
|
|
95
|
+
);
|
|
96
|
+
return stdout.split('\n').filter((line) => line.length > 0).sort();
|
|
97
|
+
} catch {
|
|
98
|
+
// No match is exit 1, and a directory that is not a repository fails too. Both mean
|
|
99
|
+
// the same thing to a reader: no generated region was found to warn about.
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Assemble one packet for a task.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} [options]
|
|
108
|
+
* @param {string} [options.root] the repository
|
|
109
|
+
* @param {string} [options.task] the task text; the empty string is a legitimate task
|
|
110
|
+
* @param {string|null} [options.paths] a `--paths` string, or null to take the scope from
|
|
111
|
+
* the files git reports as modified
|
|
112
|
+
* @param {number} [options.budget] the byte ceiling; 0 means the whole packet, which no
|
|
113
|
+
* delivered brief may ask for
|
|
114
|
+
* @param {object} [options.flags] passed through to the skill registry loader
|
|
115
|
+
* @param {boolean} [options.build] whether an empty index may be built here
|
|
116
|
+
* @param {boolean} [options.federate] whether a declared external server may be asked one
|
|
117
|
+
* question; a delivered packet turns it off to keep its 300 ms bar
|
|
118
|
+
* @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
|
|
119
|
+
* `{ cold: true }` when the index is empty and this caller may not build one
|
|
120
|
+
*/
|
|
121
|
+
export async function assembleBrief({
|
|
122
|
+
root = process.cwd(),
|
|
123
|
+
task = '',
|
|
124
|
+
paths = null,
|
|
125
|
+
budget = DEFAULT_BRIEF_BYTES,
|
|
126
|
+
flags = {},
|
|
127
|
+
build = true,
|
|
128
|
+
federate = true,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const named = paths !== null;
|
|
131
|
+
const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(STATE_DIR));
|
|
132
|
+
const scope = named ? parsePathList(paths) : dirty;
|
|
133
|
+
|
|
134
|
+
const graph = await loadGraph(root, { build });
|
|
135
|
+
if (graph.error) return { error: graph.error };
|
|
136
|
+
if (graph.cold) return { cold: true };
|
|
137
|
+
const { files, symbols, edges, fts = [], embeddings = [] } = graph;
|
|
138
|
+
const filePaths = new Set(files.map((file) => file.path));
|
|
139
|
+
|
|
140
|
+
const { kind, basis } = classifyTask(task, { paths: scope });
|
|
141
|
+
const packet = collect();
|
|
142
|
+
packet.add('TASK', `kind ${kind} — ${basis}`, {
|
|
143
|
+
hard: true,
|
|
144
|
+
why: ['the kind chooses the commands, and the commands are the part a reader cannot recover from a wrong guess'],
|
|
145
|
+
});
|
|
146
|
+
packet.add('TASK', `task ${task.length > 0 ? task : '(none given)'}`, { hard: true, why: ['the packet is an answer to this'] });
|
|
147
|
+
packet.add('TASK', `scope ${scope.length > 0 ? scope.join(', ') : 'the whole repository'}`, {
|
|
148
|
+
why: [named ? 'named with --paths' : 'taken from the files git reports as modified'],
|
|
149
|
+
});
|
|
150
|
+
if (task.length === 0 && !named) {
|
|
151
|
+
packet.add('CAUTIONS', 'no task was given, so the packet describes the working tree rather than a task', {
|
|
152
|
+
why: ['an unasked question has no answer to be short of'],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const repo = await fingerprint(root);
|
|
157
|
+
const card = await commandCard({ root, manifest: repo.manifest ?? null, stack: repo.stack, facts: repo.facts });
|
|
158
|
+
const wanted = cardVerbs(kind);
|
|
159
|
+
const chosen = card.lines.filter((line) => wanted.includes(line.verb));
|
|
160
|
+
for (const line of chosen.length > 0 ? chosen : card.lines) {
|
|
161
|
+
packet.add('COMMANDS', `${line.label} ${line.command}`, {
|
|
162
|
+
hard: line.verb === 'test' || line.verb === 'focused',
|
|
163
|
+
why: [`the ${kind} kind needs ${line.verb}`],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (card.lines.length === 0) {
|
|
167
|
+
packet.add('CAUTIONS', 'the repository declares no evidence of how it is built, so no command was offered', {
|
|
168
|
+
why: ['a card is read from committed evidence, never invented from a guess'],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
for (const verb of card.dropped) {
|
|
172
|
+
packet.add('COMMANDS', `dropped by the card's own 600-byte ceiling: ${verb}`, {
|
|
173
|
+
why: ['the card keeps its own budget before this packet spends any of it'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bm25 = task.trim().length > 0 ? await searchFtsBm25(root, task, { fts }) : [];
|
|
178
|
+
const queryVector =
|
|
179
|
+
flags?.embed === true && embeddings.length > 0
|
|
180
|
+
? await embedTaskQuery(root, task, {
|
|
181
|
+
embed: true,
|
|
182
|
+
allowRemote: flags?.allowRemote === true,
|
|
183
|
+
})
|
|
184
|
+
: null;
|
|
185
|
+
const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
|
|
186
|
+
const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
|
|
187
|
+
for (const line of rendered.text.split('\n')) {
|
|
188
|
+
packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
|
|
189
|
+
}
|
|
190
|
+
if (rendered.truncated) {
|
|
191
|
+
packet.add('CAUTIONS', `the map held back ${rendered.total - rendered.shown} of ${rendered.total} files`, {
|
|
192
|
+
why: [`\`${CLI} map --budget 0\` lists them all`],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Model and effort advice comes from the repository's bench history, not from anything a
|
|
197
|
+
// model produced, so it stays deterministic. The tier exists only if the history resolved
|
|
198
|
+
// a similar task, and the claim travels with its own numbers and its unmeasured accept
|
|
199
|
+
// bar; simt-dev never selects the model an agent runs (plan 0003 R40).
|
|
200
|
+
const advice = await briefAdvice(root, { task, scope, kind });
|
|
201
|
+
if (advice?.picked) {
|
|
202
|
+
packet.add('ADVICE', advice.picked.line, {
|
|
203
|
+
why: ['the tier is the estate’s own bench ladder, and a human still names the model'],
|
|
204
|
+
});
|
|
205
|
+
packet.add('ADVICE', advice.picked.basis, {
|
|
206
|
+
why: ['an advice claim names the neighborhood that backs it, so the reader can re-derive it'],
|
|
207
|
+
});
|
|
208
|
+
packet.add('ADVICE', advice.picked.metric, {
|
|
209
|
+
why: ['the row has an accept bar, and nothing in a packet may claim the bar was met'],
|
|
210
|
+
});
|
|
211
|
+
} else if (advice?.caution) {
|
|
212
|
+
packet.add('CAUTIONS', advice.caution, {
|
|
213
|
+
why: ['advice without a neighborhood is a guess dressed as history'],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const likely = ranked.slice(0, LIKELY_FILES).map((row) => row.path);
|
|
218
|
+
const known = testEdges({ files, imports: edges.filter((edge) => edge.kind === 'import') });
|
|
219
|
+
const untested = [];
|
|
220
|
+
packet.add('TESTS', `the index knows direct importers; \`${CLI} tests <path>\` is the authority for multi-line and dynamic imports`, {
|
|
221
|
+
why: ['a cheap packet and an exact answer are different questions'],
|
|
222
|
+
});
|
|
223
|
+
for (const path of likely) {
|
|
224
|
+
const found = testsFor(path, known);
|
|
225
|
+
if (found.length === 0) untested.push(path);
|
|
226
|
+
else packet.add('TESTS', `${path} -> ${found.join(' ')}`, { why: ['the test imports it, or sits beside it'] });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const records = await decisionRecords(root);
|
|
230
|
+
const ctx = {
|
|
231
|
+
files: filePaths,
|
|
232
|
+
commands: new Set(card.lines.map((line) => line.command)),
|
|
233
|
+
roots: sourceRoots([...filePaths]),
|
|
234
|
+
};
|
|
235
|
+
const decisions = new Map();
|
|
236
|
+
const unresolved = new Set();
|
|
237
|
+
for (const path of likely) {
|
|
238
|
+
const hit = decisionHits(records, path, ctx);
|
|
239
|
+
for (const entry of hit.hits) if (!decisions.has(entry.id)) decisions.set(entry.id, { ...entry, path });
|
|
240
|
+
for (const entry of hit.unresolved) unresolved.add(entry.token);
|
|
241
|
+
}
|
|
242
|
+
for (const id of [...decisions.keys()].sort()) {
|
|
243
|
+
const entry = decisions.get(id);
|
|
244
|
+
packet.add('DECISIONS', `${entry.id} — ${entry.detail}`, { why: [`names ${entry.path}`] });
|
|
245
|
+
}
|
|
246
|
+
if (unresolved.size > 0) {
|
|
247
|
+
packet.add('CAUTIONS', `decisions name tokens nothing resolves: ${[...unresolved].sort().join(', ')}`, {
|
|
248
|
+
why: [`\`${CLI} why\` reports them`],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (federate) {
|
|
253
|
+
const { servers, findings } = await readFederation(root);
|
|
254
|
+
for (const finding of findings) {
|
|
255
|
+
packet.add('CAUTIONS', `federation: ${finding}`, {
|
|
256
|
+
why: ['a server the manifest names is skipped rather than guessed at'],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (servers.length > 0) {
|
|
260
|
+
for (const server of servers) {
|
|
261
|
+
if (server.languages.length > 0) {
|
|
262
|
+
packet.add('FEDERATION', `${server.name} covers ${[...server.languages].sort().join(', ')}`, {
|
|
263
|
+
why: ['the answer for a covered language can come from this server'],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// One question, and the task is the question. With no task text there is still a
|
|
268
|
+
// question worth asking — the file the index ranks highest — and with neither there
|
|
269
|
+
// is nothing to ask, which is a caution rather than a fabricated query.
|
|
270
|
+
const question = task.trim().length > 0 ? task.trim() : (likely[0] ?? '');
|
|
271
|
+
if (question.length === 0) {
|
|
272
|
+
packet.add('CAUTIONS', 'federation is declared but this packet has no task to ask about', {
|
|
273
|
+
why: ['a structural question needs something to be about'],
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
const asked = await consult({ root, query: question, servers });
|
|
277
|
+
for (const answer of asked.answers) {
|
|
278
|
+
packet.add('FEDERATION', `${answer.server} — ${answer.text}`, {
|
|
279
|
+
why: [`\`${answer.server}\`, declared under \`federation.servers\`, answered \`${answer.tool}\``],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
for (const finding of asked.findings) {
|
|
283
|
+
packet.add('CAUTIONS', `federation: ${finding}`, {
|
|
284
|
+
why: ['the brief carries on; a server that is down is not a task that cannot start'],
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const loaded = await loadRegistry(null, flags);
|
|
292
|
+
if (!loaded.ok) {
|
|
293
|
+
packet.add('CAUTIONS', `the skill registry did not load (${loaded.errors[0]}), so no skill was offered`, {
|
|
294
|
+
why: ['every other section is independent of the registry, which is why this is a caution and not a failure'],
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
const rankedSkills = rankSkills(
|
|
298
|
+
loaded.registry.skills,
|
|
299
|
+
{ repoStack: repo.signals, taskTokens: tokenize(task), paths: scope },
|
|
300
|
+
{ forced: [] },
|
|
301
|
+
);
|
|
302
|
+
const expanded = closure(rankedSkills.selected, loaded.registry.skills);
|
|
303
|
+
const allowance = budget > 0 ? Math.floor(budget / 4) : 0;
|
|
304
|
+
const selection = selectWithinBudget(expanded, { budget: allowance, all: false });
|
|
305
|
+
for (const entry of selection.picked) {
|
|
306
|
+
const reasons = [...entry.reasons];
|
|
307
|
+
packet.add('SKILLS', `${entry.skill.name} — ${entry.skill.bytes} B, ${reasons.join(' ')}`, {
|
|
308
|
+
why: [`\`${CLI} skills show ${entry.skill.name}\` reads the body`],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (selection.deferred.length > 0) {
|
|
312
|
+
packet.add('CAUTIONS', `skills held back for the budget: ${selection.deferred.map((entry) => entry.skill.name).sort().join(', ')}`, {
|
|
313
|
+
why: ['a body is not worth a command that cannot be run'],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lessonTargets = [...new Set([...scope, ...likely])];
|
|
319
|
+
const taskTokenSet = new Set(tokenize(task));
|
|
320
|
+
for (const lesson of await lessonScopes(root)) {
|
|
321
|
+
const pathHits = lessonTargets.flatMap((path) => scopeHits(path, { lessons: [lesson] }));
|
|
322
|
+
const keywordMatch =
|
|
323
|
+
pathHits.length === 0 &&
|
|
324
|
+
Array.isArray(lesson.keywords) &&
|
|
325
|
+
lesson.keywords.some((kw) => taskTokenSet.has(String(kw).toLowerCase())) &&
|
|
326
|
+
[...filePaths].some((path) => scopeHits(path, { lessons: [lesson] }).length > 0);
|
|
327
|
+
if (pathHits.length > 0 || keywordMatch) {
|
|
328
|
+
const body =
|
|
329
|
+
lesson.paragraph && lesson.paragraph !== lesson.detail
|
|
330
|
+
? `${lesson.detail}: ${lesson.paragraph.replace(/\s+/g, ' ').trim()}`
|
|
331
|
+
: lesson.detail;
|
|
332
|
+
packet.add('LESSONS', `${lesson.id} — ${body}`, {
|
|
333
|
+
why: [pathHits.length > 0 ? 'a lesson names a path this task will touch' : 'a lesson trigger matches this task'],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const generated = await generatedRegions(root);
|
|
339
|
+
if (generated.length > 0) {
|
|
340
|
+
packet.add('CAUTIONS', `generated — edit the source, not these: ${generated.join(', ')}`, {
|
|
341
|
+
why: ['a hand edit inside a generated region is overwritten by the next projection'],
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (untested.length > 0) {
|
|
345
|
+
packet.add('CAUTIONS', `the index knows no test for ${untested.join(', ')}`, {
|
|
346
|
+
why: [`\`${CLI} tests <path>\` reads the blobs and may know one`],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (named && dirty.length > 0) {
|
|
350
|
+
const outside = dirty.filter((path) => !scope.includes(path));
|
|
351
|
+
if (outside.length > 0) {
|
|
352
|
+
packet.add('CAUTIONS', `dirty and outside the scope, leave alone: ${outside.join(', ')}`, {
|
|
353
|
+
why: ['a change nobody asked for is a change nobody reviews'],
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const packed = buildPacket(packet.items, { budget });
|
|
359
|
+
const cut = new Set(packed.cut);
|
|
360
|
+
const sections = {};
|
|
361
|
+
for (const item of packet.items) {
|
|
362
|
+
if (cut.has(item.id)) continue;
|
|
363
|
+
sections[item.section] ??= [];
|
|
364
|
+
sections[item.section].push(item.text);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { root, kind, basis, task, scope, budget, items: packet.items, packed, sections };
|
|
368
|
+
}
|
|
@@ -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
|
+
}
|