@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.
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,30 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { bucketOf, digestOf } from "./digest.js";
3
+
4
+ describe(`digestOf`, () => {
5
+ test(`is stable for the same parts and different for different ones`, () => {
6
+ expect(digestOf(`a`, `b`)).toBe(digestOf(`a`, `b`));
7
+ expect(digestOf(`a`, `b`)).not.toBe(digestOf(`b`, `a`));
8
+ expect(digestOf(`a`)).not.toBe(digestOf(`aa`));
9
+ });
10
+
11
+ test(`is short enough to sit in a JSON ledger without anyone minding`, () => {
12
+ expect(digestOf(`x`.repeat(10_000)).length).toBeLessThanOrEqual(8);
13
+ });
14
+ });
15
+
16
+ /* The anti-drift mechanism. A chore that counts things must not mint a new digest — and therefore a new badge —
17
+ * every time an ordinary day's work moves the number by one. Buckets widen with the count, because the difference
18
+ * between one and two matters and the difference between four hundred and five hundred does not. */
19
+ describe(`bucketOf`, () => {
20
+ test(`absorbs drift and keeps the moves that mean something`, () => {
21
+ expect(bucketOf(12)).toBe(bucketOf(13));
22
+ expect(bucketOf(12)).not.toBe(bucketOf(40));
23
+ expect(bucketOf(1)).not.toBe(bucketOf(2));
24
+ expect(bucketOf(400)).toBe(bucketOf(500));
25
+ });
26
+
27
+ test(`nothing is its own bucket, so a chore's first finding of a kind is always news`, () => {
28
+ expect(bucketOf(0)).not.toBe(bucketOf(1));
29
+ });
30
+ });
Binary file
@@ -0,0 +1,9 @@
1
+ export { CHORES, choreAutomationPrompt, choreById, chorePrompt, repoLabel } from "./chores.js";
2
+ export type { Chore, ChoreContext, ChoreFinding, ChoreStance } from "./chores.js";
3
+ export { bucketOf, digestOf } from "./digest.js";
4
+ export { CHORE_INVARIANTS, composeAsk, REFACTOR_INVARIANTS, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
5
+ export type { Ask } from "./prompt.js";
6
+ export { PROBES, probeSpec } from "./probes.js";
7
+ export type { ProbeSpec } from "./probes.js";
8
+ export { assessChore, assessReport, ledgerKey, unseenVerdicts } from "./verdict.js";
9
+ export type { ChoreState, ChoreVerdict } from "./verdict.js";
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { probeSpec } from "./probes.js";
3
+
4
+ /* The parsers are the part of this library that faces someone else's output, so they are tested the way that
5
+ * output actually arrives: real shapes, then the shapes that have historically broken things — a tool that
6
+ * printed a warning line before its JSON, a version whose fields moved, an empty run. The bar for every one of
7
+ * them is the same: recognise it, or return undefined so the runner can record a failure. Never throw, and never
8
+ * report a clean result from output it did not understand. */
9
+
10
+ const parse = (id: Parameters<typeof probeSpec>[0], stdout: string) => probeSpec(id).parse(stdout);
11
+
12
+ describe(`outdated`, () => {
13
+ test(`reads pnpm's name → {current, latest} map and classifies the semver step`, () => {
14
+ const facts = parse(
15
+ `outdated`,
16
+ JSON.stringify({
17
+ vue: { current: `3.4.1`, latest: `4.0.0`, dependencyType: `dependencies` },
18
+ vitest: { current: `2.1.0`, latest: `2.3.4`, dependencyType: `devDependencies` },
19
+ zod: { current: `4.4.3`, latest: `4.4.9`, dependencyType: `dependencies` },
20
+ }),
21
+ );
22
+ expect(facts).toEqual({
23
+ id: `outdated`,
24
+ packages: [
25
+ { name: `vue`, current: `3.4.1`, latest: `4.0.0`, kind: `major`, section: `dependencies` },
26
+ { name: `vitest`, current: `2.1.0`, latest: `2.3.4`, kind: `minor`, section: `devDependencies` },
27
+ { name: `zod`, current: `4.4.3`, latest: `4.4.9`, kind: `patch`, section: `dependencies` },
28
+ ],
29
+ });
30
+ });
31
+
32
+ test(`skips entries pnpm could not resolve, rather than inventing a version for them`, () => {
33
+ const facts = parse(`outdated`, JSON.stringify({ ok: { current: `1.0.0`, latest: `2.0.0` }, broken: { current: `1.0.0` }, alsoBroken: null }));
34
+ expect(facts).toEqual({ id: `outdated`, packages: [{ name: `ok`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }] });
35
+ });
36
+
37
+ // pnpm prints deprecation and lockfile notices on the same stream in some versions; the JSON still has to be
38
+ // found. This is the single most common reason a parser like this silently reports "clean".
39
+ test(`finds the JSON after a leading warning line`, () => {
40
+ expect(parse(`outdated`, ` WARN Ignoring broken lockfile\n{"vue":{"current":"1.0.0","latest":"2.0.0"}}`)).toEqual({
41
+ id: `outdated`,
42
+ packages: [{ name: `vue`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }],
43
+ });
44
+ });
45
+
46
+ test(`an empty report is no packages, not a failure`, () => {
47
+ expect(parse(`outdated`, `{}`)).toEqual({ id: `outdated`, packages: [] });
48
+ });
49
+
50
+ test(`output that is not JSON at all is a failure, never a clean result`, () => {
51
+ expect(parse(`outdated`, `ERR_PNPM_NO_LOCKFILE Cannot proceed`)).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe(`audit`, () => {
56
+ const advisory = (over: Record<string, unknown>) => ({
57
+ module_name: `left-pad`,
58
+ severity: `high`,
59
+ title: `Prototype pollution`,
60
+ patched_versions: `>=1.3.0`,
61
+ findings: [{ dev: false }],
62
+ ...over,
63
+ });
64
+
65
+ test(`carries which package, how bad, and whether a fix exists`, () => {
66
+ expect(parse(`audit`, JSON.stringify({ advisories: { "1": advisory({}) } }))).toEqual({
67
+ id: `audit`,
68
+ advisories: [{ name: `left-pad`, severity: `high`, title: `Prototype pollution`, patched: `>=1.3.0`, dev: false }],
69
+ });
70
+ });
71
+
72
+ // "<0.0.0" is npm's spelling of "no version fixes this". Treating it as a range would have the chore promise
73
+ // a bump that cannot be made, which is the one thing a security prompt must not do.
74
+ test(`treats "<0.0.0" as no patch published`, () => {
75
+ const facts = parse(`audit`, JSON.stringify({ advisories: { "1": advisory({ patched_versions: `<0.0.0` }) } }));
76
+ expect(facts).toEqual({ id: `audit`, advisories: [expect.not.objectContaining({ patched: expect.anything() })] });
77
+ });
78
+
79
+ test(`an advisory is dev-only only when every finding is`, () => {
80
+ const mixed = parse(`audit`, JSON.stringify({ advisories: { "1": advisory({ findings: [{ dev: true }, { dev: false }] }) } }));
81
+ const devOnly = parse(`audit`, JSON.stringify({ advisories: { "1": advisory({ findings: [{ dev: true }, { dev: true }] }) } }));
82
+ expect(mixed).toMatchObject({ advisories: [{ dev: false }] });
83
+ expect(devOnly).toMatchObject({ advisories: [{ dev: true }] });
84
+ });
85
+
86
+ test(`a report with no advisories key is clean, not unparseable`, () => {
87
+ expect(parse(`audit`, JSON.stringify({ metadata: { vulnerabilities: { high: 0 } } }))).toEqual({ id: `audit`, advisories: [] });
88
+ });
89
+ });
90
+
91
+ describe(`knip`, () => {
92
+ test(`sums the per-file issue arrays and samples the wholly unreferenced files`, () => {
93
+ const facts = parse(
94
+ `knip`,
95
+ JSON.stringify({
96
+ issues: [
97
+ { file: `src/a.ts`, exports: [{ name: `x` }, { name: `y` }], types: [{ name: `T` }], dependencies: [{ name: `lodash` }], devDependencies: [], files: [] },
98
+ { file: `src/b.ts`, exports: [{ name: `z` }], types: [], dependencies: [], devDependencies: [{ name: `jest` }], files: [] },
99
+ { file: `src/old.ts`, exports: [], files: [{ name: `src/old.ts` }] },
100
+ { file: `src/older.ts`, exports: [], files: [{ name: `src/older.ts` }] },
101
+ ],
102
+ }),
103
+ );
104
+ expect(facts).toEqual({
105
+ id: `knip`,
106
+ deadCode: { files: 2, exports: 3, types: 1, dependencies: 1, devDependencies: 1, sample: [`src/old.ts`, `src/older.ts`] },
107
+ });
108
+ });
109
+
110
+ test(`missing per-kind arrays count as zero rather than throwing`, () => {
111
+ expect(parse(`knip`, JSON.stringify({ issues: [{ file: `src/a.ts` }, null] }))).toMatchObject({
112
+ deadCode: { files: 0, exports: 0, types: 0, dependencies: 0, devDependencies: 0, sample: [] },
113
+ });
114
+ });
115
+
116
+ // A clean run still prints the envelope, and an empty one is the answer that keeps the chore quiet.
117
+ test(`an empty issue list is a clean repository, not an unrecognisable one`, () => {
118
+ expect(parse(`knip`, JSON.stringify({ issues: [] }))).toEqual({
119
+ id: `knip`,
120
+ deadCode: { files: 0, exports: 0, types: 0, dependencies: 0, devDependencies: 0, sample: [] },
121
+ });
122
+ });
123
+
124
+ // Without an `issues` array this is not knip's report, whatever else it contains — and reporting zero dead code
125
+ // from a shape we do not recognise is exactly the lie the state machine exists to prevent.
126
+ test(`a shape without an issues array is a failure`, () => {
127
+ expect(parse(`knip`, JSON.stringify({ files: [`src/old.ts`] }))).toBeUndefined();
128
+ });
129
+ });
130
+
131
+ describe(`jscpd`, () => {
132
+ test(`takes the percentage of scanned lines and the biggest clones, longest first`, () => {
133
+ const facts = parse(
134
+ `jscpd`,
135
+ JSON.stringify({
136
+ statistics: { total: { percentage: 7.4, lines: 1000 } },
137
+ duplicates: [
138
+ { lines: 12, firstFile: { name: `a.ts` }, secondFile: { name: `b.ts` } },
139
+ { lines: 40, firstFile: { name: `c.ts` }, secondFile: { name: `d.ts` } },
140
+ ],
141
+ }),
142
+ );
143
+ expect(facts).toEqual({
144
+ id: `jscpd`,
145
+ duplication: {
146
+ percentage: 7.4,
147
+ clones: 2,
148
+ top: [
149
+ { lines: 40, first: `c.ts`, second: `d.ts` },
150
+ { lines: 12, first: `a.ts`, second: `b.ts` },
151
+ ],
152
+ },
153
+ });
154
+ });
155
+
156
+ test(`a clean run reports zero percent with no clones`, () => {
157
+ expect(parse(`jscpd`, JSON.stringify({ statistics: { total: { percentage: 0 } }, duplicates: [] }))).toMatchObject({
158
+ duplication: { percentage: 0, clones: 0, top: [] },
159
+ });
160
+ });
161
+
162
+ // jscpd writes its report to a file; when the run dies the `cat` in the command prints nothing at all.
163
+ test(`no output at all is a failure`, () => {
164
+ expect(parse(`jscpd`, ``)).toBeUndefined();
165
+ });
166
+ });
@@ -0,0 +1,273 @@
1
+ import type { Advisory, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId } from "../schemas.js";
2
+
3
+ /* THE PROBES — the measurements that cost a subprocess, declared once so the daemon that runs them and the panel
4
+ * that explains them cannot disagree about what "outdated" meant.
5
+ *
6
+ * A spec is a shell command and a parser, deliberately in that order of trust: the command is whatever the tool's
7
+ * own maintainers publish as its machine-readable output, and the parser is written to be DISAPPOINTED. Every one
8
+ * of these tools has changed its JSON shape at least once, they are run against whatever version the repo pinned,
9
+ * and a probe that throws on an unexpected field would take the whole maintenance surface down with it. So each
10
+ * parser walks the structure defensively and returns `undefined` when it cannot recognise what it got — which the
11
+ * runner records as a failed probe with the output attached, rather than as a clean repository.
12
+ *
13
+ * TIERS ARE ABOUT COST, and the cost is what sets the cadence. Tier 1 reads metadata that already exists (a
14
+ * lockfile, a registry's version list) and finishes in seconds, so the background runner refreshes it daily. Tier
15
+ * 2 reads the whole tree — knip type-checks it, jscpd tokenizes every file — and can run for minutes on a large
16
+ * repo, so it refreshes weekly and says how long it took, because a reader deciding whether to force a refresh
17
+ * deserves to know what they are asking for.
18
+ *
19
+ * THE `available` COMMAND IS NOT AN AFTERTHOUGHT. "knip is not a devDependency of this repo" and "knip found no
20
+ * dead code" are opposite facts that a bare exit code cannot tell apart, and collapsing them is how a maintenance
21
+ * panel ends up reporting a green repository it has never measured. Non-zero here means unmeasured, and an
22
+ * unmeasured chore renders greyed and can never light the rail. */
23
+
24
+ const HOUR_MS = 3_600_000;
25
+ const DAY_MS = 24 * HOUR_MS;
26
+
27
+ export interface ProbeSpec {
28
+ readonly id: ProbeId;
29
+ readonly title: string;
30
+ // What the reader is told this measures, in the panel, next to its age.
31
+ readonly measures: string;
32
+ readonly tier: 1 | 2;
33
+ // How long a result stays fresh. The background runner refreshes anything older; the panel's per-probe
34
+ // refresh button ignores it.
35
+ readonly ttlMs: number;
36
+ readonly timeoutMs: number;
37
+ // Exit 0 ⇒ this repo can be measured. Runs in the repo's own directory, like the command.
38
+ readonly available: string;
39
+ // `sh -c`, in the repo's directory. Stdout is the parser's input; a non-zero exit is NOT a failure by itself
40
+ // (pnpm outdated and pnpm audit both exit non-zero precisely when they have something to report), so the
41
+ // runner judges by whether the parser recognised the output.
42
+ readonly command: string;
43
+ readonly parse: (stdout: string) => ProbeFacts | undefined;
44
+ }
45
+
46
+ // A parsed JSON object, or undefined for anything else. Every parser starts here, so "the tool printed a warning
47
+ // before its JSON" and "the tool printed nothing" both land on the same honest answer instead of throwing.
48
+ const asObject = (text: string): Record<string, unknown> | undefined => {
49
+ const start = text.indexOf(`{`);
50
+ if (start === -1) {
51
+ return undefined;
52
+ }
53
+ try {
54
+ const parsed: unknown = JSON.parse(text.slice(start));
55
+ return typeof parsed === `object` && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ };
60
+
61
+ const asString = (value: unknown): string | undefined => (typeof value === `string` && value !== `` ? value : undefined);
62
+ const countOf = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
63
+
64
+ // Which semver step separates two versions. Compared as leading integers rather than by a semver library: the
65
+ // only question is which position first differs, and prerelease/build metadata cannot change that answer.
66
+ const versionParts = (version: string): number[] => version.replace(/^[^\d]*/, ``).split(`.`).map((part) => Number.parseInt(part, 10) || 0);
67
+
68
+ const semverKind = (current: string, latest: string): OutdatedPackage["kind"] => {
69
+ const [currentMajor = 0, currentMinor = 0] = versionParts(current);
70
+ const [latestMajor = 0, latestMinor = 0] = versionParts(latest);
71
+ if (latestMajor !== currentMajor) {
72
+ return `major`;
73
+ }
74
+ return latestMinor !== currentMinor ? `minor` : `patch`;
75
+ };
76
+
77
+ /* `pnpm outdated --json` prints a map of package name → { current, latest, dependencyType }. In a workspace the
78
+ * recursive form merges every package's entries into the same map and adds `dependentPackages`, which is why this
79
+ * reads the map rather than expecting a list: one shape covers both, and a field we don't use appearing is not a
80
+ * parse failure. Entries missing `current` or `latest` are skipped — that is how pnpm reports a package it could
81
+ * not resolve against the registry, and it is not evidence of anything. */
82
+ const parseOutdated = (stdout: string): ProbeFacts | undefined => {
83
+ const root = asObject(stdout);
84
+ if (root === undefined) {
85
+ return undefined;
86
+ }
87
+ const packages: OutdatedPackage[] = [];
88
+ for (const [name, raw] of Object.entries(root)) {
89
+ if (typeof raw !== `object` || raw === null) {
90
+ continue;
91
+ }
92
+ const entry = raw as Record<string, unknown>;
93
+ const current = asString(entry[`current`]);
94
+ const latest = asString(entry[`latest`]);
95
+ if (current === undefined || latest === undefined || current === latest) {
96
+ continue;
97
+ }
98
+ packages.push({ name, current, latest, kind: semverKind(current, latest), section: asString(entry[`dependencyType`]) ?? `dependencies` });
99
+ }
100
+ return { id: `outdated`, packages };
101
+ };
102
+
103
+ const SEVERITIES = new Set([`critical`, `high`, `moderate`, `low`, `info`]);
104
+
105
+ /* `pnpm audit --json` prints `{ advisories: { <id>: {...} }, metadata: {...} }`. The metadata's counts are
106
+ * deliberately ignored: they are a tally, and this surface needs the advisories themselves — which package, and
107
+ * whether a patched range exists — because "there is a fix that is a version bump" and "there is no patch yet"
108
+ * lead to completely different turns, and a count cannot tell them apart.
109
+ *
110
+ * `dev` comes off the findings' own flag rather than being inferred. A build-time-only advisory is real but it is
111
+ * not the same risk as one in a running service, and the chore's prompt says so instead of treating them alike. */
112
+ const parseAudit = (stdout: string): ProbeFacts | undefined => {
113
+ const root = asObject(stdout);
114
+ if (root === undefined) {
115
+ return undefined;
116
+ }
117
+ const raw = root[`advisories`];
118
+ // No `advisories` key at all is pnpm's clean report — an empty list, not an unrecognisable one.
119
+ if (raw === undefined) {
120
+ return { id: `audit`, advisories: [] };
121
+ }
122
+ if (typeof raw !== `object` || raw === null) {
123
+ return undefined;
124
+ }
125
+ const advisories: Advisory[] = [];
126
+ for (const value of Object.values(raw as Record<string, unknown>)) {
127
+ if (typeof value !== `object` || value === null) {
128
+ continue;
129
+ }
130
+ const entry = value as Record<string, unknown>;
131
+ const name = asString(entry[`module_name`]);
132
+ const severity = asString(entry[`severity`]);
133
+ if (name === undefined || severity === undefined || !SEVERITIES.has(severity)) {
134
+ continue;
135
+ }
136
+ const patched = asString(entry[`patched_versions`]);
137
+ const findings = Array.isArray(entry[`findings`]) ? (entry[`findings`] as Record<string, unknown>[]) : [];
138
+ advisories.push({
139
+ name,
140
+ severity: severity as Advisory["severity"],
141
+ title: asString(entry[`title`]) ?? name,
142
+ // "<0.0.0" is npm's spelling of "no patch exists", and treating it as a fixing range would have the
143
+ // chore promise a bump that cannot be made.
144
+ ...(patched === undefined || patched === `<0.0.0` ? {} : { patched }),
145
+ dev: findings.length > 0 && findings.every((finding) => finding[`dev`] === true),
146
+ });
147
+ }
148
+ return { id: `audit`, advisories };
149
+ };
150
+
151
+ /* knip's JSON reporter prints `{ issues: [...] }` — one row per file that has findings, carrying a per-kind array
152
+ * of what it found there. A wholly unreferenced file is a row whose own `files` array names it, which is why that
153
+ * count is a sum like every other kind rather than a list of its own. Counts plus a sample of the file paths, not
154
+ * the full list: the agent re-runs knip itself against the live tree (a list from a probe hours old would send it
155
+ * at files that are already gone), so what travels here only has to be enough to decide whether the turn is worth
156
+ * starting. */
157
+ const DEAD_CODE_SAMPLE = 8;
158
+
159
+ const parseKnip = (stdout: string): ProbeFacts | undefined => {
160
+ const raw = asObject(stdout)?.[`issues`];
161
+ // `issues` is the whole report in this reporter, so its absence means we are not reading knip's output at all.
162
+ if (!Array.isArray(raw)) {
163
+ return undefined;
164
+ }
165
+ const issues = raw.filter((issue): issue is Record<string, unknown> => typeof issue === `object` && issue !== null);
166
+ const sum = (key: string): number => issues.reduce((total, issue) => total + countOf(issue[key]), 0);
167
+ const deadCode: DeadCode = {
168
+ files: sum(`files`),
169
+ exports: sum(`exports`),
170
+ types: sum(`types`),
171
+ dependencies: sum(`dependencies`),
172
+ devDependencies: sum(`devDependencies`),
173
+ sample: issues.flatMap((issue) => (countOf(issue[`files`]) === 0 ? [] : (asString(issue[`file`]) ?? []))).slice(0, DEAD_CODE_SAMPLE),
174
+ };
175
+ return { id: `knip`, deadCode };
176
+ };
177
+
178
+ /* jscpd writes its JSON to a file rather than stdout, which is why the command below ends in a `cat`. The report
179
+ * carries `statistics[`total`].percentage` (of scanned lines) and a `duplicates` array; the percentage is what a
180
+ * threshold is worth setting against, because a clone COUNT grows with the repository and would mean something
181
+ * different every quarter. */
182
+ const DUPLICATION_SAMPLE = 5;
183
+
184
+ const parseJscpd = (stdout: string): ProbeFacts | undefined => {
185
+ const root = asObject(stdout);
186
+ const statistics = root?.[`statistics`];
187
+ if (typeof statistics !== `object` || statistics === null) {
188
+ return undefined;
189
+ }
190
+ const total = (statistics as Record<string, unknown>)[`total`];
191
+ const percentage = typeof total === `object` && total !== null ? (total as Record<string, unknown>)[`percentage`] : undefined;
192
+ const duplicates = Array.isArray(root?.[`duplicates`]) ? (root[`duplicates`] as Record<string, unknown>[]) : [];
193
+ const pathOf = (side: unknown): string => (typeof side === `object` && side !== null ? (asString((side as Record<string, unknown>)[`name`]) ?? `?`) : `?`);
194
+ const duplication: Duplication = {
195
+ percentage: typeof percentage === `number` ? percentage : 0,
196
+ clones: duplicates.length,
197
+ top: duplicates
198
+ .map((clone) => ({ lines: typeof clone[`lines`] === `number` ? clone[`lines`] : 0, first: pathOf(clone[`firstFile`]), second: pathOf(clone[`secondFile`]) }))
199
+ .toSorted((left, right) => right.lines - left.lines)
200
+ .slice(0, DUPLICATION_SAMPLE),
201
+ };
202
+ return { id: `jscpd`, duplication };
203
+ };
204
+
205
+ // Where the tier-2 tools leave their reports. Under /tmp because they are inputs to a parse that happens
206
+ // immediately after, never something to keep — the cached ProbeResult is the artefact that survives. The same
207
+ // path the scheduled form of this chore uses (chores.ts), so a workspace running both keeps one copy.
208
+ const JSCPD_DIR = `/tmp/intentic-chore-jscpd`;
209
+
210
+ export const PROBES: readonly ProbeSpec[] = [
211
+ {
212
+ id: `outdated`,
213
+ title: `Dependency versions`,
214
+ measures: `how far behind the registry each dependency is`,
215
+ tier: 1,
216
+ ttlMs: DAY_MS,
217
+ timeoutMs: 5 * 60_000,
218
+ available: `test -f package.json`,
219
+ // `-r` so a monorepo reports every workspace package, not just the root's own handful. `|| true` because
220
+ // pnpm exits non-zero exactly when it HAS findings, and the runner judges by whether the parse succeeded.
221
+ command: `pnpm outdated -r --json 2>/dev/null || true`,
222
+ parse: parseOutdated,
223
+ },
224
+ {
225
+ id: `audit`,
226
+ title: `Security advisories`,
227
+ measures: `published advisories against this dependency tree`,
228
+ tier: 1,
229
+ ttlMs: DAY_MS,
230
+ timeoutMs: 5 * 60_000,
231
+ // A lockfile, not a package.json: auditing resolves the actual installed tree, and without one pnpm
232
+ // reports against nothing.
233
+ available: `test -f pnpm-lock.yaml || test -f package-lock.json`,
234
+ command: `pnpm audit --json 2>/dev/null || true`,
235
+ parse: parseAudit,
236
+ },
237
+ {
238
+ id: `knip`,
239
+ title: `Unreachable code`,
240
+ measures: `files, exports and dependencies nothing references`,
241
+ tier: 2,
242
+ ttlMs: 7 * DAY_MS,
243
+ timeoutMs: 15 * 60_000,
244
+ // The repo's OWN knip, never a floating one: `pnpm dlx knip` would download a version that disagrees with
245
+ // the repo's knip.json about what counts as an entry point, and then report its whole public API as dead.
246
+ available: `pnpm exec knip --version >/dev/null 2>&1`,
247
+ command: `pnpm exec knip --reporter json --no-exit-code 2>/dev/null || true`,
248
+ parse: parseKnip,
249
+ },
250
+ {
251
+ id: `jscpd`,
252
+ title: `Copy-paste`,
253
+ measures: `how much of the tree is duplicated elsewhere in it`,
254
+ tier: 2,
255
+ ttlMs: 7 * DAY_MS,
256
+ timeoutMs: 20 * 60_000,
257
+ available: `test -f package.json`,
258
+ // `--threshold 100` so jscpd never fails the command on its own opinion of what is too much duplication —
259
+ // that judgement is the chore's, made from the percentage, not the tool's exit code.
260
+ command:
261
+ `pnpm dlx jscpd --reporters json --output ${JSCPD_DIR} --min-lines 12 --threshold 100 . >/dev/null 2>&1; ` +
262
+ `cat ${JSCPD_DIR}/jscpd-report.json 2>/dev/null`,
263
+ parse: parseJscpd,
264
+ },
265
+ ];
266
+
267
+ export const probeSpec = (id: ProbeId): ProbeSpec => {
268
+ const spec = PROBES.find((probe) => probe.id === id);
269
+ if (spec === undefined) {
270
+ throw new Error(`chores: no probe named "${id}"`);
271
+ }
272
+ return spec;
273
+ };
@@ -0,0 +1,64 @@
1
+ /* HOW WE ASK. Every prompt this workspace generates from a measurement — a hotspot's refactor, a chore's sweep —
2
+ * has the same four parts, in the same order, for the same reasons:
3
+ *
4
+ * subject the one line that says what is being worked on. First, because a model that reads the rationale
5
+ * before the target starts planning against a subject it has not been told yet.
6
+ * why the NUMBERS, quoted exactly as the panel shows them, then what they mean. Exact so the agent and
7
+ * the person are arguing about one set of facts; the agent can and should recount them.
8
+ * goal what shape to move towards — never a design. Whoever generated this prompt has not read the code,
9
+ * so a prescribed solution from out here is a guess wearing an instruction's clothes.
10
+ * done falsifiable, and checkable by the agent itself. The same resident engine that produced the
11
+ * measurement answers `iq` in the agent's own worktree, so "run it again and see" is available and
12
+ * "I have finished" is not something it has to be taken at its word on.
13
+ *
14
+ * The invariants sit between goal and done because they are the constraints on HOW, and they are stated in full
15
+ * every time rather than assumed. Each one is a specific way the turn fails without it — they are here because
16
+ * they were each learned from a diff nobody could review. */
17
+
18
+ export interface Ask {
19
+ readonly subject: string;
20
+ readonly why: string;
21
+ readonly diagnosis: string;
22
+ readonly goal: string;
23
+ readonly invariants: string;
24
+ readonly done: string;
25
+ }
26
+
27
+ export const composeAsk = ({ subject, why, diagnosis, goal, invariants, done }: Ask): string =>
28
+ [subject, `Why: ${why} ${diagnosis}`, goal, `${invariants} ${done}`].join(`\n\n`);
29
+
30
+ /* Said to every turn a TOOL woke, and the reason the maintenance surface can point agents at tool output at all.
31
+ * A tool reporting N findings is not reporting N problems: knip is confidently wrong about anything reachable
32
+ * from outside the repo, jscpd counts generated files, an advisory in a build-time dependency is not the same
33
+ * risk as one in a running service. A chore that mechanically actions the whole list is worse than no chore —
34
+ * it makes noisy, confident, wrong changes at three in the morning, and the next person has to review a diff
35
+ * whose author had no opinion about it. */
36
+ export const TRIAGE_NOTE =
37
+ `The measurement woke you; it did not decide anything. Read the repository before you touch it, and treat every ` +
38
+ `finding as a claim to verify rather than a task to execute. If a finding is wrong, say why in one line and leave ` +
39
+ `it — a run that verifies ten and fixes two is a good run.`;
40
+
41
+ /* The invariants for a turn that CHANGES things. Whatever it does lands as uncommitted work in the owner's
42
+ * workspace, so it is reviewed as one diff by someone who did not watch it happen — which is what every clause
43
+ * here is protecting.
44
+ *
45
+ * "Separately explainable" is doing the most work: a chore that fixes its findings AND tidies what it passed on
46
+ * the way produces a diff whose reviewer cannot tell which changes were the point. */
47
+ export const CHORE_INVARIANTS =
48
+ `Keep it mechanical and separately explainable: nothing lands that you could not justify on its own line of the ` +
49
+ `summary. Do not reformat, rename or "while I was in here" anything the finding did not name. Run the repository's ` +
50
+ `own type-check and tests before you finish, and if you cannot make them pass, leave the change out and say so.`;
51
+
52
+ // The invariants for a turn that only LOOKS. Separate from the above rather than a flag on it, because the failure
53
+ // mode is the opposite one: a report-stance chore that quietly starts editing is the single most surprising thing
54
+ // this surface could do, and it has to be forbidden in words rather than by omission.
55
+ export const REPORT_INVARIANTS =
56
+ `Change nothing. This is a survey: the output is your findings, cited file:line, and a recommendation the owner ` +
57
+ `can act on or dismiss. Where you would propose an edit, describe it and where it would go instead of making it.`;
58
+
59
+ /* The invariants for a turn refactoring ONE FILE, as the codebase-health panel's rows ask for. Distinct from the
60
+ * chore ones because the blast radius is the thing at stake: named as a radius rather than "only this file",
61
+ * since half those archetypes ask for new files and must not read as forbidding them. */
62
+ export const REFACTOR_INVARIANTS =
63
+ `Read it first. Behaviour stays identical, and the blast radius is this file, whatever it splits into, and the ` +
64
+ `importers that must follow — no re-export shims left behind.`;