@intentic/sandbox-contract 1.170.0 → 1.172.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/agent-catalog.d.ts +1 -0
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +1 -0
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/chores/chores.d.ts +45 -0
  6. package/dist/chores/chores.d.ts.map +1 -0
  7. package/dist/chores/chores.js +487 -0
  8. package/dist/chores/chores.js.map +1 -0
  9. package/dist/chores/digest.d.ts +3 -0
  10. package/dist/chores/digest.d.ts.map +1 -0
  11. package/dist/chores/digest.js +0 -0
  12. package/dist/chores/digest.js.map +1 -0
  13. package/dist/chores/index.d.ts +10 -0
  14. package/dist/chores/index.d.ts.map +1 -0
  15. package/dist/chores/index.js +6 -0
  16. package/dist/chores/index.js.map +1 -0
  17. package/dist/chores/probes.d.ts +15 -0
  18. package/dist/chores/probes.d.ts.map +1 -0
  19. package/dist/chores/probes.js +177 -0
  20. package/dist/chores/probes.js.map +1 -0
  21. package/dist/chores/prompt.d.ts +14 -0
  22. package/dist/chores/prompt.d.ts.map +1 -0
  23. package/dist/chores/prompt.js +12 -0
  24. package/dist/chores/prompt.js.map +1 -0
  25. package/dist/chores/verdict.d.ts +20 -0
  26. package/dist/chores/verdict.d.ts.map +1 -0
  27. package/dist/chores/verdict.js +59 -0
  28. package/dist/chores/verdict.js.map +1 -0
  29. package/dist/contracts/agent.contract.d.ts +11 -0
  30. package/dist/contracts/agent.contract.d.ts.map +1 -1
  31. package/dist/contracts/agents.contract.d.ts +2 -0
  32. package/dist/contracts/agents.contract.d.ts.map +1 -1
  33. package/dist/contracts/automations.contract.d.ts +54 -0
  34. package/dist/contracts/automations.contract.d.ts.map +1 -1
  35. package/dist/contracts/chores.contract.d.ts +151 -0
  36. package/dist/contracts/chores.contract.d.ts.map +1 -0
  37. package/dist/contracts/chores.contract.js +8 -0
  38. package/dist/contracts/chores.contract.js.map +1 -0
  39. package/dist/contracts/system.contract.d.ts +14 -14
  40. package/dist/contracts/workspace.contract.d.ts +11 -24
  41. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  42. package/dist/events.d.ts +10 -0
  43. package/dist/events.d.ts.map +1 -1
  44. package/dist/events.js +7 -1
  45. package/dist/events.js.map +1 -1
  46. package/dist/hostnames.d.ts +0 -1
  47. package/dist/hostnames.d.ts.map +1 -1
  48. package/dist/hostnames.js +0 -1
  49. package/dist/hostnames.js.map +1 -1
  50. package/dist/index.d.ts +243 -38
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/schemas.d.ts +590 -40
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +136 -2
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/tunnel-ids.d.ts +2 -0
  59. package/dist/tunnel-ids.d.ts.map +1 -1
  60. package/dist/tunnel-ids.js +2 -0
  61. package/dist/tunnel-ids.js.map +1 -1
  62. package/dist/workspace-state.d.ts.map +1 -1
  63. package/dist/workspace-state.js +5 -0
  64. package/dist/workspace-state.js.map +1 -1
  65. package/package.json +14 -2
  66. package/src/agent-catalog.test.ts +32 -1
  67. package/src/agent-catalog.ts +15 -0
  68. package/src/chores/chores.ts +837 -0
  69. package/src/chores/digest.test.ts +30 -0
  70. package/src/chores/digest.ts +0 -0
  71. package/src/chores/index.ts +9 -0
  72. package/src/chores/probes.test.ts +166 -0
  73. package/src/chores/probes.ts +273 -0
  74. package/src/chores/prompt.ts +64 -0
  75. package/src/chores/verdict.test.ts +394 -0
  76. package/src/chores/verdict.ts +167 -0
  77. package/src/contracts/chores.contract.ts +23 -0
  78. package/src/events.ts +15 -2
  79. package/src/hostnames.ts +4 -6
  80. package/src/index.ts +3 -0
  81. package/src/schemas.ts +383 -13
  82. package/src/tunnel-ids.test.ts +49 -0
  83. package/src/tunnel-ids.ts +24 -0
  84. package/src/workspace-state.ts +5 -0
