@intentic/sandbox-contract 1.170.0 → 1.171.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/dist/agent-catalog.d.ts +1 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +1 -0
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.d.ts +45 -0
- package/dist/chores/chores.d.ts.map +1 -0
- package/dist/chores/chores.js +487 -0
- package/dist/chores/chores.js.map +1 -0
- package/dist/chores/digest.d.ts +3 -0
- package/dist/chores/digest.d.ts.map +1 -0
- package/dist/chores/digest.js +0 -0
- package/dist/chores/digest.js.map +1 -0
- package/dist/chores/index.d.ts +10 -0
- package/dist/chores/index.d.ts.map +1 -0
- package/dist/chores/index.js +6 -0
- package/dist/chores/index.js.map +1 -0
- package/dist/chores/probes.d.ts +15 -0
- package/dist/chores/probes.d.ts.map +1 -0
- package/dist/chores/probes.js +177 -0
- package/dist/chores/probes.js.map +1 -0
- package/dist/chores/prompt.d.ts +14 -0
- package/dist/chores/prompt.d.ts.map +1 -0
- package/dist/chores/prompt.js +12 -0
- package/dist/chores/prompt.js.map +1 -0
- package/dist/chores/verdict.d.ts +20 -0
- package/dist/chores/verdict.d.ts.map +1 -0
- package/dist/chores/verdict.js +59 -0
- package/dist/chores/verdict.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +11 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +2 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +54 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/chores.contract.d.ts +151 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -0
- package/dist/contracts/chores.contract.js +8 -0
- package/dist/contracts/chores.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +14 -14
- package/dist/contracts/workspace.contract.d.ts +11 -24
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/events.d.ts +10 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -1
- package/dist/events.js.map +1 -1
- package/dist/hostnames.d.ts +0 -1
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +0 -1
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +243 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +590 -40
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +136 -2
- package/dist/schemas.js.map +1 -1
- package/dist/tunnel-ids.d.ts +2 -0
- package/dist/tunnel-ids.d.ts.map +1 -1
- package/dist/tunnel-ids.js +2 -0
- package/dist/tunnel-ids.js.map +1 -1
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +5 -0
- package/dist/workspace-state.js.map +1 -1
- package/package.json +14 -2
- package/src/agent-catalog.test.ts +32 -1
- package/src/agent-catalog.ts +15 -0
- package/src/chores/chores.ts +837 -0
- package/src/chores/digest.test.ts +30 -0
- package/src/chores/digest.ts +0 -0
- package/src/chores/index.ts +9 -0
- package/src/chores/probes.test.ts +166 -0
- package/src/chores/probes.ts +273 -0
- package/src/chores/prompt.ts +64 -0
- package/src/chores/verdict.test.ts +394 -0
- package/src/chores/verdict.ts +167 -0
- package/src/contracts/chores.contract.ts +23 -0
- package/src/events.ts +15 -2
- package/src/hostnames.ts +4 -6
- package/src/index.ts +3 -0
- package/src/schemas.ts +383 -13
- package/src/tunnel-ids.test.ts +49 -0
- package/src/tunnel-ids.ts +24 -0
- package/src/workspace-state.ts +5 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import type { Advisory, ChoreSignals, OutdatedPackage, ProbeId, ProbeResult } from "../schemas.js";
|
|
2
|
+
import { bucketOf, digestOf } from "./digest.js";
|
|
3
|
+
import { CHORE_INVARIANTS, composeAsk, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
|
|
4
|
+
|
|
5
|
+
/* THE CHORE BOOK — what routine maintenance a repository is owed, and what has to be TRUE before we say so.
|
|
6
|
+
*
|
|
7
|
+
* Everything in here is a standing offer: work that is worth doing eventually, that nobody will ever put on a
|
|
8
|
+
* sprint board, and that a person cannot notice is overdue by looking at their editor. The engineering problem is
|
|
9
|
+
* not finding such work — any linter will hand you a thousand findings — it is deciding which of them is worth
|
|
10
|
+
* interrupting somebody about, on a surface they will still be reading in six months.
|
|
11
|
+
*
|
|
12
|
+
* Three rules, and every entry below obeys all three:
|
|
13
|
+
*
|
|
14
|
+
* 1. DELTAS, NOT ABSOLUTES. "38 packages are undocumented" is a statistic; it will be true every day for a year,
|
|
15
|
+
* and a tile lit every day teaches the eye to stop seeing the rail. "A package appeared that nothing explains"
|
|
16
|
+
* is an event. So a chore's `digest` is built from the IDENTITIES of what it found — which packages, which
|
|
17
|
+
* advisories, which files — and the rail speaks when that set changes, not while it is merely non-empty. The
|
|
18
|
+
* standing count still shows inside the panel, next to the thing it describes, which is where a statistic
|
|
19
|
+
* belongs.
|
|
20
|
+
*
|
|
21
|
+
* 2. LEADER-RELATIVE, NOT TUNED. Nowhere in here is there a threshold that would need a different value for a
|
|
22
|
+
* Rust repo, a fresh scaffold, or a ten-year monolith — with one deliberate exception (duplication's 5%, which
|
|
23
|
+
* is a percentage of the tree and therefore already scale-free). "Three times the median of its own ranking"
|
|
24
|
+
* needs no calibration and cannot rot.
|
|
25
|
+
*
|
|
26
|
+
* 3. THE EVIDENCE IS THE TRUTH; THE LEDGER ONLY DEBOUNCES. Nothing here can be ticked off. A chore goes quiet
|
|
27
|
+
* because the measurement moved, which means someone fixing it by hand — or an unrelated change fixing it by
|
|
28
|
+
* accident — is registered exactly like a chore turn doing it. The ledger's only power is to stop the rail
|
|
29
|
+
* repeating itself about evidence a turn has already been spent on (verdict.ts).
|
|
30
|
+
*
|
|
31
|
+
* What is NOT here is as deliberate. There is no composite score, no letter grade, no "health: 78%". Those are
|
|
32
|
+
* not comparable across projects, cannot be checked by the reader, and turn a set of specific, arguable findings
|
|
33
|
+
* into one number nobody can act on. And no chore is ever created enabled-and-hidden: a chore that runs is a
|
|
34
|
+
* turn that spends money and writes to the workspace, so it is either something the owner started or an
|
|
35
|
+
* automation they can see in a list. */
|
|
36
|
+
|
|
37
|
+
export type ChoreStance = "act" | "report";
|
|
38
|
+
|
|
39
|
+
export interface ChoreContext {
|
|
40
|
+
// Root-relative repo dir; the empty string is the workspace's own root repo.
|
|
41
|
+
readonly repo: string;
|
|
42
|
+
readonly probes: ReadonlyMap<ProbeId, ProbeResult>;
|
|
43
|
+
readonly signals: ChoreSignals;
|
|
44
|
+
// What the daemon is actually RUNNING, not what a manifest wishes for.
|
|
45
|
+
readonly node: string;
|
|
46
|
+
readonly nowMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What a chore found, when it found anything. `undefined` from `assess` is the healthy case and the common one.
|
|
50
|
+
export interface ChoreFinding {
|
|
51
|
+
// One line, in numbers, for the row. The reader decides from this whether to open anything.
|
|
52
|
+
readonly headline: string;
|
|
53
|
+
// The evidence itself, one claim per line — what the panel lists under the row, and what makes the headline
|
|
54
|
+
// checkable rather than something to be believed.
|
|
55
|
+
readonly detail: readonly string[];
|
|
56
|
+
// The identity of THIS evidence. See digest.ts: it is what the rail's transitions are measured against.
|
|
57
|
+
readonly digest: string;
|
|
58
|
+
// `warning` is for a risk the owner is carrying right now — a live advisory, a runtime past its EOL. Everything
|
|
59
|
+
// else is `info`, including large and ugly numbers, because "there is a lot of it" is not an emergency.
|
|
60
|
+
readonly severity: "info" | "warning";
|
|
61
|
+
// The numbers again, in the agent's terms, for the prompt's "Why:" line. Exact — the agent may recount them.
|
|
62
|
+
readonly why: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Chore {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly title: string;
|
|
68
|
+
// An app icon name. Left as a plain string for the same reason the extension API leaves Activation.icon open:
|
|
69
|
+
// this library must not depend on the UI kit to name a glyph.
|
|
70
|
+
readonly icon: string;
|
|
71
|
+
// The one-line standing description, shown whether or not the chore is currently due.
|
|
72
|
+
readonly description: string;
|
|
73
|
+
/* THE RULE, in words — what has to be true for this chore to be due, stated so a reader can check it against
|
|
74
|
+
* the evidence below it and disagree.
|
|
75
|
+
*
|
|
76
|
+
* This is not decoration. A row that says "4 majors waiting" and nothing else is asking to be taken on
|
|
77
|
+
* trust; the same row saying "shown because: a dependency is a major version behind" is a claim someone can
|
|
78
|
+
* argue with, and arguing with it is how the book gets better. It rides into the prompt too, so the agent is
|
|
79
|
+
* told the rule it was woken by rather than left to infer it from the numbers.
|
|
80
|
+
*
|
|
81
|
+
* Kept as prose next to the code that implements it, which means it can drift from it — the tests below
|
|
82
|
+
* cannot check English. The rule for writing one: say the THRESHOLD, not the subject. "Duplication is high"
|
|
83
|
+
* is a topic; "more than 5% of the tree is duplicated" is a criterion. */
|
|
84
|
+
readonly criterion: string;
|
|
85
|
+
/* WHETHER THIS IS A QUESTION WORTH ASKING OF THIS REPOSITORY AT ALL — returns undefined when it is, and a
|
|
86
|
+
* plain-language reason when it is not ("this repository ships no Dockerfile").
|
|
87
|
+
*
|
|
88
|
+
* Distinct from `assess`, and the distinction is the whole point: `assess` asks whether the answer is yes,
|
|
89
|
+
* this asks whether the question makes sense. "Re-read the documentation against the code" in a repository
|
|
90
|
+
* with no documentation is not a chore that is currently clear — it is one that will never apply here, and
|
|
91
|
+
* showing it as clear says we checked something we cannot check. A chore that does not apply is dropped from
|
|
92
|
+
* the panel entirely; only a footer records that it was considered.
|
|
93
|
+
*
|
|
94
|
+
* Reads `signals` rather than probes on purpose: applicability is about what the repository IS, which is a
|
|
95
|
+
* fact the daemon holds without measuring anything. If a gate needed a probe it would be describing the
|
|
96
|
+
* answer rather than the question. */
|
|
97
|
+
readonly applies?: (signals: ChoreSignals) => string | undefined;
|
|
98
|
+
// Whether the turn is allowed to CHANGE anything. Not a hint — it selects the invariants block, and a
|
|
99
|
+
// report-stance chore is told in words that editing would be a surprise.
|
|
100
|
+
readonly stance: ChoreStance;
|
|
101
|
+
// Probes that must have run and succeeded before this chore can be assessed at all. Missing ⇒ `unavailable`:
|
|
102
|
+
// rendered greyed, never badged, and never mistaken for a clean result.
|
|
103
|
+
readonly needs: readonly ProbeId[];
|
|
104
|
+
/* How long until this is worth doing again REGARDLESS of what changed. For a measured chore this is a backstop
|
|
105
|
+
* (evidence normally decides); for a survey chore it is the whole trigger, because "read this code with fresh
|
|
106
|
+
* eyes" has no measurement and its value is entirely in being done periodically. */
|
|
107
|
+
readonly cadenceMs: number;
|
|
108
|
+
// A survey has no measurement: it is due on its cadence and clear otherwise. Named rather than inferred from
|
|
109
|
+
// an empty `needs`, because the two are different claims and the panel says which one a row is.
|
|
110
|
+
readonly survey?: true;
|
|
111
|
+
/* THE SCHEDULED FORM, for the chores worth running unattended — what the Automations page offers as a
|
|
112
|
+
* one-click "code chore", and the second way this book is consumed.
|
|
113
|
+
*
|
|
114
|
+
* The two modes are genuinely different and both are wanted. The Maintenance panel is EVIDENCE-driven: it
|
|
115
|
+
* reads what the daemon already measured and offers a turn against a specific finding you can read first. An
|
|
116
|
+
* automation is SCHEDULE-driven: it wakes on a clock, at 3am, with nobody watching. So an automation cannot
|
|
117
|
+
* carry a finding — there is no verdict at fire time — and instead it carries a GUARD: a shell one-liner that
|
|
118
|
+
* runs for free on the sandbox's own clock and exits non-zero to skip, so the half that costs a turn only
|
|
119
|
+
* starts when there is something to start it for.
|
|
120
|
+
*
|
|
121
|
+
* `report` is where the guard leaves its findings. A guard's stdout is discarded on success (only a FAILING
|
|
122
|
+
* guard's output survives, as the skip reason), so a file is how the free deterministic half hands what it
|
|
123
|
+
* found to the expensive half. */
|
|
124
|
+
readonly automation?: {
|
|
125
|
+
readonly cron: string;
|
|
126
|
+
readonly guard: string;
|
|
127
|
+
readonly note: string;
|
|
128
|
+
readonly report: string;
|
|
129
|
+
// How the woken turn is told what it is looking at — the "Why:" line, in place of a finding.
|
|
130
|
+
readonly woke: string;
|
|
131
|
+
};
|
|
132
|
+
readonly assess: (context: ChoreContext) => ChoreFinding | undefined;
|
|
133
|
+
// The prompt's three variable parts (prompt.ts owns the shape). `diagnosis` says what the numbers MEAN, `goal`
|
|
134
|
+
// says what shape to move towards — never a design — and `done` is falsifiable by the agent itself.
|
|
135
|
+
readonly diagnosis: string;
|
|
136
|
+
readonly goal: string;
|
|
137
|
+
readonly done: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DAY_MS = 86_400_000;
|
|
141
|
+
|
|
142
|
+
/* Where a scheduled chore's guard leaves its report for the woken turn to read. Under /tmp because they are
|
|
143
|
+
* inputs to a turn that starts moments later, never something to keep — and deliberately the SAME paths the
|
|
144
|
+
* probe runner uses, so a workspace that runs both does not keep two copies of the same measurement. */
|
|
145
|
+
const AUDIT_REPORT = `/tmp/intentic-chore-audit.json`;
|
|
146
|
+
const KNIP_REPORT = `/tmp/intentic-chore-knip.json`;
|
|
147
|
+
const JSCPD_DIR = `/tmp/intentic-chore-jscpd`;
|
|
148
|
+
const JSCPD_REPORT = `${JSCPD_DIR}/jscpd-report.json`;
|
|
149
|
+
|
|
150
|
+
// How a repo is named to a person and to an agent. "root" is the wire id the daemon's git and health routes
|
|
151
|
+
// already use for the workspace's own repository, and it is a word an agent would otherwise read as a directory
|
|
152
|
+
// called "root" — so it is spelled out here, once, rather than at every call site that builds a prompt.
|
|
153
|
+
export const repoLabel = (repo: string): string => (repo === `root` || repo === `` ? `the workspace root repository` : repo);
|
|
154
|
+
|
|
155
|
+
const plural = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`;
|
|
156
|
+
|
|
157
|
+
// One outdated dependency, as the panel lists it. The semver step leads, because it is what decides whether the
|
|
158
|
+
// row is a morning's work or a project.
|
|
159
|
+
const outdatedLine = (entry: OutdatedPackage): string => `${entry.kind} · ${entry.name} ${entry.current} → ${entry.latest}`;
|
|
160
|
+
|
|
161
|
+
// The `facts` of a probe that actually ran. Anything else — never run, unavailable, failed — reads as absent, so
|
|
162
|
+
// no assess() can accidentally treat an unmeasured repo as a measured clean one.
|
|
163
|
+
const factsOf = <T extends ProbeId>(context: ChoreContext, id: T): Extract<NonNullable<ProbeResult["facts"]>, { id: T }> | undefined => {
|
|
164
|
+
const probe = context.probes.get(id);
|
|
165
|
+
if (probe?.state !== `ok` || probe.facts === undefined || probe.facts.id !== id) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
return probe.facts as Extract<NonNullable<ProbeResult["facts"]>, { id: T }>;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// ---- the entries -----------------------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const BLOCKING = new Set<Advisory["severity"]>([`critical`, `high`]);
|
|
174
|
+
|
|
175
|
+
/* SECURITY. The only chore with no cadence at all: an advisory is not something that becomes worth looking at
|
|
176
|
+
* after thirty days, and there is nothing periodic about it. It is also the only one that reaches `warning`
|
|
177
|
+
* routinely, which is exactly why the bar is critical-or-high and production-or-dev is carried through to the
|
|
178
|
+
* prompt rather than flattened — a moderate advisory in a build-time-only tool badging red is how `warning` stops
|
|
179
|
+
* meaning anything within a week. */
|
|
180
|
+
const security: Chore = {
|
|
181
|
+
id: `security-advisories`,
|
|
182
|
+
title: `Patch security advisories`,
|
|
183
|
+
icon: `shield`,
|
|
184
|
+
description: `Published advisories against this dependency tree, and the ones whose fix is a version bump.`,
|
|
185
|
+
criterion: `pnpm audit reports an advisory of high or critical severity against the resolved tree.`,
|
|
186
|
+
applies: (signals) => (signals.shape.lockfile ? undefined : `there is no lockfile here, so nothing resolves to a tree that could be audited`),
|
|
187
|
+
stance: `act`,
|
|
188
|
+
needs: [`audit`],
|
|
189
|
+
cadenceMs: 0,
|
|
190
|
+
automation: {
|
|
191
|
+
cron: `0 4 * * *`,
|
|
192
|
+
guard:
|
|
193
|
+
`pnpm audit --json > ${AUDIT_REPORT} 2>/dev/null; ` +
|
|
194
|
+
`[ "$(jq '(.metadata.vulnerabilities.high // 0) + (.metadata.vulnerabilities.critical // 0)' ${AUDIT_REPORT} 2>/dev/null || echo 0)" -gt 0 ]`,
|
|
195
|
+
note: `nightly · high + critical only`,
|
|
196
|
+
report: AUDIT_REPORT,
|
|
197
|
+
woke: `pnpm audit's report for this workspace is in ${AUDIT_REPORT} (JSON), and it woke you because it carries a high or critical advisory.`,
|
|
198
|
+
},
|
|
199
|
+
assess: (context) => {
|
|
200
|
+
const facts = factsOf(context, `audit`);
|
|
201
|
+
if (facts === undefined) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const blocking = facts.advisories.filter((advisory) => BLOCKING.has(advisory.severity));
|
|
205
|
+
if (blocking.length === 0) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
const production = blocking.filter((advisory) => !advisory.dev);
|
|
209
|
+
const patchable = blocking.filter((advisory) => advisory.patched !== undefined);
|
|
210
|
+
return {
|
|
211
|
+
headline: `${plural(blocking.length, `advisory`, `advisories`)}, ${patchable.length} with a published fix`,
|
|
212
|
+
detail: blocking
|
|
213
|
+
.toSorted((left, right) => left.name.localeCompare(right.name))
|
|
214
|
+
.map((advisory) => `${advisory.severity} · ${advisory.name} — ${advisory.title}${advisory.patched === undefined ? ` (no patch yet)` : ``}`),
|
|
215
|
+
// Identities, not counts: every advisory that appears or is fixed is genuinely news, and there is no
|
|
216
|
+
// ordinary drift here to absorb.
|
|
217
|
+
digest: digestOf(...blocking.map((advisory) => `${advisory.name}@${advisory.severity}`).toSorted()),
|
|
218
|
+
severity: production.length > 0 ? `warning` : `info`,
|
|
219
|
+
// Named, not counted. "1 high advisory" tells an agent nothing it can act on, and the first thing it
|
|
220
|
+
// would have to do is re-derive the list we already have — badly, because pnpm audit is slow and it
|
|
221
|
+
// would be reading a different tree by then.
|
|
222
|
+
why:
|
|
223
|
+
`pnpm audit reports ${plural(blocking.length, `high or critical advisory`, `high or critical advisories`)} against ` +
|
|
224
|
+
`${repoLabel(context.repo)} — ${production.length} reaching a production dependency path, ${patchable.length} with a published patched range: ` +
|
|
225
|
+
`${blocking.map((advisory) => `${advisory.name} (${advisory.severity}${advisory.dev ? `, dev-only` : ``}${advisory.patched === undefined ? `, no patch` : `, fixed in ${advisory.patched}`})`).join(`; `)}.`,
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
diagnosis: `An advisory with a published fix is a version bump someone has to actually make; one without is a risk to decide about.`,
|
|
229
|
+
goal:
|
|
230
|
+
`For each advisory, establish whether this workspace reaches the vulnerable code path at all — a transitive dependency of a ` +
|
|
231
|
+
`build-time tool is a different problem from one in a running service. Where the fix is a version bump the lockfile can absorb, ` +
|
|
232
|
+
`make it. Where it needs a real upgrade or has no patch published, leave it and say what it would take. Never rewrite ` +
|
|
233
|
+
`application code to route around a CVE.`,
|
|
234
|
+
done: `Done when \`pnpm audit\` reports fewer high/critical advisories than it did, and the repository's type-check and tests pass.`,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/* DEPENDENCIES. Majors are the finding; the total is context. A repo that is forty patch releases behind is a
|
|
238
|
+
* morning's work and does not need a rail tile, while one major on a framework is a project — so the digest is
|
|
239
|
+
* built from WHICH packages have a major waiting, and a new one appearing is the event. The total count rides
|
|
240
|
+
* along bucketed (digest.ts) so that ordinary drift, which is constant, does not read as news. */
|
|
241
|
+
const OUTDATED_NOISE_FLOOR = 20;
|
|
242
|
+
|
|
243
|
+
const dependencies: Chore = {
|
|
244
|
+
id: `dependencies-outdated`,
|
|
245
|
+
title: `Update dependencies`,
|
|
246
|
+
icon: `arrow-circle-up`,
|
|
247
|
+
description: `How far behind the registry this tree has drifted, and which majors are waiting.`,
|
|
248
|
+
criterion: `A dependency is a major version behind, or more than 20 are behind by any amount.`,
|
|
249
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository has no package.json, so there is no npm dependency tree to be behind`),
|
|
250
|
+
stance: `act`,
|
|
251
|
+
needs: [`outdated`],
|
|
252
|
+
cadenceMs: 30 * DAY_MS,
|
|
253
|
+
assess: (context) => {
|
|
254
|
+
const facts = factsOf(context, `outdated`);
|
|
255
|
+
if (facts === undefined) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
const majors = facts.packages.filter((entry) => entry.kind === `major`);
|
|
259
|
+
// Nothing major and a short tail is a healthy repository, not a chore. The floor is on the TOTAL rather
|
|
260
|
+
// than on any one package because minors and patches are only worth a turn in bulk.
|
|
261
|
+
if (majors.length === 0 && facts.packages.length < OUTDATED_NOISE_FLOOR) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
headline:
|
|
266
|
+
majors.length === 0
|
|
267
|
+
? `${plural(facts.packages.length, `package`)} behind`
|
|
268
|
+
: `${plural(majors.length, `major`)} waiting, ${facts.packages.length} behind in total`,
|
|
269
|
+
detail: majors.toSorted((left, right) => left.name.localeCompare(right.name)).map(outdatedLine),
|
|
270
|
+
digest: digestOf(...majors.map((entry) => `${entry.name}@${entry.latest}`).toSorted(), `total:${bucketOf(facts.packages.length)}`),
|
|
271
|
+
severity: `info`,
|
|
272
|
+
// The majors are named because they are what the turn is actually about — the minors and patches are a
|
|
273
|
+
// bulk operation the agent will enumerate itself, and listing four hundred of them here would bury it.
|
|
274
|
+
why:
|
|
275
|
+
`pnpm outdated reports ${plural(facts.packages.length, `dependency`, `dependencies`)} behind the registry in ` +
|
|
276
|
+
`${repoLabel(context.repo)}, ${majors.length} of them by a major version` +
|
|
277
|
+
`${majors.length === 0 ? `` : `: ${majors.map((entry) => `${entry.name} ${entry.current} → ${entry.latest}`).join(`; `)}`}.`,
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
diagnosis: `Version drift is cheap to fix continuously and expensive to fix in one go, because the majors start depending on each other.`,
|
|
281
|
+
goal:
|
|
282
|
+
`Take the patch and minor upgrades in one pass — those are what the lockfile can absorb without argument. Then take the majors ` +
|
|
283
|
+
`ONE AT A TIME, reading each one's changelog for breaking changes before you touch anything, and stop at the first one that ` +
|
|
284
|
+
`needs more than a mechanical fix: leave it, and say what it would take. Do not batch majors; a failing test after eight of them ` +
|
|
285
|
+
`is a bisect nobody wanted.`,
|
|
286
|
+
done: `Done when the repository's type-check and tests pass, and your summary names every major you took and every one you left, with the reason.`,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/* DEAD CODE. knip's counts, folded into one chore rather than split by kind: unused files, unused exports and
|
|
290
|
+
* unused dependencies are the same finding wearing three hats, they are fixed in one pass, and three rows that
|
|
291
|
+
* light together are three chances to teach someone to ignore the rail. */
|
|
292
|
+
const deadCode: Chore = {
|
|
293
|
+
id: `dead-code`,
|
|
294
|
+
title: `Clear out dead code`,
|
|
295
|
+
icon: `trash`,
|
|
296
|
+
description: `Files, exports and dependencies nothing in this repository references any more.`,
|
|
297
|
+
criterion: `knip reports at least one unreferenced file, export or dependency.`,
|
|
298
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository is not a Node project, and knip only reads those`),
|
|
299
|
+
stance: `act`,
|
|
300
|
+
needs: [`knip`],
|
|
301
|
+
cadenceMs: 14 * DAY_MS,
|
|
302
|
+
automation: {
|
|
303
|
+
cron: `0 3 * * *`,
|
|
304
|
+
// Two gates, so the two ways to not run are distinguishable in the run history: knip absent (a repo that
|
|
305
|
+
// never adopted it) reads differently from knip clean. `pnpm exec` resolves the repo's own devDependency
|
|
306
|
+
// rather than downloading a floating version that would disagree with its knip.json.
|
|
307
|
+
guard:
|
|
308
|
+
`pnpm exec knip --version >/dev/null 2>&1 || { echo "knip is not a devDependency of this repo"; exit 1; }; ` +
|
|
309
|
+
`pnpm exec knip --reporter json > ${KNIP_REPORT} && { echo "no dead code"; exit 1; }`,
|
|
310
|
+
note: `nightly · wakes only on findings`,
|
|
311
|
+
report: KNIP_REPORT,
|
|
312
|
+
woke: `knip's findings for this workspace are in ${KNIP_REPORT} (JSON), and it woke you because there are some.`,
|
|
313
|
+
},
|
|
314
|
+
assess: (context) => {
|
|
315
|
+
const facts = factsOf(context, `knip`);
|
|
316
|
+
if (facts === undefined) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const { files, exports, types, dependencies: unusedDeps, devDependencies, sample } = facts.deadCode;
|
|
320
|
+
const total = files + exports + types + unusedDeps + devDependencies;
|
|
321
|
+
if (total === 0) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
headline: `${plural(files, `unreferenced file`)}, ${exports + types} unused exports, ${unusedDeps + devDependencies} unused dependencies`,
|
|
326
|
+
detail: sample.map((path) => `unreferenced · ${path}`),
|
|
327
|
+
// The file identities carry the news (a newly-dead file is an event); the export and dependency counts
|
|
328
|
+
// ride along bucketed, since they drift by one constantly as code is written.
|
|
329
|
+
digest: digestOf(...sample.toSorted(), `exports:${bucketOf(exports + types)}`, `deps:${bucketOf(unusedDeps + devDependencies)}`),
|
|
330
|
+
severity: `info`,
|
|
331
|
+
// The sample rather than the full list, and the goal tells the agent to re-run knip for the rest: this
|
|
332
|
+
// measurement is hours old, and sending a turn at a file that has already been deleted wastes it.
|
|
333
|
+
why:
|
|
334
|
+
`knip reports ${plural(files, `unreferenced file`)}, ${exports + types} unused exports and ` +
|
|
335
|
+
`${unusedDeps + devDependencies} unused dependencies in ${repoLabel(context.repo)}` +
|
|
336
|
+
`${sample.length === 0 ? `` : `, among them ${sample.join(`, `)}`}.`,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
diagnosis: `Code nothing reaches still has to be read, type-checked and kept compiling by everyone who works nearby.`,
|
|
340
|
+
goal:
|
|
341
|
+
`Re-run knip yourself first — this measurement is hours old and the tree has moved. Then check each finding against how the ` +
|
|
342
|
+
`file is actually used: knip is confidently wrong about anything reachable from OUTSIDE the repository, which means a package's ` +
|
|
343
|
+
`public entry points, files a bundler or framework loads by convention, and types consumed only by a downstream package. Delete ` +
|
|
344
|
+
`what is genuinely unreachable. Leave the false positives and list them in one line each, so the next run's reader knows they ` +
|
|
345
|
+
`were considered rather than missed.`,
|
|
346
|
+
done: `Done when knip reports fewer findings, the repository's type-check and tests pass, and nothing you deleted is reachable from another package.`,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/* DUPLICATION. Report-stance, and it is the clearest case for why that stance exists at all. Most duplication
|
|
350
|
+
* should not be removed: generated files, tests that repeat on purpose, and two things that merely look alike
|
|
351
|
+
* today but answer to different owners tomorrow. Deciding which copies genuinely have to change together is a
|
|
352
|
+
* design judgement, and an agent that "collapses duplication" unattended produces exactly the abstraction that
|
|
353
|
+
* gets deleted a year later. */
|
|
354
|
+
const DUPLICATION_FLOOR = 5;
|
|
355
|
+
|
|
356
|
+
const duplication: Chore = {
|
|
357
|
+
id: `duplication`,
|
|
358
|
+
title: `Find duplication worth collapsing`,
|
|
359
|
+
icon: `clone`,
|
|
360
|
+
description: `Copy-paste that has grown past a fifth of a percent of the tree. Reports only — extracting is a design call.`,
|
|
361
|
+
criterion: `jscpd reports more than 5% of the scanned tree duplicated.`,
|
|
362
|
+
stance: `report`,
|
|
363
|
+
needs: [`jscpd`],
|
|
364
|
+
cadenceMs: 30 * DAY_MS,
|
|
365
|
+
automation: {
|
|
366
|
+
cron: `0 3 * * 1`,
|
|
367
|
+
// Gated on the percentage rather than "any clone at all", which every real repository has: below this the
|
|
368
|
+
// report is noise that would wake an agent every week to say nothing actionable.
|
|
369
|
+
guard:
|
|
370
|
+
`pnpm dlx jscpd --reporters json --output ${JSCPD_DIR} --min-lines 12 --threshold 100 . >/dev/null 2>&1; ` +
|
|
371
|
+
`[ "$(jq '.statistics.total.percentage // 0 | floor' ${JSCPD_REPORT} 2>/dev/null || echo 0)" -ge ${DUPLICATION_FLOOR} ]`,
|
|
372
|
+
note: `weekly · wakes above ${DUPLICATION_FLOOR}% duplication`,
|
|
373
|
+
report: JSCPD_REPORT,
|
|
374
|
+
woke: `jscpd's clone report for this workspace is in ${JSCPD_REPORT}, and it woke you because duplication is above ${DUPLICATION_FLOOR}%.`,
|
|
375
|
+
},
|
|
376
|
+
assess: (context) => {
|
|
377
|
+
const facts = factsOf(context, `jscpd`);
|
|
378
|
+
if (facts === undefined || facts.duplication.percentage < DUPLICATION_FLOOR) {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
const { percentage, clones, top } = facts.duplication;
|
|
382
|
+
return {
|
|
383
|
+
headline: `${percentage.toFixed(1)}% of the tree is duplicated, across ${plural(clones, `clone`)}`,
|
|
384
|
+
detail: top.map((clone) => `${clone.lines} lines · ${clone.first} ↔ ${clone.second}`),
|
|
385
|
+
// A whole percentage point is the smallest move worth calling news; the biggest clones' identities
|
|
386
|
+
// carry the rest, so a new large clone appearing is an event even at a flat percentage.
|
|
387
|
+
digest: digestOf(`pct:${Math.round(percentage)}`, ...top.map((clone) => `${clone.first}|${clone.second}`).toSorted()),
|
|
388
|
+
severity: `info`,
|
|
389
|
+
why:
|
|
390
|
+
`jscpd reports ${percentage.toFixed(1)}% duplication across ${plural(clones, `clone`)} in ${repoLabel(context.repo)}; ` +
|
|
391
|
+
`the largest are ${top.map((clone) => `${clone.first} ↔ ${clone.second} (${clone.lines} lines)`).join(`; `)}.`,
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
diagnosis: `Duplication only costs anything when the copies have to change together — and only some of it does.`,
|
|
395
|
+
goal:
|
|
396
|
+
`Report the clones where the copies genuinely have to change together. For each: cite both file:line ranges, say what the shared ` +
|
|
397
|
+
`concept actually is, and name where the extraction would live. Then say explicitly which of the reported clones you are NOT ` +
|
|
398
|
+
`recommending against — generated files, deliberately repetitive tests, and lookalikes owned by different subsystems — so the ` +
|
|
399
|
+
`next reader knows the list was triaged rather than truncated.`,
|
|
400
|
+
done: `Done when every clone in the report has either a named extraction or a one-line reason it should stay.`,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/* DOCUMENTATION. The evidence is a package with no architecture document, which sounds like a coverage statistic
|
|
404
|
+
* and would be one if the rail read it directly. It does not: the digest is the SET of undocumented package
|
|
405
|
+
* directories, so a long-standing backlog goes quiet after it is seen once, and a package appearing that nothing
|
|
406
|
+
* explains is an event that speaks. That is the whole difference between this being useful and being a nag. */
|
|
407
|
+
const documentation: Chore = {
|
|
408
|
+
id: `documentation-refresh`,
|
|
409
|
+
title: `Document what nothing explains`,
|
|
410
|
+
icon: `file-edit`,
|
|
411
|
+
description: `Packages in this repository with no architecture document — new ones first.`,
|
|
412
|
+
criterion: `A workspace package has no docs/architecture document.`,
|
|
413
|
+
applies: (signals) => (signals.packages.length > 0 ? undefined : `this repository is not a workspace, so it has no packages to document one by one`),
|
|
414
|
+
stance: `act`,
|
|
415
|
+
needs: [],
|
|
416
|
+
cadenceMs: 90 * DAY_MS,
|
|
417
|
+
assess: (context) => {
|
|
418
|
+
const undocumented = context.signals.packages.filter((entry) => !entry.documented);
|
|
419
|
+
if (undocumented.length === 0) {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
headline: `${plural(undocumented.length, `package`)} of ${context.signals.packages.length} have no document`,
|
|
424
|
+
detail: undocumented.map((entry) => `${entry.name} · ${entry.dir}`),
|
|
425
|
+
digest: digestOf(...undocumented.map((entry) => entry.dir).toSorted()),
|
|
426
|
+
severity: `info`,
|
|
427
|
+
why:
|
|
428
|
+
`${plural(undocumented.length, `package`)} of ${context.signals.packages.length} in ${repoLabel(context.repo)} have no ` +
|
|
429
|
+
`docs/architecture document: ${undocumented.map((entry) => entry.dir).join(`, `)}.`,
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
diagnosis: `A package nobody can read the shape of gets worked in by guesswork, and the guesses accumulate.`,
|
|
433
|
+
goal:
|
|
434
|
+
`Follow this workspace's own documentation conventions — read them first, they are not optional and they are not generic. For ` +
|
|
435
|
+
`each undocumented package, read the package before you write a word about it, and produce the document its conventions call ` +
|
|
436
|
+
`for: what the package is FOR, how it fits the system, and which files matter. Explain at the module level. Never describe code ` +
|
|
437
|
+
`line by line, and never document a package you did not read.`,
|
|
438
|
+
done: `Done when every package you named has a document that a newcomer could use to find the file they need, and no other file changed.`,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/* COMPLEXITY. The one chore whose evidence comes from the resident index rather than a subprocess, and the one
|
|
442
|
+
* most at risk of being a ranking laundered into a to-do list — there is ALWAYS a top of a hotspot ranking, and
|
|
443
|
+
* "your worst file" is not a finding. So it does not report the ranking. It reports the two shapes within it that
|
|
444
|
+
* are genuinely arguable:
|
|
445
|
+
*
|
|
446
|
+
* volatile AND load-bearing a hotspot that is also a key module: every edit ripples outward.
|
|
447
|
+
* out of proportion branching three times the median of its own ranking: tangled, not merely busy.
|
|
448
|
+
*
|
|
449
|
+
* Both are relative to the same list the user is reading, so nothing here needs tuning per repository or per
|
|
450
|
+
* language, and a healthy repo produces an empty set rather than a top five. */
|
|
451
|
+
const COMPLEXITY_MULTIPLE = 3;
|
|
452
|
+
|
|
453
|
+
const median = (values: readonly number[]): number => {
|
|
454
|
+
if (values.length === 0) {
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
458
|
+
const middle = Math.floor(sorted.length / 2);
|
|
459
|
+
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : (sorted[middle] ?? 0);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const complexity: Chore = {
|
|
463
|
+
id: `complexity`,
|
|
464
|
+
title: `Simplify what everything waits on`,
|
|
465
|
+
icon: `wave-pulse`,
|
|
466
|
+
description: `Files that both churn and carry the repository — where edits are slow and ripple outward.`,
|
|
467
|
+
criterion: `A file in the hotspot ranking is also a key module, or its branching is three times the median of that ranking.`,
|
|
468
|
+
stance: `act`,
|
|
469
|
+
needs: [],
|
|
470
|
+
cadenceMs: 30 * DAY_MS,
|
|
471
|
+
assess: (context) => {
|
|
472
|
+
// A half-built index ranks whatever it has finished reading, which is not the repository. Better to say
|
|
473
|
+
// nothing than to send a turn at the wrong file.
|
|
474
|
+
if (!context.signals.indexed || context.signals.hotspots.length === 0) {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
const keyModules = new Set(context.signals.keyModules.map((module) => module.path));
|
|
478
|
+
const middle = median(context.signals.hotspots.map((hotspot) => hotspot.complexity));
|
|
479
|
+
const found = context.signals.hotspots.filter(
|
|
480
|
+
(hotspot) => keyModules.has(hotspot.path) || hotspot.complexity >= middle * COMPLEXITY_MULTIPLE,
|
|
481
|
+
);
|
|
482
|
+
if (found.length === 0) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
const reason = (path: string, branches: number): string =>
|
|
486
|
+
keyModules.has(path) ? `churns and the rest of the repository imports it` : `${branches} branch points against a median of ${middle}`;
|
|
487
|
+
return {
|
|
488
|
+
headline: `${plural(found.length, `file`)} where every edit is slow and ripples outward`,
|
|
489
|
+
detail: found.map((hotspot) => `${hotspot.path} — ${hotspot.commits} commits, ${reason(hotspot.path, hotspot.complexity)}`),
|
|
490
|
+
digest: digestOf(...found.map((hotspot) => hotspot.path).toSorted()),
|
|
491
|
+
severity: `info`,
|
|
492
|
+
why:
|
|
493
|
+
`${plural(found.length, `file`)} in ${repoLabel(context.repo)} are both change magnets and structurally tangled: ` +
|
|
494
|
+
`${found.map((hotspot) => `${hotspot.path} (${hotspot.commits} commits, ${hotspot.complexity} branch points)`).join(`; `)}.`,
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
diagnosis: `A file that changes constantly and branches heavily makes every edit near it slow and easy to get wrong.`,
|
|
498
|
+
goal:
|
|
499
|
+
`Take ONE file — the worst of them — and no more. Read it first. If the rest of the repository imports it, separate the stable ` +
|
|
500
|
+
`contract from the churn: a narrow surface for importers, the volatile implementation private behind it. If it is simply ` +
|
|
501
|
+
`tangled, flatten it where it stands — edge cases as early returns, compound conditions behind named predicates, long chains as ` +
|
|
502
|
+
`lookups — and extract a unit only if a cohesive one falls out. Behaviour stays identical, and no re-export shims are left behind.`,
|
|
503
|
+
done: `Done when \`iq hotspots\` reports materially fewer branch points for that file, the repository's checks pass, and no importer changed meaning.`,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/* RUNTIME. A static table, and it is honest about being one: there is no network call here, so the dates below
|
|
507
|
+
* are a fact about the day this file was last edited rather than a live feed. That is the right trade for a
|
|
508
|
+
* signal that moves twice a year and must work on a box with no outbound access — but it does mean this table is
|
|
509
|
+
* maintenance in its own right, and a major missing from it reads as "not end-of-life", which is the safe way to
|
|
510
|
+
* be wrong. Source: nodejs/Release. */
|
|
511
|
+
const NODE_EOL: Readonly<Record<number, string>> = {
|
|
512
|
+
16: `2023-09-11`,
|
|
513
|
+
18: `2025-04-30`,
|
|
514
|
+
20: `2026-04-30`,
|
|
515
|
+
22: `2027-04-30`,
|
|
516
|
+
24: `2028-04-30`,
|
|
517
|
+
};
|
|
518
|
+
// How far ahead of an end-of-life date the chore starts speaking. A quarter, because moving a runtime is planned
|
|
519
|
+
// work — telling someone the day security patches stop is telling them too late to do anything but scramble.
|
|
520
|
+
const EOL_HORIZON_MS = 90 * DAY_MS;
|
|
521
|
+
|
|
522
|
+
const runtime: Chore = {
|
|
523
|
+
id: `runtime-eol`,
|
|
524
|
+
title: `Move off an end-of-life runtime`,
|
|
525
|
+
icon: `bolt`,
|
|
526
|
+
description: `Whether the Node this sandbox runs still receives security patches.`,
|
|
527
|
+
criterion: `The Node release this sandbox runs is past its end-of-life date, or within 90 days of it.`,
|
|
528
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository is not a Node project, so the sandbox's runtime is not its concern`),
|
|
529
|
+
stance: `act`,
|
|
530
|
+
needs: [],
|
|
531
|
+
cadenceMs: 0,
|
|
532
|
+
assess: (context) => {
|
|
533
|
+
const major = Number.parseInt(context.node.replace(/^v/, ``), 10);
|
|
534
|
+
const eol = NODE_EOL[major];
|
|
535
|
+
if (Number.isNaN(major) || eol === undefined) {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
const eolMs = Date.parse(`${eol}T00:00:00Z`);
|
|
539
|
+
if (context.nowMs < eolMs - EOL_HORIZON_MS) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
const past = context.nowMs >= eolMs;
|
|
543
|
+
const days = Math.round(Math.abs(eolMs - context.nowMs) / DAY_MS);
|
|
544
|
+
// Which packages would have to be argued with, so the finding names the work rather than only the fact.
|
|
545
|
+
const pinned = context.signals.packages.filter((entry) => entry.engines?.[`node`] !== undefined);
|
|
546
|
+
return {
|
|
547
|
+
headline: past ? `Node ${major} stopped receiving security patches ${days} days ago` : `Node ${major} reaches end of life in ${days} days`,
|
|
548
|
+
detail: [
|
|
549
|
+
`running · ${context.node}`,
|
|
550
|
+
`end of life · ${eol}`,
|
|
551
|
+
...pinned.map((entry) => `pinned · ${entry.name} requires node ${entry.engines?.[`node`] ?? ``}`),
|
|
552
|
+
],
|
|
553
|
+
// The state, not the date: a countdown would mint a new digest every single day and badge forever.
|
|
554
|
+
digest: digestOf(`node:${major}`, past ? `eol` : `approaching`),
|
|
555
|
+
severity: past ? `warning` : `info`,
|
|
556
|
+
why:
|
|
557
|
+
`This sandbox runs ${context.node}, and Node ${major} ${past ? `reached end of life on ${eol}` : `reaches end of life on ${eol}`} — ` +
|
|
558
|
+
`${plural(pinned.length, `package`)} in ${repoLabel(context.repo)} pin a node engine range.`,
|
|
559
|
+
};
|
|
560
|
+
},
|
|
561
|
+
diagnosis: `An unsupported runtime stops receiving security patches, so every advisory against it stays open permanently.`,
|
|
562
|
+
goal:
|
|
563
|
+
`Establish what actually pins this runtime: the image's own base, the workspace's useNodeVersion, and each package's engines ` +
|
|
564
|
+
`range. Propose the smallest move to a supported LTS — which of those pins have to change, in what order, and what is likely to ` +
|
|
565
|
+
`break at that boundary. Make the pin changes that are mechanical; do NOT attempt the image rebuild itself.`,
|
|
566
|
+
done: `Done when the pins name a supported release, the repository's type-check and tests pass on it, and anything needing a rebuild is named as such.`,
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/* LIBRARIES. The one chore here with evidence for a question that usually gets asked as a vibe ("should we be
|
|
570
|
+
* using a library for this?"). Two libraries that solve the same problem in one tree is a fact, not an opinion:
|
|
571
|
+
* somebody added the second one without removing the first, both are now in the bundle, and new code picks
|
|
572
|
+
* whichever the neighbouring file used. The table below is deliberately short and only names categories where
|
|
573
|
+
* having two is genuinely a mistake — not, say, two test runners, which is an ordinary migration. */
|
|
574
|
+
const CATEGORIES: readonly { readonly category: string; readonly members: readonly string[] }[] = [
|
|
575
|
+
{ category: `date handling`, members: [`moment`, `dayjs`, `date-fns`, `luxon`, `js-joda`] },
|
|
576
|
+
{ category: `HTTP clients`, members: [`axios`, `got`, `node-fetch`, `superagent`, `undici`, `request`] },
|
|
577
|
+
{ category: `schema validation`, members: [`zod`, `yup`, `joi`, `ajv`, `superstruct`, `valibot`] },
|
|
578
|
+
{ category: `utility belts`, members: [`lodash`, `underscore`, `ramda`, `remeda`] },
|
|
579
|
+
{ category: `state stores`, members: [`redux`, `mobx`, `zustand`, `jotai`, `recoil`, `pinia`, `valtio`] },
|
|
580
|
+
{ category: `UUID generation`, members: [`uuid`, `nanoid`, `cuid`, `shortid`, `ulid`] },
|
|
581
|
+
{ category: `test runners`, members: [`jest`, `mocha`, `ava`, `tap`] },
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
const libraries: Chore = {
|
|
585
|
+
id: `library-overlap`,
|
|
586
|
+
title: `Settle on one library per job`,
|
|
587
|
+
icon: `box`,
|
|
588
|
+
description: `Two dependencies solving the same problem — both shipped, both maintained, one picked at random.`,
|
|
589
|
+
criterion: `Two or more installed dependencies do the same job.`,
|
|
590
|
+
applies: (signals) => (signals.packages.length > 0 ? undefined : `this repository is not a workspace, so there are no package manifests to compare`),
|
|
591
|
+
stance: `report`,
|
|
592
|
+
needs: [],
|
|
593
|
+
cadenceMs: 90 * DAY_MS,
|
|
594
|
+
assess: (context) => {
|
|
595
|
+
const installed = new Set(context.signals.packages.flatMap((entry) => [...entry.dependencies, ...entry.devDependencies]));
|
|
596
|
+
const collisions = CATEGORIES.map(({ category, members }) => ({ category, found: members.filter((member) => installed.has(member)) })).filter(
|
|
597
|
+
({ found }) => found.length > 1,
|
|
598
|
+
);
|
|
599
|
+
if (collisions.length === 0) {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
headline: `${plural(collisions.length, `job`)} done by more than one library`,
|
|
604
|
+
detail: collisions.map(({ category, found }) => `${category} · ${found.join(`, `)}`),
|
|
605
|
+
digest: digestOf(...collisions.map(({ category, found }) => `${category}:${found.toSorted().join(`+`)}`).toSorted()),
|
|
606
|
+
severity: `info`,
|
|
607
|
+
why:
|
|
608
|
+
`${repoLabel(context.repo)} depends on more than one library for the same job: ` +
|
|
609
|
+
`${collisions.map(({ category, found }) => `${category} (${found.join(`, `)})`).join(`; `)}.`,
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
diagnosis: `Two libraries for one job means both ship, both need upgrading, and new code picks whichever the neighbouring file used.`,
|
|
613
|
+
goal:
|
|
614
|
+
`For each overlapping pair, find out which one is actually load-bearing — how many call sites each has, whether one is a ` +
|
|
615
|
+
`transitive dependency nobody chose, and whether either is unmaintained. Recommend the one to keep and estimate the migration ` +
|
|
616
|
+
`honestly, including the call sites where the two libraries genuinely differ in behaviour. Where the overlap is deliberate or ` +
|
|
617
|
+
`the second is only transitive, say so and close the question.`,
|
|
618
|
+
done: `Done when every overlapping pair has a recommendation with a call-site count behind it, or a reason the overlap is fine.`,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/* THE SURVEYS. Chores with no measurement at all, and they are here because the absence of a measurement is not
|
|
622
|
+
* the absence of value — these are the reviews a codebase silently rots without, and none of them can be detected
|
|
623
|
+
* by a tool. Their trigger is the calendar, and the ledger is what makes that trigger honest: a survey is due
|
|
624
|
+
* because it has not been done in a quarter, which is a claim the panel can show and the reader can check.
|
|
625
|
+
*
|
|
626
|
+
* All of them are report-stance. A survey that starts editing is the most surprising thing this surface could do,
|
|
627
|
+
* and none of them has a specific enough finding to justify a diff.
|
|
628
|
+
*
|
|
629
|
+
* A SURVEY NEEDS ITS `applies` GATE MORE THAN A MEASURED CHORE DOES, not less, and this is the trap the shape of
|
|
630
|
+
* the thing sets. A measured chore is gated by its own evidence for free: no undocumented packages, no finding,
|
|
631
|
+
* no row. A survey has no evidence to be absent — "90 days have passed" is true of every repository in the
|
|
632
|
+
* world — so without a gate it fires everywhere, forever, including in the repositories where its subject does
|
|
633
|
+
* not exist. "Re-read the documentation against the code" in a repository with no documentation is the exact
|
|
634
|
+
* failure, and it is not a hypothetical: it is what this helper did before the gate existed.
|
|
635
|
+
*
|
|
636
|
+
* An options object rather than the eight positional arguments this grew into: `id, title, icon, description,
|
|
637
|
+
* diagnosis, goal, done, 90` reads as nothing at all at the call site, and the gate would have made it nine. */
|
|
638
|
+
interface SurveySpec {
|
|
639
|
+
readonly id: string;
|
|
640
|
+
readonly title: string;
|
|
641
|
+
readonly icon: string;
|
|
642
|
+
readonly description: string;
|
|
643
|
+
readonly diagnosis: string;
|
|
644
|
+
readonly goal: string;
|
|
645
|
+
readonly done: string;
|
|
646
|
+
readonly cadenceDays: number;
|
|
647
|
+
// What must exist in the repository for this review to have a subject. Required, not optional, precisely
|
|
648
|
+
// because forgetting it is the failure mode above — a survey that genuinely applies everywhere still has to
|
|
649
|
+
// say so out loud, with `() => undefined`.
|
|
650
|
+
readonly applies: (signals: ChoreSignals) => string | undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const survey = ({ id, title, icon, description, diagnosis, goal, done, cadenceDays, applies }: SurveySpec): Chore => ({
|
|
654
|
+
id,
|
|
655
|
+
title,
|
|
656
|
+
icon,
|
|
657
|
+
description,
|
|
658
|
+
criterion: `${cadenceDays} days have passed since this review was last run.`,
|
|
659
|
+
applies,
|
|
660
|
+
stance: `report`,
|
|
661
|
+
needs: [],
|
|
662
|
+
cadenceMs: cadenceDays * DAY_MS,
|
|
663
|
+
survey: true,
|
|
664
|
+
// A survey's evidence is that time has passed, so the digest is the PERIOD it is due for: one badge per
|
|
665
|
+
// quarter, and a run inside that quarter settles it until the next one begins.
|
|
666
|
+
assess: (context) => ({
|
|
667
|
+
headline: `Not surveyed in ${cadenceDays} days`,
|
|
668
|
+
detail: [`Cadence · every ${cadenceDays} days`],
|
|
669
|
+
digest: digestOf(id, `period:${Math.floor(context.nowMs / (cadenceDays * DAY_MS))}`),
|
|
670
|
+
severity: `info`,
|
|
671
|
+
why: `This is a periodic review of ${repoLabel(context.repo)}, run every ${cadenceDays} days; nothing measured it — it is due because it has been that long.`,
|
|
672
|
+
}),
|
|
673
|
+
diagnosis,
|
|
674
|
+
goal,
|
|
675
|
+
done,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Below this a repository is too small for cross-cutting patterns to have diverged from each other: there is one
|
|
679
|
+
// way things are done because there is barely more than one place doing them. Counted in INDEXED files, so a
|
|
680
|
+
// scaffold that is mostly config and lockfiles does not pass it by accident.
|
|
681
|
+
const PATTERNS_FLOOR = 25;
|
|
682
|
+
|
|
683
|
+
const patterns = survey({
|
|
684
|
+
id: `standardize-patterns`,
|
|
685
|
+
title: `Standardize the cross-cutting patterns`,
|
|
686
|
+
icon: `sitemap`,
|
|
687
|
+
description: `Error handling, validation, logging, configuration, retries, pagination — the things every file does slightly differently.`,
|
|
688
|
+
diagnosis: `Cross-cutting concerns drift one file at a time, and the cost only shows up when someone has to work across several of them.`,
|
|
689
|
+
goal:
|
|
690
|
+
`Pick the cross-cutting concerns this repository actually has — error handling, input validation, logging, configuration, retries, ` +
|
|
691
|
+
`pagination, serialization — and for each, survey how it is done. Name the dominant pattern, the outliers, and which of the ` +
|
|
692
|
+
`outliers are deliberate. Recommend ONE convention per concern with a file to point at as the reference implementation, and ` +
|
|
693
|
+
`estimate the size of the conversion. Do not convert anything.`,
|
|
694
|
+
done: `Done when each concern has a named convention, a reference file, and a count of the sites that diverge from it.`,
|
|
695
|
+
cadenceDays: 90,
|
|
696
|
+
applies: (signals) =>
|
|
697
|
+
signals.totals.files >= PATTERNS_FLOOR
|
|
698
|
+
? undefined
|
|
699
|
+
: `this repository has ${signals.totals.files} indexed files — too few for cross-cutting patterns to have diverged`,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const deprecated = survey({
|
|
703
|
+
id: `deprecated-apis`,
|
|
704
|
+
title: `Audit deprecated APIs`,
|
|
705
|
+
icon: `exclamation-triangle`,
|
|
706
|
+
description: `Language, runtime and framework APIs this code still uses that their own maintainers have moved on from.`,
|
|
707
|
+
diagnosis: `A deprecated API works right up until the upgrade that removes it, and then it is an emergency during someone else's migration.`,
|
|
708
|
+
goal:
|
|
709
|
+
`Survey what this repository uses that its own dependencies have deprecated: read the framework and runtime versions in use, check ` +
|
|
710
|
+
`their deprecation notices, and search for the call sites. Include the repository's OWN deprecations — anything its code marks ` +
|
|
711
|
+
`as deprecated and still calls. Rank by when each one actually breaks, not by how many call sites it has, and name the ` +
|
|
712
|
+
`replacement for each. Change nothing.`,
|
|
713
|
+
done: `Done when every deprecation has call sites cited, a replacement named, and the release it is expected to break in.`,
|
|
714
|
+
cadenceDays: 90,
|
|
715
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository declares no dependencies whose deprecations could be read`),
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
/* THE CHORE THAT NAMED THE PROBLEM. Gated on documents actually EXISTING, which is the whole reason `applies`
|
|
719
|
+
* exists: without it this survey fires on its cadence in every repository, including the ones with nothing to
|
|
720
|
+
* re-read, and the first thing an owner of a fresh workspace sees is an offer to re-read documentation they have
|
|
721
|
+
* never written. That is not a chore being wrong about a threshold — it is the surface admitting it never looked.
|
|
722
|
+
*
|
|
723
|
+
* Note which fact it gates on: the DOCUMENTS, not the directory. An empty `docs/architecture/` is a directory
|
|
724
|
+
* somebody made and never filled, and a gate on the directory would put the chore back exactly where it started. */
|
|
725
|
+
const documentationDrift = survey({
|
|
726
|
+
id: `documentation-drift`,
|
|
727
|
+
title: `Re-read the documentation against the code`,
|
|
728
|
+
icon: `file`,
|
|
729
|
+
description: `Whether what the documents claim is still what the code does — the drift no tool can measure.`,
|
|
730
|
+
diagnosis: `Documentation is trusted in proportion to how recently it was true, and a document that is quietly wrong is worse than a missing one.`,
|
|
731
|
+
goal:
|
|
732
|
+
`Read this repository's architecture documents against the code they describe. Report every claim that is no longer true, citing the ` +
|
|
733
|
+
`document line and the file that contradicts it. Prioritise the claims someone would ACT on — where a subsystem lives, what owns ` +
|
|
734
|
+
`what, which file to change — over prose that has merely aged. Do not rewrite the documents; produce the list of what is wrong.`,
|
|
735
|
+
done: `Done when every architecture document has been read and every false claim is listed with both sides cited.`,
|
|
736
|
+
cadenceDays: 90,
|
|
737
|
+
applies: (signals) => (signals.shape.docs.length > 0 ? undefined : `this repository has no architecture documents to re-read`),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
/* THE TWO CHORES THAT ONLY EXIST WHERE THEIR SUBJECT DOES. Both are surveys — nothing here can measure whether a
|
|
741
|
+
* pipeline caches well or an image is bigger than it needs to be without running them, and running someone's CI
|
|
742
|
+
* to find out would be a strange thing for a maintenance panel to do — so both are gated on the artefact itself.
|
|
743
|
+
* Together they are the argument for `applies` being first-class rather than folded into `assess`: neither has
|
|
744
|
+
* any evidence to be absent, and in a repository with no pipeline and no image both would otherwise sit in the
|
|
745
|
+
* list forever, permanently due, describing work that cannot be done. */
|
|
746
|
+
const pipelines = survey({
|
|
747
|
+
id: `ci-hygiene`,
|
|
748
|
+
title: `Tighten the CI pipeline`,
|
|
749
|
+
icon: `bolt`,
|
|
750
|
+
description: `What the pipeline re-does every run: uncached installs, rebuilt layers, jobs that could run in parallel.`,
|
|
751
|
+
diagnosis: `A slow pipeline is paid on every push by everyone, and it degrades one uncached step at a time without anyone deciding to.`,
|
|
752
|
+
goal:
|
|
753
|
+
`Read this repository's pipeline definitions and report what it pays for repeatedly: dependency installs with no cache key, ` +
|
|
754
|
+
`build outputs recomputed between jobs, steps that are serial for no reason, and matrix legs that duplicate each other's work. ` +
|
|
755
|
+
`For each, name the file and step, say roughly what it costs per run, and give the change that would fix it. Where a step is slow ` +
|
|
756
|
+
`because it genuinely has to be, say so — a pipeline that is honestly expensive is not a finding.`,
|
|
757
|
+
done: `Done when every finding names a file, a step, and a concrete change, and anything deliberately slow is called out as such.`,
|
|
758
|
+
cadenceDays: 90,
|
|
759
|
+
applies: (signals) => (signals.shape.ci.length > 0 ? undefined : `this repository defines no CI pipeline`),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const images = survey({
|
|
763
|
+
id: `docker-image`,
|
|
764
|
+
title: `Slim the container image`,
|
|
765
|
+
icon: `box`,
|
|
766
|
+
description: `Layer order, build context and final size — what ships in the image that did not need to.`,
|
|
767
|
+
diagnosis: `Image size is paid on every pull and every cold start, and layer order decides how much of a build is cache hits.`,
|
|
768
|
+
goal:
|
|
769
|
+
`Read this repository's Dockerfiles and report what makes the image larger or the build slower than it needs to be: layers ordered ` +
|
|
770
|
+
`so that a source edit invalidates the dependency install, build-time toolchains left in the final stage, a build context that ships ` +
|
|
771
|
+
`the whole repository, and package caches never cleaned. For each, cite the file and line, and name the change. Do not rewrite the ` +
|
|
772
|
+
`Dockerfiles — an image that fails to build is a much worse problem than one that is larger than ideal.`,
|
|
773
|
+
done: `Done when every finding cites a Dockerfile line and names the change, with the ones that would need a base-image swap called out separately.`,
|
|
774
|
+
cadenceDays: 90,
|
|
775
|
+
applies: (signals) => (signals.shape.dockerfiles.length > 0 ? undefined : `this repository ships no Dockerfile`),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
/* THE BOOK'S ORDER, which is the panel's reading order and therefore a product decision rather than whatever
|
|
779
|
+
* order these were written in. It narrows from "this is a risk you are carrying right now" to "this is worth
|
|
780
|
+
* thinking about this quarter":
|
|
781
|
+
* carrying security, runtime — someone else decides when these become urgent
|
|
782
|
+
* accruing dependencies, dead code, complexity — cheap now, expensive later, and always getting later
|
|
783
|
+
* drifting documentation, duplication, libraries — the shape of the thing is diverging from the idea of it
|
|
784
|
+
* surveying the periodic reads, which have no urgency by construction
|
|
785
|
+
*
|
|
786
|
+
* Ordering is by KIND, not by whether a given repository will see them: a chore that does not apply is dropped
|
|
787
|
+
* from that repository's list entirely (verdict.ts), so the reading order never has holes in it. */
|
|
788
|
+
export const CHORES: readonly Chore[] = [
|
|
789
|
+
security,
|
|
790
|
+
runtime,
|
|
791
|
+
dependencies,
|
|
792
|
+
deadCode,
|
|
793
|
+
complexity,
|
|
794
|
+
documentation,
|
|
795
|
+
duplication,
|
|
796
|
+
libraries,
|
|
797
|
+
patterns,
|
|
798
|
+
deprecated,
|
|
799
|
+
documentationDrift,
|
|
800
|
+
pipelines,
|
|
801
|
+
images,
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
export const choreById = (id: string): Chore | undefined => CHORES.find((chore) => chore.id === id);
|
|
805
|
+
|
|
806
|
+
// The prompt for one chore against one finding. Built here rather than in the view because the panel, the badge's
|
|
807
|
+
// tooltip and the automation that runs unattended must all be describing the same turn.
|
|
808
|
+
/* THE SCHEDULED TURN, for a chore woken by its automation rather than started from the panel. Same four parts and
|
|
809
|
+
* the same invariants — a chore asks for the same work whoever started it — with the guard's own report standing
|
|
810
|
+
* in for the finding, because at 3am there is no verdict to quote and no reader to have checked it first.
|
|
811
|
+
*
|
|
812
|
+
* Workspace-wide rather than per repository: an automation's guard runs at the workspace root on the sandbox's
|
|
813
|
+
* clock, and it has no repo argument to be scoped by. */
|
|
814
|
+
export const choreAutomationPrompt = (chore: Chore): string | undefined =>
|
|
815
|
+
chore.automation === undefined
|
|
816
|
+
? undefined
|
|
817
|
+
: composeAsk({
|
|
818
|
+
subject: `${chore.title} across this workspace.`,
|
|
819
|
+
why: `${chore.automation.woke} ${TRIAGE_NOTE}`,
|
|
820
|
+
diagnosis: chore.diagnosis,
|
|
821
|
+
goal: chore.goal,
|
|
822
|
+
invariants: chore.stance === `act` ? CHORE_INVARIANTS : REPORT_INVARIANTS,
|
|
823
|
+
done: chore.done,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
export const chorePrompt = (chore: Chore, finding: ChoreFinding, repo: string): string =>
|
|
827
|
+
composeAsk({
|
|
828
|
+
subject: `${chore.title} in ${repoLabel(repo)}.`,
|
|
829
|
+
// The RULE before the numbers. An agent told only "4 majors waiting" has to infer why anyone cares; told
|
|
830
|
+
// the criterion it was woken by, it can also tell us the criterion was wrong — which is the single most
|
|
831
|
+
// useful thing a chore turn can report back, and the only way the book gets better.
|
|
832
|
+
why: `${finding.why} You were woken because: ${chore.criterion} ${TRIAGE_NOTE}`,
|
|
833
|
+
diagnosis: chore.diagnosis,
|
|
834
|
+
goal: chore.goal,
|
|
835
|
+
invariants: chore.stance === `act` ? CHORE_INVARIANTS : REPORT_INVARIANTS,
|
|
836
|
+
done: chore.done,
|
|
837
|
+
});
|