@@ -0,0 +1,394 @@
1
+ import type { ChoreLedgerEntry, ChorePackage, ChoresReport, ChoreShape, ChoreSignals, ProbeResult } from "../schemas.js";
2
+ import { describe, expect, test } from "vitest";
3
+ import { choreById, CHORES } from "./chores.js";
4
+ import { assessReport, ledgerKey, unseenVerdicts } from "./verdict.js";
5
+
6
+ /* The state machine, tested at the distinctions it exists to draw. Every case below is one that a simpler design
7
+ * gets wrong in a way that costs the surface its credibility: reporting a repository clean that was never
8
+ * measured, badging the same finding every hour while its fix sits in review, letting a snooze become a
9
+ * permanent silence, or letting an agent's "these were false positives" be forgotten by the next poll. */
10
+
11
+ const DAY = 86_400_000;
12
+ const NOW = Date.UTC(2026, 6, 31);
13
+
14
+ const pkg = (over: Partial<ChorePackage> = {}): ChorePackage => ({
15
+ dir: `_libs/thing`,
16
+ name: `@x/thing`,
17
+ dependencies: [],
18
+ devDependencies: [],
19
+ documented: true,
20
+ ...over,
21
+ });
22
+
23
+ // A repository that is a Node workspace with documents, a pipeline and an image — so every chore APPLIES by
24
+ // default and each applicability test can turn off exactly the one fact it is about.
25
+ const shape = (over: Partial<ChoreShape> = {}): ChoreShape => ({
26
+ docs: [`docs/architecture/repo.md`],
27
+ dockerfiles: [`Dockerfile`],
28
+ ci: [`.github/workflows/ci.yml`],
29
+ lockfile: true,
30
+ packageManifest: true,
31
+ ...over,
32
+ });
33
+
34
+ const signals = (over: Partial<ChoreSignals> = {}): ChoreSignals => ({
35
+ packages: [pkg()],
36
+ shape: shape(),
37
+ hotspots: [],
38
+ keyModules: [],
39
+ totals: { files: 100, symbols: 1000, complexity: 900, hotspots: 0 },
40
+ indexed: true,
41
+ ...over,
42
+ });
43
+
44
+ const probe = (over: Partial<ProbeResult> & Pick<ProbeResult, "id">): ProbeResult => ({ state: `ok`, ranAt: NOW - DAY, tookMs: 1000, ...over });
45
+
46
+ const auditProbe = (names: readonly string[]): ProbeResult =>
47
+ probe({
48
+ id: `audit`,
49
+ facts: { id: `audit`, advisories: names.map((name) => ({ name, severity: `high` as const, title: `${name} is bad`, patched: `>=2`, dev: false })) },
50
+ });
51
+
52
+ const report = (over: Partial<ChoresReport> = {}): ChoresReport => ({ repos: [{ repo: `app`, probes: [], signals: signals() }], ledger: [], node: `v24.18.0`, ...over });
53
+
54
+ const verdictFor = (input: ChoresReport, chore: string, repo = `app`) => {
55
+ const found = assessReport(input, NOW).find((verdict) => verdict.chore.id === chore && verdict.repo === repo);
56
+ if (found === undefined) {
57
+ throw new Error(`no verdict for ${chore} in ${repo}`);
58
+ }
59
+ return found;
60
+ };
61
+
62
+ describe(`what "we have not measured this" means`, () => {
63
+ test(`a chore whose probe never ran is unavailable, not clear`, () => {
64
+ const verdict = verdictFor(report(), `security-advisories`);
65
+ expect(verdict.state).toBe(`unavailable`);
66
+ expect(verdict.detail).toEqual([`Security advisories · not measured yet`]);
67
+ });
68
+
69
+ // The distinction that stops the panel reporting a green repository it has never looked at. A tool the repo
70
+ // does not have is not evidence of anything, and it carries the tool's own reason rather than an invented one.
71
+ test(`a probe the repository cannot run says so, and never badges`, () => {
72
+ const input = report({
73
+ repos: [{ repo: `app`, probes: [probe({ id: `knip`, state: `unavailable`, reason: `knip is not a devDependency` })], signals: signals() }],
74
+ });
75
+ const verdict = verdictFor(input, `dead-code`);
76
+ expect(verdict.state).toBe(`unavailable`);
77
+ expect(verdict.detail[0]).toContain(`knip is not a devDependency`);
78
+ expect(unseenVerdicts([verdict], {})).toEqual([]);
79
+ });
80
+
81
+ test(`a probe that ran and found nothing is clear, with no prompt to spend a turn on`, () => {
82
+ const verdict = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([])], signals: signals() }] }), `security-advisories`);
83
+ expect(verdict.state).toBe(`clear`);
84
+ expect(verdict.prompt).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe(`the ledger debounces; it cannot hide`, () => {
89
+ const withAdvisories = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] });
90
+ const ledgerEntry = (over: Partial<ChoreLedgerEntry> = {}): ChoreLedgerEntry => ({
91
+ repo: `app`,
92
+ chore: `security-advisories`,
93
+ ranAt: NOW - DAY,
94
+ runId: `r1`,
95
+ outcome: `acted`,
96
+ digest: verdictFor(withAdvisories, `security-advisories`).digest,
97
+ ...over,
98
+ });
99
+
100
+ test(`a run against this exact evidence leaves the chore due but settled — shown, never badged`, () => {
101
+ const verdict = verdictFor({ ...withAdvisories, ledger: [ledgerEntry()] }, `security-advisories`);
102
+ expect(verdict.state).toBe(`due`);
103
+ expect(verdict.settled).toBe(true);
104
+ expect(unseenVerdicts([verdict], {})).toEqual([]);
105
+ });
106
+
107
+ // The point of digesting evidence rather than stamping a time: a fix landing, or a NEW advisory arriving,
108
+ // both move the evidence and both deserve to be heard again.
109
+ test(`evidence that has moved since the run is unsettled again`, () => {
110
+ const moved = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`, `minimist`])], signals: signals() }], ledger: [ledgerEntry()] });
111
+ const verdict = verdictFor(moved, `security-advisories`);
112
+ expect(verdict.state).toBe(`due`);
113
+ expect(verdict.settled).toBe(false);
114
+ expect(unseenVerdicts([verdict], {})).toHaveLength(1);
115
+ });
116
+
117
+ test(`an agent reporting the findings did not hold up clears the chore until the evidence changes`, () => {
118
+ const verdict = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ outcome: `clean` })] }, `security-advisories`);
119
+ expect(verdict.state).toBe(`clear`);
120
+ expect(verdict.headline).toBe(`Checked — the findings did not hold up`);
121
+ });
122
+
123
+ test(`a snooze silences a due chore without hiding it, and lapses on its own`, () => {
124
+ const snoozed = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ snoozedUntil: NOW + DAY })] }, `security-advisories`);
125
+ expect(snoozed.state).toBe(`snoozed`);
126
+ expect(snoozed.detail).not.toEqual([]);
127
+ expect(unseenVerdicts([snoozed], {})).toEqual([]);
128
+
129
+ const lapsed = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ snoozedUntil: NOW - 1 })] }, `security-advisories`);
130
+ expect(lapsed.state).toBe(`due`);
131
+ });
132
+
133
+ /* A chore with a cadence expires its own settlement, so "we looked and chose not to act" cannot silence it
134
+ * for good. Security has no cadence on purpose — an advisory does not become interesting again because
135
+ * ninety days passed, it becomes interesting when the advisory set changes — so its settlement persists. */
136
+ test(`settlement expires with the chore's cadence, and persists for the chores that have none`, () => {
137
+ const dependencies = choreById(`dependencies-outdated`);
138
+ expect(dependencies?.cadenceMs).toBeGreaterThan(0);
139
+ expect(choreById(`security-advisories`)?.cadenceMs).toBe(0);
140
+
141
+ const old = { ...withAdvisories, ledger: [ledgerEntry({ ranAt: NOW - 400 * DAY })] };
142
+ expect(verdictFor(old, `security-advisories`).settled).toBe(true);
143
+ });
144
+ });
145
+
146
+ describe(`surveys are due because time passed, and say so`, () => {
147
+ const surveyLedger = (ranAt: number): ChoreLedgerEntry => ({
148
+ repo: `app`,
149
+ chore: `standardize-patterns`,
150
+ ranAt,
151
+ runId: `r1`,
152
+ outcome: `reported`,
153
+ digest: `whatever`,
154
+ });
155
+
156
+ test(`never run is due`, () => {
157
+ expect(verdictFor(report(), `standardize-patterns`).state).toBe(`due`);
158
+ });
159
+
160
+ test(`run inside the period is clear, and reports when it was read rather than claiming nothing to do`, () => {
161
+ const verdict = verdictFor({ ...report(), ledger: [surveyLedger(NOW - 10 * DAY)] }, `standardize-patterns`);
162
+ expect(verdict.state).toBe(`clear`);
163
+ expect(verdict.headline).toBe(`Surveyed 10 days ago`);
164
+ });
165
+
166
+ test(`run longer ago than the cadence is due again`, () => {
167
+ expect(verdictFor({ ...report(), ledger: [surveyLedger(NOW - 200 * DAY)] }, `standardize-patterns`).state).toBe(`due`);
168
+ });
169
+ });
170
+
171
+ describe(`the badge speaks about transitions, not about statistics`, () => {
172
+ const withAdvisories = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] });
173
+
174
+ test(`acknowledging a digest silences it, and the next distinct finding still gets through`, () => {
175
+ const first = verdictFor(withAdvisories, `security-advisories`);
176
+ expect(unseenVerdicts([first], {})).toHaveLength(1);
177
+
178
+ const seen = { [ledgerKey(`app`, `security-advisories`)]: first.digest };
179
+ expect(unseenVerdicts([first], seen)).toEqual([]);
180
+
181
+ const next = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`, `tar`])], signals: signals() }] }), `security-advisories`);
182
+ expect(unseenVerdicts([next], seen)).toHaveLength(1);
183
+ });
184
+
185
+ // The rule the whole surface is built to satisfy: a backlog that has been seen once must stop speaking, or
186
+ // the tile is lit every day and the rail stops being read at all.
187
+ test(`a standing backlog of undocumented packages goes quiet once seen, but a new package speaks`, () => {
188
+ const undocumented = (dirs: readonly string[]): ChoresReport =>
189
+ report({
190
+ repos: [{ repo: `app`, probes: [], signals: signals({ packages: dirs.map((dir) => pkg({ dir, name: dir, documented: false })) }) }],
191
+ });
192
+
193
+ const backlog = verdictFor(undocumented([`_libs/a`, `_libs/b`]), `documentation-refresh`);
194
+ expect(backlog.state).toBe(`due`);
195
+ const seen = { [ledgerKey(`app`, `documentation-refresh`)]: backlog.digest };
196
+ expect(unseenVerdicts([backlog], seen)).toEqual([]);
197
+
198
+ const grown = verdictFor(undocumented([`_libs/a`, `_libs/b`, `_libs/new`]), `documentation-refresh`);
199
+ expect(unseenVerdicts([grown], seen)).toHaveLength(1);
200
+ });
201
+ });
202
+
203
+ describe(`the findings themselves`, () => {
204
+ test(`complexity reports only files that are load-bearing or out of proportion, never just the ranking's top`, () => {
205
+ const hotspot = (path: string, complexity: number) => ({ path, commits: 20, adds: 100, dels: 50, complexity, score: complexity * 20, latestMs: NOW });
206
+ // An even ranking has no outlier and no key module in it: a healthy repository, and an empty finding.
207
+ const even = report({
208
+ repos: [{ repo: `app`, probes: [], signals: signals({ hotspots: [hotspot(`a.ts`, 30), hotspot(`b.ts`, 28), hotspot(`c.ts`, 26)] }) }],
209
+ });
210
+ expect(verdictFor(even, `complexity`).state).toBe(`clear`);
211
+
212
+ const outlier = report({
213
+ repos: [{ repo: `app`, probes: [], signals: signals({ hotspots: [hotspot(`a.ts`, 200), hotspot(`b.ts`, 28), hotspot(`c.ts`, 26)] }) }],
214
+ });
215
+ const verdict = verdictFor(outlier, `complexity`);
216
+ expect(verdict.state).toBe(`due`);
217
+ expect(verdict.detail).toHaveLength(1);
218
+ expect(verdict.detail[0]).toContain(`a.ts`);
219
+ });
220
+
221
+ test(`a half-built index says nothing rather than ranking what it has read so far`, () => {
222
+ const partial = report({
223
+ repos: [
224
+ {
225
+ repo: `app`,
226
+ probes: [],
227
+ signals: signals({ indexed: false, hotspots: [{ path: `a.ts`, commits: 9, adds: 1, dels: 1, complexity: 900, score: 8100, latestMs: NOW }] }),
228
+ },
229
+ ],
230
+ });
231
+ expect(verdictFor(partial, `complexity`).state).toBe(`clear`);
232
+ });
233
+
234
+ test(`two libraries for one job is the finding; one is not`, () => {
235
+ const one = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [pkg({ dependencies: [`zod`] })] }) }] });
236
+ expect(verdictFor(one, `library-overlap`).state).toBe(`clear`);
237
+
238
+ const two = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [pkg({ dependencies: [`zod`, `yup`] })] }) }] });
239
+ const verdict = verdictFor(two, `library-overlap`);
240
+ expect(verdict.state).toBe(`due`);
241
+ expect(verdict.detail[0]).toContain(`schema validation`);
242
+ });
243
+
244
+ // The runtime table is static and offline by design; a major it does not know about must read as "not
245
+ // end-of-life", which is the safe direction to be wrong in.
246
+ test(`an unknown node major is not reported as end-of-life`, () => {
247
+ expect(verdictFor({ ...report(), node: `v99.0.0` }, `runtime-eol`).state).toBe(`clear`);
248
+ });
249
+
250
+ test(`a runtime past its end-of-life date is the one ordinary finding that reaches warning`, () => {
251
+ const verdict = verdictFor({ ...report(), node: `v18.20.0` }, `runtime-eol`);
252
+ expect(verdict.state).toBe(`due`);
253
+ expect(verdict.severity).toBe(`warning`);
254
+ });
255
+
256
+ test(`a supported runtime is clear`, () => {
257
+ expect(verdictFor({ ...report(), node: `v24.18.0` }, `runtime-eol`).state).toBe(`clear`);
258
+ });
259
+ });
260
+
261
+ describe(`the prompts`, () => {
262
+ const dueVerdict = () => verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] }), `security-advisories`);
263
+
264
+ /* A prompt that counts without NAMING sends the agent off to re-derive a list we are already holding — slowly,
265
+ * and against a tree that has moved since. Every measured chore names its artefacts. */
266
+ test(`name the artefacts, not just how many there were`, () => {
267
+ const verdict = dueVerdict();
268
+ expect(verdict.prompt).toContain(`left-pad`);
269
+ expect(verdict.prompt).toContain(`app`);
270
+ });
271
+
272
+ test(`tell an acting chore to keep the diff reviewable and a reporting chore not to edit at all`, () => {
273
+ expect(dueVerdict().prompt).toContain(`separately explainable`);
274
+ const survey = verdictFor(report(), `standardize-patterns`);
275
+ expect(survey.prompt).toContain(`Change nothing.`);
276
+ });
277
+
278
+ test(`every chore that can be due can produce a prompt`, () => {
279
+ const verdicts = assessReport(
280
+ report({
281
+ repos: [
282
+ {
283
+ repo: `app`,
284
+ probes: [
285
+ auditProbe([`left-pad`]),
286
+ probe({ id: `outdated`, facts: { id: `outdated`, packages: [{ name: `vue`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }] } }),
287
+ probe({ id: `knip`, facts: { id: `knip`, deadCode: { files: 3, exports: 2, types: 0, dependencies: 1, devDependencies: 0, sample: [`a.ts`] } } }),
288
+ probe({ id: `jscpd`, facts: { id: `jscpd`, duplication: { percentage: 9, clones: 4, top: [{ lines: 20, first: `a.ts`, second: `b.ts` }] } } }),
289
+ ],
290
+ signals: signals({ packages: [pkg({ documented: false, dependencies: [`zod`, `joi`] })] }),
291
+ },
292
+ ],
293
+ node: `v18.20.0`,
294
+ }),
295
+ NOW,
296
+ );
297
+ const due = verdicts.filter((verdict) => verdict.state === `due`);
298
+ expect(due.length).toBeGreaterThanOrEqual(8);
299
+ for (const verdict of due) {
300
+ expect(verdict.prompt, verdict.chore.id).toBeTypeOf(`string`);
301
+ expect(verdict.digest, verdict.chore.id).not.toBe(``);
302
+ }
303
+ // Each measured chore's own artefact reaches its own prompt — the regression this whole test exists for.
304
+ const promptFor = (chore: string) => due.find((verdict) => verdict.chore.id === chore)?.prompt ?? ``;
305
+ expect(promptFor(`security-advisories`)).toContain(`left-pad`);
306
+ expect(promptFor(`dependencies-outdated`)).toContain(`vue 1.0.0 → 2.0.0`);
307
+ expect(promptFor(`dead-code`)).toContain(`a.ts`);
308
+ expect(promptFor(`duplication`)).toContain(`a.ts ↔ b.ts`);
309
+ expect(promptFor(`documentation-refresh`)).toContain(`_libs/thing`);
310
+ expect(promptFor(`library-overlap`)).toContain(`zod`);
311
+ expect(promptFor(`runtime-eol`)).toContain(`v18.20.0`);
312
+ });
313
+ });
314
+
315
+ /* APPLICABILITY — whether the chore is a QUESTION worth asking of this repository, as opposed to whether the
316
+ * answer is yes. Every case here is one where the previous design showed a row that could never be acted on:
317
+ * an offer to re-read documentation that was never written, to slim an image that does not exist, to tighten a
318
+ * pipeline nobody has. Each of those teaches the reader that this list was not written by someone who looked. */
319
+ describe(`what does not apply here`, () => {
320
+ const withShape = (over: Partial<ChoreShape>): ChoresReport =>
321
+ report({ repos: [{ repo: `app`, probes: [], signals: signals({ shape: shape(over) }) }] });
322
+
323
+ test(`a repository with no documents is not asked to re-read its documentation`, () => {
324
+ const verdict = verdictFor(withShape({ docs: [] }), `documentation-drift`);
325
+ expect(verdict.state).toBe(`not-applicable`);
326
+ expect(verdict.headline).toBe(`this repository has no architecture documents to re-read`);
327
+ expect(verdict.prompt).toBeUndefined();
328
+ });
329
+
330
+ test(`a repository with no Dockerfile is not asked to slim its image`, () => {
331
+ expect(verdictFor(withShape({ dockerfiles: [] }), `docker-image`).state).toBe(`not-applicable`);
332
+ });
333
+
334
+ test(`a repository with no pipeline is not asked to tighten one`, () => {
335
+ expect(verdictFor(withShape({ ci: [] }), `ci-hygiene`).state).toBe(`not-applicable`);
336
+ });
337
+
338
+ test(`a repository that is not a Node project is not offered the npm-shaped chores`, () => {
339
+ const foreign = withShape({ packageManifest: false, lockfile: false });
340
+ for (const chore of [`dependencies-outdated`, `runtime-eol`, `dead-code`, `security-advisories`, `deprecated-apis`]) {
341
+ expect(verdictFor(foreign, chore).state, chore).toBe(`not-applicable`);
342
+ }
343
+ });
344
+
345
+ test(`a repository that is not a workspace is not asked about per-package documents or library overlap`, () => {
346
+ const single = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [] }) }] });
347
+ expect(verdictFor(single, `documentation-refresh`).state).toBe(`not-applicable`);
348
+ expect(verdictFor(single, `library-overlap`).state).toBe(`not-applicable`);
349
+ });
350
+
351
+ // A survey has no evidence to be absent — "90 days have passed" is true everywhere — so without a gate it
352
+ // fires forever in repositories where its subject does not exist. This is the regression that motivated
353
+ // making `applies` a required field on SurveySpec rather than an optional one.
354
+ test(`a tiny repository is not surveyed for cross-cutting patterns it cannot have`, () => {
355
+ const tiny = report({ repos: [{ repo: `app`, probes: [], signals: signals({ totals: { files: 4, symbols: 10, complexity: 5, hotspots: 0 } }) }] });
356
+ const verdict = verdictFor(tiny, `standardize-patterns`);
357
+ expect(verdict.state).toBe(`not-applicable`);
358
+ expect(verdict.headline).toContain(`4 indexed files`);
359
+ });
360
+
361
+ test(`applicability is decided before measurement, so a missing probe never masks it`, () => {
362
+ // dead-code needs the knip probe, which has not run; the gate still wins, because "we cannot ask this
363
+ // question here" outranks "we have not measured it".
364
+ expect(verdictFor(withShape({ packageManifest: false }), `dead-code`).state).toBe(`not-applicable`);
365
+ });
366
+
367
+ test(`a chore that does not apply can never reach the rail`, () => {
368
+ const verdicts = assessReport(withShape({ docs: [], dockerfiles: [], ci: [] }), NOW).filter((verdict) => verdict.state === `not-applicable`);
369
+ expect(verdicts.length).toBeGreaterThanOrEqual(3);
370
+ expect(unseenVerdicts(verdicts, {})).toEqual([]);
371
+ });
372
+
373
+ test(`a fully-equipped repository rules nothing out`, () => {
374
+ expect(assessReport(withShape({}), NOW).filter((verdict) => verdict.state === `not-applicable`)).toEqual([]);
375
+ });
376
+ });
377
+
378
+ /* THE CRITERION — the rule in words, next to the evidence that met it. A row that reports a number without the
379
+ * rule behind it is asking to be taken on trust, and the first row that turns out to be wrong costs the whole
380
+ * list its credibility. */
381
+ describe(`every chore says what would make it due`, () => {
382
+ test(`every entry in the book carries a criterion`, () => {
383
+ for (const chore of CHORES) {
384
+ expect(chore.criterion, chore.id).toBeTypeOf(`string`);
385
+ expect(chore.criterion.length, chore.id).toBeGreaterThan(20);
386
+ }
387
+ });
388
+
389
+ test(`the criterion reaches the prompt, so the agent can tell us the rule was wrong`, () => {
390
+ const due = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] }), `security-advisories`);
391
+ expect(due.prompt).toContain(`You were woken because:`);
392
+ expect(due.prompt).toContain(due.chore.criterion);
393
+ });
394
+ });
@@ -0,0 +1,167 @@
1
+ import type { ChoreLedgerEntry, ChoresReport, ProbeId, ProbeResult } from "../schemas.js";
2
+ import { type Chore, type ChoreContext, type ChoreFinding, CHORES, chorePrompt } from "./chores.js";
3
+ import { probeSpec } from "./probes.js";
4
+
5
+ /* FROM EVIDENCE TO A VERDICT — the one place that decides whether a chore is due, and the only place that is
6
+ * allowed to. Both the Maintenance panel and its rail badge run this function over the same report, so the number
7
+ * on the tile and the reason in the panel are the same computation and cannot drift apart.
8
+ *
9
+ * Five states, and the distinctions between them are the whole design:
10
+ *
11
+ * not-applicable this chore is not a QUESTION worth asking of this repository — there is no Dockerfile to
12
+ * slim, no pipeline to tighten, no documentation to re-read. Dropped from the panel entirely
13
+ * rather than shown as clear, because "clear" claims we checked, and there was nothing to check.
14
+ * The reason survives as a footer, so "why is there no Docker chore here?" has an answer.
15
+ * unavailable we have not measured this. knip is not a devDependency; there is no lockfile to audit. Rendered
16
+ * greyed, never badged, and never collapsed into `clear` — a maintenance surface reporting a green
17
+ * repository it has never actually measured is worse than one that says nothing.
18
+ * clear we measured, and there is nothing to do. This is the common state, and it has to be visibly
19
+ * reachable or the panel is just a list of complaints.
20
+ * snoozed the owner said "not now". Still listed, still showing its evidence, silent until it lapses.
21
+ * due there is something to do.
22
+ *
23
+ * The first three are all ways of saying "no", and keeping them apart is what makes the surface trustworthy: they
24
+ * mean we cannot ask, we did not measure, and we measured and found nothing — three different claims, and only
25
+ * the last one is reassurance.
26
+ *
27
+ * And one flag that is not a state: `settled`. A due chore whose evidence is UNCHANGED since a turn was already
28
+ * spent on it stays due — because it is — but must never light the rail again. This is what stops the surface
29
+ * repeating itself while a fix sits in review, and it is why the ledger stores a digest rather than a timestamp:
30
+ * "ran 3 days ago" cannot tell you whether it ran against THIS.
31
+ *
32
+ * Nothing here can hide a problem. Snoozing and settling change whether the rail SPEAKS; the panel still shows
33
+ * the chore, its evidence and its state. The one thing that removes a row entirely is `not-applicable`, and that
34
+ * is not hiding — it is the absence of a subject, recorded in the panel's footer with its reason. A maintenance
35
+ * surface you can quietly bury findings in is a maintenance surface nobody trusts. */
36
+
37
+ export type ChoreState = "due" | "clear" | "snoozed" | "unavailable" | "not-applicable";
38
+
39
+ export interface ChoreVerdict {
40
+ readonly chore: Chore;
41
+ readonly repo: string;
42
+ readonly state: ChoreState;
43
+ readonly severity: ChoreFinding["severity"];
44
+ // Always present, in every state — "nothing to do" and "not measured" are answers a reader deserves in words.
45
+ readonly headline: string;
46
+ readonly detail: readonly string[];
47
+ // The evidence identity. Empty for `unavailable`, where there is no evidence to identify.
48
+ readonly digest: string;
49
+ // The turn. Present only when there is something to do — a "start an agent" button on a clear chore is an
50
+ // invitation to spend money proving that nothing is wrong.
51
+ readonly prompt: string | undefined;
52
+ readonly lastRun: ChoreLedgerEntry | undefined;
53
+ // A turn has already been spent on exactly this evidence, and the chore's cadence has not lapsed since. Still
54
+ // due, still shown, never badged.
55
+ readonly settled: boolean;
56
+ }
57
+
58
+ // A survey that is clear is clear because it was READ recently, and saying so is the only way its row means
59
+ // anything — "nothing to do" under a chore that has no measurement would be a claim about the code rather than
60
+ // about the calendar.
61
+ const clearHeadline = (chore: Chore, lastRun: ChoreLedgerEntry | undefined, nowMs: number): string =>
62
+ chore.survey === true && lastRun !== undefined ? `Surveyed ${Math.round((nowMs - lastRun.ranAt) / 86_400_000)} days ago` : `Nothing to do`;
63
+
64
+ // Why a chore could not be assessed, in the words of the thing that could not do it. Never invented: an
65
+ // `unavailable` probe carries the tool's own reason, and a probe that has simply not run yet says that.
66
+ const unmeasuredDetail = (needs: readonly ProbeId[], probes: ReadonlyMap<ProbeId, ProbeResult>): string[] =>
67
+ needs.flatMap((id) => {
68
+ const probe = probes.get(id);
69
+ const spec = probeSpec(id);
70
+ if (probe === undefined) {
71
+ return [`${spec.title} · not measured yet`];
72
+ }
73
+ if (probe.state === `ok`) {
74
+ return [];
75
+ }
76
+ return [`${spec.title} · ${probe.state === `unavailable` ? `not available in this repository` : `failed`}${probe.reason === undefined ? `` : ` — ${probe.reason}`}`];
77
+ });
78
+
79
+ export const assessChore = (chore: Chore, context: ChoreContext, ledger: ChoreLedgerEntry | undefined): ChoreVerdict => {
80
+ const base = { chore, repo: context.repo, lastRun: ledger, settled: false, prompt: undefined } as const;
81
+
82
+ /* APPLICABILITY FIRST, before anything is measured or any evidence is read. A chore that does not apply is
83
+ * not "clear" and not "unmeasured" — the question does not arise here, and every subsequent branch of this
84
+ * function would be answering it anyway. The reason is carried as the headline, because the panel's footer
85
+ * is the only place it will ever be read. */
86
+ const inapplicable = chore.applies?.(context.signals);
87
+ if (inapplicable !== undefined) {
88
+ return { ...base, state: `not-applicable`, severity: `info`, headline: inapplicable, detail: [], digest: `` };
89
+ }
90
+
91
+ const unmeasured = unmeasuredDetail(chore.needs, context.probes);
92
+ if (unmeasured.length > 0) {
93
+ return { ...base, state: `unavailable`, severity: `info`, headline: `Not measured`, detail: unmeasured, digest: `` };
94
+ }
95
+
96
+ const finding = chore.assess(context);
97
+ if (finding === undefined) {
98
+ return { ...base, state: `clear`, severity: `info`, headline: clearHeadline(chore, ledger, context.nowMs), detail: [], digest: `` };
99
+ }
100
+
101
+ /* Has the last run's settlement lapsed? A cadence of 0 means "this is decided by evidence alone" — an advisory
102
+ * does not become worth looking at again because ninety days passed, it becomes worth looking at again when
103
+ * the advisory set changes. Anything with a cadence expires its own settlement, so "we looked and chose not to
104
+ * act" cannot silence a chore for good. */
105
+ const lapsed = ledger !== undefined && chore.cadenceMs > 0 && context.nowMs - ledger.ranAt >= chore.cadenceMs;
106
+ const sameEvidence = ledger?.digest === finding.digest && !lapsed;
107
+
108
+ /* A SURVEY has no measurement, so the calendar is the whole trigger: it is due because it has been that long,
109
+ * and a run inside the current period settles it until the next one begins. Checked against the run's TIME
110
+ * rather than its digest, because a survey run three days into a quarter and one three days before its end
111
+ * are the same period but very different answers to "when was this last read?". */
112
+ if (chore.survey === true && ledger !== undefined && context.nowMs - ledger.ranAt < chore.cadenceMs) {
113
+ return { ...base, state: `clear`, severity: `info`, headline: clearHeadline(chore, ledger, context.nowMs), detail: finding.detail, digest: finding.digest };
114
+ }
115
+
116
+ const prompt = chorePrompt(chore, finding, context.repo);
117
+
118
+ if (ledger?.snoozedUntil !== undefined && ledger.snoozedUntil > context.nowMs) {
119
+ return { ...base, state: `snoozed`, severity: `info`, headline: finding.headline, detail: finding.detail, digest: finding.digest, prompt };
120
+ }
121
+
122
+ /* The agent looked at exactly this evidence and reported that there was nothing in it — knip's findings were
123
+ * all public entry points, the clones were all generated files. That verdict has to stick, or the next poll
124
+ * starts the same turn again and the surface has taught the owner that its rows are wrong. It stops sticking
125
+ * when the evidence changes (a different digest) or the cadence lapses. */
126
+ if (sameEvidence && ledger?.outcome === `clean`) {
127
+ return {
128
+ ...base,
129
+ state: `clear`,
130
+ severity: `info`,
131
+ headline: `Checked — the findings did not hold up`,
132
+ detail: finding.detail,
133
+ digest: finding.digest,
134
+ };
135
+ }
136
+
137
+ return { ...base, state: `due`, severity: finding.severity, headline: finding.headline, detail: finding.detail, digest: finding.digest, prompt, settled: sameEvidence };
138
+ };
139
+
140
+ // The ledger is keyed by repo + chore, which is the grain a verdict is decided at: the same chore in two repos is
141
+ // two independent questions with two independent answers.
142
+ export const ledgerKey = (repo: string, chore: string): string => `${repo}|${chore}`;
143
+
144
+ /* Every chore in every repo, from one report. This is what both surfaces call — the panel groups the result, the
145
+ * badge filters it — so there is exactly one traversal of the book in the codebase and adding a chore to CHORES
146
+ * reaches both surfaces without touching either. */
147
+ export const assessReport = (report: ChoresReport, nowMs: number): ChoreVerdict[] => {
148
+ const ledger = new Map(report.ledger.map((entry) => [ledgerKey(entry.repo, entry.chore), entry]));
149
+ return report.repos.flatMap(({ repo, probes, signals }) => {
150
+ const context: ChoreContext = { repo, probes: new Map(probes.map((probe) => [probe.id, probe])), signals, node: report.node, nowMs };
151
+ return CHORES.map((chore) => assessChore(chore, context, ledger.get(ledgerKey(repo, chore.id))));
152
+ });
153
+ };
154
+
155
+ /* WHAT THE RAIL IS ALLOWED TO SAY. A badge must mean "something happened here that you don't already know about",
156
+ * never "here is a statistic" — the extension API states that bar and this is the function that holds this
157
+ * surface to it. Three filters, and every one of them removes a case that would otherwise light the tile forever:
158
+ *
159
+ * state === due the obvious one.
160
+ * !settled a turn has already been spent on this exact evidence.
161
+ * unseen digest the owner has already LOOKED at this evidence in the panel. Acknowledgement is per digest
162
+ * rather than per chore, so acknowledging today's finding does not also swallow tomorrow's.
163
+ *
164
+ * `seen` maps ledgerKey → the digest last acknowledged. It lives in a file beside the ledger, because the badge is
165
+ * derived from files and its acknowledgement belongs in the same tree. */
166
+ export const unseenVerdicts = (verdicts: readonly ChoreVerdict[], seen: Readonly<Record<string, string>>): ChoreVerdict[] =>
167
+ verdicts.filter((verdict) => verdict.state === `due` && !verdict.settled && seen[ledgerKey(verdict.repo, verdict.chore.id)] !== verdict.digest);
@@ -0,0 +1,23 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { ChoreLedgerWriteSchema, ChoreProbeRequestSchema, ChoresReportSchema, OkSchema } from "../schemas.js";
3
+
4
+ /* Maintenance evidence: what every repo under /work currently measures, and what has already been done about it.
5
+ * Three routes, because there are exactly three things the surface does — read the evidence, ask for a
6
+ * measurement to be retaken, and record what a turn concluded.
7
+ *
8
+ * There is no `GET /chores/{id}` and no "run this chore" route on purpose. A chore RUN is an ordinary isolated
9
+ * fleet agent (`POST /agent` with a derived conversation id), the same as an acceptance run or a documentation
10
+ * generation — so the worktree, the live status, the cost, the transcript and the /agents/<id> page already
11
+ * exist, and adding a bespoke launcher here would be a second way to start a turn that has to be kept in step
12
+ * with the first. */
13
+ export const choresContract = {
14
+ // Every repo's standing evidence in one read: cached probe results (with their age and state), the cheap
15
+ // resident signals, the ledger, and the daemon's node version. The rail badge polls this; so does the panel.
16
+ list: oc.route({ method: "GET", path: "/chores" }).output(ChoresReportSchema),
17
+ // Re-run one repo's probe now, ignoring its TTL — the panel's per-probe refresh. An ack: the runner works in
18
+ // the background and the result arrives on the next `list`, because a jscpd sweep outlives any sane request.
19
+ probe: oc.route({ method: "POST", path: "/chores/probe" }).input(ChoreProbeRequestSchema).output(OkSchema),
20
+ // Record what a chore turn concluded, or snooze one. Upsert by repo+chore: a chore has one current verdict,
21
+ // and a growing history of "we looked at this and it was fine" is not something any reader wants paged.
22
+ record: oc.route({ method: "POST", path: "/chores/ledger" }).input(ChoreLedgerWriteSchema).output(OkSchema),
23
+ };
package/src/events.ts CHANGED
@@ -192,8 +192,21 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
192
192
  * bind-mounted over the workspace root and the harness is rewriting tool paths into it instead. That
193
193
  * fallback covers what arrives as tool input and not what a subprocess computes for itself, so the
194
194
  * operator needs to know — this state used to be one line in the daemon log at boot, and the way it got
195
- * noticed was files appearing in the main tree from agents that were supposed to be on branches. */
196
- z.object({ kind: z.literal("worktree"), branch: z.string(), base: z.string(), unenforced: z.boolean().optional() }),
195
+ * noticed was files appearing in the main tree from agents that were supposed to be on branches.
196
+ *
197
+ * `sync` reports the pre-turn rebase (agents/sync.ts), and rides here because this frame is already the
198
+ * turn's "where you are standing" announcement. Present only when the branch was BEHIND the main line —
199
+ * `commits` is how many main-line commits it gained, `blocked` names the repos whose rebase would not apply
200
+ * and was rolled back. Both can be non-empty at once in a multi-repo composition. It is a notice and never
201
+ * a question: the user is answering their agent, and the alternative to rebasing is not "stay safe" but
202
+ * "conflict at land time", which interrupts them harder. */
203
+ z.object({
204
+ kind: z.literal("worktree"),
205
+ branch: z.string(),
206
+ base: z.string(),
207
+ unenforced: z.boolean().optional(),
208
+ sync: z.object({ commits: z.number(), blocked: z.array(z.string()) }).optional(),
209
+ }),
197
210
  // Emitted after a clean isolated turn whose delta auto-landed (or failed to): landed ⇒ the work is now
198
211
  // UNCOMMITTED changes in the main tree (the Changes panel is the review); conflicts ⇒ it stayed safely in
199
212
  // the worktree, and each named path carries WHY it would not apply (see LandConflictSchema) so the report
package/src/hostnames.ts CHANGED
@@ -43,18 +43,16 @@ export const CATCH_ALL = { service: "http_status:404" } as const;
43
43
  // covers exactly one level), where <panel> is `<repo>` or `<repo>--<app>` and <sandboxId> pins the hostname to
44
44
  // this sandbox (the shared intentic zone hosts many sandboxes; without the id two users' panels would collide).
45
45
  // Port-forward scheme: `port-<slot>-<sandboxId>.<zone>` — the same shape with a `port-` prefix, where <slot>
46
- // is one of the sandbox's fixed forward slots (see PORT_SLOTS), not the port number itself: slots keep the
47
- // intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral ports.
46
+ // is one of the sandbox's forward slots (portSlotsFromToken in ./tunnel-ids), not the port number itself:
47
+ // slots keep the intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral
48
+ // ports. The slot labels are salted with the connect token rather than being the letters a…h, so a forwarded
49
+ // port's hostname is not derivable from the (public) sandbox id alone — see tunnel-ids for why that matters.
48
50
  //
49
51
  // A *label* is the first-DNS-label prefix before `-<sandboxId>` (`preview-<panel>` / `port-<slot>`) — the unit
50
52
  // the platform's /sandbox/preview-route mints, so one endpoint serves both schemes.
51
53
  export const previewLabel = (panel: string): string => `preview-${panel}`;
52
54
  export const portLabel = (slot: string): string => `port-${slot}`;
53
55
 
54
- // The fixed per-sandbox forward slots. Eight is deliberate: enough for a monorepo's worth of concurrent dev
55
- // servers, and the hard cap on preview DNS records a sandbox can ever cost the shared intentic zone.
56
- export const PORT_SLOTS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
57
-
58
56
  // The hostname a label resolves to — what the platform's /sandbox/preview-route mints from the label alone.
59
57
  export const labelHostname = (label: string, id: string, zone: string): string => `${label}-${id}.${zone}`;
60
58
  export const previewHostname = (panel: string, id: string, zone: string): string => labelHostname(previewLabel(panel), id, zone);