@intentic/sandbox-contract 1.233.0 → 1.234.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 (120) hide show
  1. package/README.md +4 -3
  2. package/dist/agent-catalog.d.ts +2 -1
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +19 -13
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/chores/chores.d.ts.map +1 -1
  7. package/dist/chores/chores.js +36 -1
  8. package/dist/chores/chores.js.map +1 -1
  9. package/dist/chores/probes.d.ts.map +1 -1
  10. package/dist/chores/probes.js +70 -0
  11. package/dist/chores/probes.js.map +1 -1
  12. package/dist/command-classes.d.ts +5 -2
  13. package/dist/command-classes.d.ts.map +1 -1
  14. package/dist/command-classes.js +34 -13
  15. package/dist/command-classes.js.map +1 -1
  16. package/dist/contracts/automations.contract.d.ts +30 -0
  17. package/dist/contracts/automations.contract.d.ts.map +1 -1
  18. package/dist/contracts/chores.contract.d.ts +17 -0
  19. package/dist/contracts/chores.contract.d.ts.map +1 -1
  20. package/dist/contracts/extensions.contract.d.ts +2 -0
  21. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  22. package/dist/contracts/extensions.contract.js.map +1 -1
  23. package/dist/contracts/issues.contract.d.ts +89 -0
  24. package/dist/contracts/issues.contract.d.ts.map +1 -0
  25. package/dist/contracts/issues.contract.js +50 -0
  26. package/dist/contracts/issues.contract.js.map +1 -0
  27. package/dist/contracts/runner.contract.d.ts +102 -102
  28. package/dist/contracts/settings.contract.d.ts +12 -0
  29. package/dist/contracts/settings.contract.d.ts.map +1 -1
  30. package/dist/credential-material.d.ts +2 -0
  31. package/dist/credential-material.d.ts.map +1 -0
  32. package/dist/credential-material.js +36 -0
  33. package/dist/credential-material.js.map +1 -0
  34. package/dist/definition.d.ts +8 -0
  35. package/dist/definition.d.ts.map +1 -1
  36. package/dist/history-state.d.ts.map +1 -1
  37. package/dist/history-state.js +1 -0
  38. package/dist/history-state.js.map +1 -1
  39. package/dist/hostnames.d.ts +2 -0
  40. package/dist/hostnames.d.ts.map +1 -1
  41. package/dist/hostnames.js +3 -1
  42. package/dist/hostnames.js.map +1 -1
  43. package/dist/index.d.ts +268 -116
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/runtime-state.d.ts +9 -0
  48. package/dist/runtime-state.d.ts.map +1 -1
  49. package/dist/runtime-state.js +3 -0
  50. package/dist/runtime-state.js.map +1 -1
  51. package/dist/schemas/agent.d.ts +6 -0
  52. package/dist/schemas/agent.d.ts.map +1 -1
  53. package/dist/schemas/agent.js +2 -1
  54. package/dist/schemas/agent.js.map +1 -1
  55. package/dist/schemas/automations.d.ts +45 -0
  56. package/dist/schemas/automations.d.ts.map +1 -1
  57. package/dist/schemas/automations.js +6 -1
  58. package/dist/schemas/automations.js.map +1 -1
  59. package/dist/schemas/extension-updates.d.ts +2 -0
  60. package/dist/schemas/extension-updates.d.ts.map +1 -1
  61. package/dist/schemas/extension-updates.js +3 -1
  62. package/dist/schemas/extension-updates.js.map +1 -1
  63. package/dist/schemas/issues.d.ts +324 -0
  64. package/dist/schemas/issues.d.ts.map +1 -0
  65. package/dist/schemas/issues.js +107 -0
  66. package/dist/schemas/issues.js.map +1 -0
  67. package/dist/schemas/maintenance.d.ts +62 -1
  68. package/dist/schemas/maintenance.d.ts.map +1 -1
  69. package/dist/schemas/maintenance.js +22 -1
  70. package/dist/schemas/maintenance.js.map +1 -1
  71. package/dist/schemas/settings.d.ts +6 -0
  72. package/dist/schemas/settings.d.ts.map +1 -1
  73. package/dist/schemas/settings.js +4 -0
  74. package/dist/schemas/settings.js.map +1 -1
  75. package/dist/schemas/terminal.d.ts.map +1 -1
  76. package/dist/schemas/terminal.js.map +1 -1
  77. package/dist/schemas/webext.d.ts +24 -0
  78. package/dist/schemas/webext.d.ts.map +1 -1
  79. package/dist/schemas/webext.js +9 -0
  80. package/dist/schemas/webext.js.map +1 -1
  81. package/dist/webext-links.d.ts +1 -0
  82. package/dist/webext-links.d.ts.map +1 -1
  83. package/dist/webext-links.js +1 -0
  84. package/dist/webext-links.js.map +1 -1
  85. package/dist/workspace-state.d.ts +10 -6
  86. package/dist/workspace-state.d.ts.map +1 -1
  87. package/dist/workspace-state.js +13 -8
  88. package/dist/workspace-state.js.map +1 -1
  89. package/package.json +8 -8
  90. package/src/agent-catalog.test.ts +55 -54
  91. package/src/agent-catalog.ts +29 -22
  92. package/src/capability-ledger.test.ts +11 -2
  93. package/src/chores/chores.test.ts +1 -1
  94. package/src/chores/chores.ts +66 -1
  95. package/src/chores/probes.test.ts +65 -0
  96. package/src/chores/probes.ts +133 -1
  97. package/src/chores/verdict.test.ts +29 -19
  98. package/src/command-classes.test.ts +71 -4
  99. package/src/command-classes.ts +112 -22
  100. package/src/contracts/extensions.contract.ts +3 -2
  101. package/src/contracts/issues.contract.ts +60 -0
  102. package/src/credential-material.test.ts +120 -0
  103. package/src/credential-material.ts +100 -0
  104. package/src/history-state.ts +12 -0
  105. package/src/hostnames.ts +18 -2
  106. package/src/index.ts +5 -0
  107. package/src/routes.test.ts +4 -2
  108. package/src/runtime-state.ts +16 -0
  109. package/src/schemas/agent.ts +15 -2
  110. package/src/schemas/automations.ts +14 -2
  111. package/src/schemas/extension-updates.ts +3 -1
  112. package/src/schemas/issues.ts +279 -0
  113. package/src/schemas/maintenance.ts +51 -1
  114. package/src/schemas/settings.ts +32 -0
  115. package/src/schemas/terminal.ts +6 -4
  116. package/src/schemas/webext.ts +29 -0
  117. package/src/tunnel-ids.test.ts +3 -1
  118. package/src/webext-links.ts +10 -0
  119. package/src/workspace-state.test.ts +3 -1
  120. package/src/workspace-state.ts +27 -37
@@ -88,9 +88,10 @@ describe("the pi provider", () => {
88
88
  expect(pi.steering).toBe(true);
89
89
  expect(pi.effort).toBe(true);
90
90
  expect(pi.commands).toBe(true);
91
+ expect(pi.mcp).toBe("none");
92
+ expect(pi.terminals).toBe(false);
91
93
  const limitations = limitationsOf(pi);
92
- expect(limitations).toContain("no MCP tools or plugins");
93
- expect(limitations).toContain("no terminal panel");
94
+ expect(limitations.length).toBeGreaterThan(0);
94
95
  expect(limitations).not.toContain("no mid-turn steering");
95
96
  expect(limitations).not.toContain("no effort control");
96
97
  expect(limitations).not.toContain("no slash commands");
@@ -152,22 +153,23 @@ test("a mode the runtime can't hold falls back to the one it runs; one it can ho
152
153
  test("the ceiling has nothing to disclose; a floor names what it lacks", () => {
153
154
  expect(limitationsOf(capabilitiesOf("claude", "native"))).toEqual([]);
154
155
 
155
- const grok = limitationsOf(capabilitiesOf("grok", "native"));
156
- expect(grok).toContain("no per-tool approvals");
157
- expect(grok).toContain("no mid-turn steering");
158
- expect(grok).toContain("no effort control");
159
- expect(grok).toContain("worktree by working directory only");
160
-
161
- // ACP takes our http MCP tools when it advertises them, so its line is a narrowing rather than an absence:
162
- // and it publishes commands and terminals, which must NOT be listed as missing.
163
- const acp = limitationsOf(capabilitiesOf("some-installed-agent", "native"));
164
- expect(acp).toContain("MCP tools only: no plugins or browser");
165
- expect(acp).not.toContain("no slash commands");
166
- expect(acp).not.toContain("no terminal panel");
167
-
168
- const codex = limitationsOf(capabilitiesOf("codex", "native"));
169
- expect(codex).toContain("browser tools only: no plugins or other MCP tools");
170
- expect(codex).not.toContain("no MCP tools or plugins");
156
+ const grokCaps = capabilitiesOf("grok", "native");
157
+ expect(grokCaps.permissions).toBe("plan");
158
+ expect(grokCaps.steering).toBe(false);
159
+ expect(grokCaps.isolation).toBe("cwd");
160
+ expect(limitationsOf(grokCaps).length).toBeGreaterThan(0);
161
+
162
+ const acpCaps = capabilitiesOf("some-installed-agent", "native");
163
+ expect(acpCaps.mcp).toBe("http");
164
+ const acp = limitationsOf(acpCaps);
165
+ expect(acp.some((line) => line.includes("MCP"))).toBe(true);
166
+ expect(acpCaps.commands).toBe(true);
167
+ expect(acpCaps.terminals).toBe(true);
168
+
169
+ const codexCaps = capabilitiesOf("codex", "native");
170
+ expect(codexCaps.mcp).toBe("browser");
171
+ expect(limitationsOf(codexCaps).some((line) => line.includes("browser"))).toBe(true);
172
+ expect(limitationsOf(codexCaps).length).toBeLessThan(limitationsOf({ ...grokCaps, mcp: "none" }).length + 5);
171
173
  });
172
174
 
173
175
  test("every axis a record can lack has words for it", () => {
@@ -185,6 +187,7 @@ test("every axis a record can lack has words for it", () => {
185
187
  terminals: false,
186
188
  recovery: false,
187
189
  instructions: "none",
190
+ skillDiscovery: "prompt",
188
191
  rulebook: "none",
189
192
  secrets: "none",
190
193
  };
@@ -201,33 +204,27 @@ test("every axis a record can lack has words for it", () => {
201
204
  * has a middle value, and disclosing the floor's words for it would tell a Codex user their rules are ignored
202
205
  * when they are in fact being applied to everything Codex asks about. */
203
206
  test("the safety axes disclose the middle answer differently from the floor", () => {
204
- const claude = limitationsOf(capabilitiesOf("claude", "native")).join(" ");
205
- const codex = limitationsOf(capabilitiesOf("codex", "native")).join(" ");
206
- const pi = limitationsOf(capabilitiesOf("pi", "native")).join(" ");
207
-
208
- // The ceiling says nothing about either axis.
209
- expect(claude).not.toContain("command rules");
210
- expect(claude).not.toContain("stored secrets");
211
-
212
- // The middle: rules DO apply, to what the vendor raises. Never the floor's flat "aren't applied".
213
- expect(codex).toContain("only to calls this agent asks about");
214
- expect(codex).not.toContain("aren't applied");
215
-
216
- // The floor: no seam at all, said plainly.
217
- expect(pi).toContain("your command rules aren't applied");
218
-
219
- /* The third answer, which exists because OpenCode's watchdog aborts a turn that pauses. Its sentence must
220
- * say the rules DO bite (unlike Pi's) and that a hold cannot ask (unlike Codex's). */
221
- const grok = limitationsOf(capabilitiesOf("grok", "native")).join(" ");
222
- expect(grok).toContain("a rule set to hold refuses instead");
223
- expect(grok).not.toContain("aren't applied");
224
- expect(grok).not.toContain("only to calls this agent asks about");
225
- // Gemini rides the same loop, so it must read the same way.
226
- expect(limitationsOf(capabilitiesOf("gemini", "native")).join(" ")).toBe(grok);
227
-
228
- // Masking is binary and structural, so every non-Claude runtime says the same thing.
207
+ const claudeCaps = capabilitiesOf("claude", "native");
208
+ const codexCaps = capabilitiesOf("codex", "native");
209
+ const piCaps = capabilitiesOf("pi", "native");
210
+ const grokCaps = capabilitiesOf("grok", "native");
211
+
212
+ expect(claudeCaps.rulebook).toBe("hooks");
213
+ expect(claudeCaps.secrets).toBe("masked");
214
+
215
+ expect(codexCaps.rulebook).toBe("approval");
216
+ expect(piCaps.rulebook).toBe("none");
217
+ expect(grokCaps.rulebook).toBe("refuse-only");
218
+
219
+ expect(limitationsOf(claudeCaps).join(" ")).not.toContain("command rules");
220
+ expect(limitationsOf(claudeCaps).join(" ")).not.toContain("stored secrets");
221
+
222
+ expect(limitationsOf(codexCaps).length).toBeGreaterThan(limitationsOf(claudeCaps).length);
223
+ expect(limitationsOf(piCaps).length).toBeGreaterThan(limitationsOf(claudeCaps).length);
224
+ expect(limitationsOf(grokCaps).join(" ")).toBe(limitationsOf(capabilitiesOf("gemini", "native")).join(" "));
225
+
229
226
  for (const provider of ["codex", "grok", "gemini", "pi", "some-installed-agent"] as const) {
230
- expect(limitationsOf(capabilitiesOf(provider, "native")).join(" ")).toContain("stored secrets reach the model unmasked");
227
+ expect(capabilitiesOf(provider, "native").secrets).toBe("none");
231
228
  }
232
229
  });
233
230
 
@@ -235,14 +232,16 @@ test("the safety axes disclose the middle answer differently from the floor", ()
235
232
  * count above cannot check: a middle value that discloses the same words as the floor would tell a Grok user
236
233
  * their prompt is ignored when it is in fact being sent. */
237
234
  test("the instruction axis discloses its two weaker answers, differently", () => {
238
- const grok = limitationsOf(capabilitiesOf("grok", "native")).join(" ");
239
- const acp = limitationsOf(capabilitiesOf("some-installed-agent", "native")).join(" ");
240
-
241
- expect(grok).toContain("added to theirs");
242
- expect(grok).not.toContain("isn't applied");
243
- expect(acp).toContain("isn't applied");
244
- // Codex on its own runtime replaces, like the Claude Code loop, so it has nothing to disclose here.
245
- expect(limitationsOf(capabilitiesOf("codex", "native")).join(" ")).not.toContain("system prompt");
235
+ const grokCaps = capabilitiesOf("grok", "native");
236
+ const acpCaps = capabilitiesOf("some-installed-agent", "native");
237
+ const codexCaps = capabilitiesOf("codex", "native");
238
+
239
+ expect(grokCaps.instructions).toBe("append");
240
+ expect(acpCaps.instructions).toBe("none");
241
+ expect(codexCaps.instructions).toBe("replace");
242
+
243
+ expect(limitationsOf(grokCaps).length).toBeGreaterThan(limitationsOf(codexCaps).length);
244
+ expect(limitationsOf(acpCaps).length).toBeGreaterThan(limitationsOf(codexCaps).length);
246
245
  });
247
246
 
248
247
  /* The JS backend is hosted by the one loop the daemon can put its own execution seam through. Pinned as a test
@@ -366,8 +365,10 @@ describe("the free providers", () => {
366
365
  // empty headline or a dangling sentence in front of the user who has connected nothing.
367
366
  for (const provider of FREE_PROVIDERS) {
368
367
  const access = accessFor(provider);
369
- expect(access?.requirement).toBeTruthy();
370
- expect(access?.runs).toBeTruthy();
368
+ // Both are sentences the gate PUTS ON SCREEN, so what they have to be is text with something in
369
+ // it. Truthiness passes a whitespace-only string, which renders as the empty headline this is about.
370
+ expect(access?.requirement).toEqual(expect.stringMatching(/\S/));
371
+ expect(access?.runs).toEqual(expect.stringMatching(/\S/));
371
372
  }
372
373
  });
373
374
  });
@@ -140,7 +140,7 @@ export const TRIAL_MODEL_ID = "auto";
140
140
  // What the picker calls it, and the sentence the surfaces put underneath. One wording, so the composer's notice
141
141
  // and the picker's row cannot end up describing different bargains.
142
142
  export const TRIAL_LABEL = "Free trial";
143
- export const TRIAL_NOTICE = "Trial messages pass through intentic's servers. Connect an account to chat directly.";
143
+ export const TRIAL_NOTICE = "Trial messages pass through intentic. Connect an account to chat directly.";
144
144
 
145
145
  export const ENDPOINT_PROVIDER_PREFIX = "endpoint/";
146
146
  export const endpointProvider = (id: string): AgentProvider => `${ENDPOINT_PROVIDER_PREFIX}${id}`;
@@ -300,6 +300,16 @@ export interface AgentCapabilities {
300
300
  * only the Claude Code loop's: Codex's own base describes Codex's own tools, so swapping it for a prompt
301
301
  * written about another harness is the owner's deliberate act (their custom text), never ours. */
302
302
  readonly instructions: "replace" | "append" | "none";
303
+ /* HOW THIS RUNTIME DISCOVERS THE WORKSPACE'S LOADED SKILLS.
304
+ *
305
+ * "native", the runtime scans one of the filesystem projections itself: `.agents/skills/` for Codex,
306
+ * `.claude/skills/` for the Claude Code loop. Its own loader injects the catalogue and reads
307
+ * the matching SKILL.md on demand, so adding our own note would duplicate it.
308
+ * "prompt", the runtime has no loader the daemon can rely on. turn-plan.ts puts the same name,
309
+ * description and absolute SKILL.md path into the opening user-message preamble. This is a
310
+ * separate axis from `instructions`: Pi and ACP take no system prompt at all, while OpenCode
311
+ * and Cursor take an append, but all four still need skill discovery. */
312
+ readonly skillDiscovery: "native" | "prompt";
303
313
  /* WHETHER THE OWNER'S COMMAND RULEBOOK REACHES THIS RUNTIME (SandboxSettings.commandRules, decided by
304
314
  * guard/actions.ts commandRun, delivered by guard/command-gate.ts).
305
315
  *
@@ -364,6 +374,7 @@ const CLAUDE_CODE: AgentCapabilities = {
364
374
  terminals: true,
365
375
  recovery: true,
366
376
  instructions: "replace",
377
+ skillDiscovery: "native",
367
378
  // The only runtime with a pre-execution hook of its own, which is why it is the only one where a HOLD can
368
379
  // park the call and wait for a card rather than having to refuse it.
369
380
  rulebook: "hooks",
@@ -397,6 +408,7 @@ const CODEX: AgentCapabilities = {
397
408
  * message ahead of its skills and team blocks. Verified against codex-cli 0.147 by reading what actually
398
409
  * reached the wire, the keys are undocumented, and a strings dump proves only that they parse. */
399
410
  instructions: "replace",
411
+ skillDiscovery: "native",
400
412
  /* App-server publishes `item/commandExecution/requestApproval`, whose params carry the command text, and
401
413
  * takes `accept`/`decline` back (codex-cli 0.147's own generated JSON Schema, read with
402
414
  * `codex app-server generate-json-schema`). The daemon only asks Codex to raise those requests when the
@@ -425,6 +437,7 @@ const OPENCODE: AgentCapabilities = {
425
437
  // replacing that, so a custom prompt lands here as extra instructions, and the settings page says so
426
438
  // rather than letting "replaces everything" quietly mean something else on two providers.
427
439
  instructions: "append",
440
+ skillDiscovery: "prompt",
428
441
  /* OpenCode asks over its own permission channel (`permission.updated`, replied on
429
442
  * `/session/{id}/permissions/{permissionID}`, vocabulary once/always/reject), and the daemon judges what it
430
443
  * raises with the same decide fn every other runtime uses.
@@ -474,6 +487,7 @@ const ACP: AgentCapabilities = {
474
487
  // ACP's `session/new` and `session/prompt` carry no system field: the agent owns its own instructions the
475
488
  // same way it owns its model and its permission posture. The persona note takes the user message instead.
476
489
  instructions: "none",
490
+ skillDiscovery: "prompt",
477
491
  /* `session/request_permission` is in the protocol floor, so every conforming agent has the channel and the
478
492
  * daemon answers it from the rulebook (acp/acp-permissions.ts). The caveat the "approval" value already
479
493
  * carries is at its widest here: WHICH calls an agent asks about is entirely the agent's choice, and one
@@ -510,6 +524,7 @@ const PI: AgentCapabilities = {
510
524
  // Pi's RPC opens a session with a prompt and steers it; nothing in that protocol sets standing
511
525
  // instructions, so like ACP it hears the persona note through the user message.
512
526
  instructions: "none",
527
+ skillDiscovery: "prompt",
513
528
  /* THE ONE RUNTIME WITH NO SEAM AT ALL. Pi runs bash in-process and its RPC publishes no approval request,
514
529
  * so there is nothing to consult before a command runs and no rule the owner writes can apply here. Said
515
530
  * out loud rather than left to be discovered: limitationsOf renders it, and the taint floor treats a "none"
@@ -582,6 +597,7 @@ const CURSOR: AgentCapabilities = {
582
597
  * `additional_context` that is folded into the request. So the owner's prompt and the persona note DO reach
583
598
  * the model, on top of Cursor's own base prompt, and nothing can replace that base. */
584
599
  instructions: "append",
600
+ skillDiscovery: "prompt",
585
601
  /* THE FULL HOOK TIER, the only foreign runtime that reaches it. Cursor reads `.cursor/hooks.json` in its
586
602
  * local runtime, and `beforeShellExecution` answers with `allow` / `deny` / `ask` plus the messages that
587
603
  * explain it, with `failClosed` available so a crashed gate blocks instead of waving the command through.
@@ -663,35 +679,26 @@ export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
663
679
  ...(capabilities.mcp === "none"
664
680
  ? ["no MCP tools or plugins"]
665
681
  : capabilities.mcp === "http"
666
- ? ["MCP tools only: no plugins or browser"]
682
+ ? ["MCP tools only, no plugins or browser"]
667
683
  : capabilities.mcp === "browser"
668
- ? ["browser tools only: no plugins or other MCP tools"]
684
+ ? ["browser tools only, no other MCP"]
669
685
  : capabilities.mcp === "tools"
670
- ? ["no plugins: every other tool reaches it"]
686
+ ? ["no plugins"]
671
687
  : []),
672
- ...(capabilities.execution.includes("js") ? [] : ["no code runs, its shell is the one way to execute"]),
688
+ ...(capabilities.execution.includes("js") ? [] : ["no code runs, shell only"]),
673
689
  ...(capabilities.effort ? [] : ["no effort control"]),
674
690
  ...(capabilities.commands ? [] : ["no slash commands"]),
675
691
  ...(capabilities.terminals ? [] : ["no terminal panel"]),
676
- ...(capabilities.isolation === "namespace" ? [] : ["worktree by working directory only"]),
677
- ...(capabilities.recovery ? [] : ["no auto-resume after an outage"]),
678
- /* The two weaker answers on the instruction axis, and only those: "replace" is the ceiling this list
679
- * measures against, so it has nothing to disclose. Both phrasings name the OWNER'S prompt rather than the
680
- * mechanism, because that is the thing they wrote and the thing that will or will not be in force. */
681
- ...(capabilities.instructions === "append" ? ["your system prompt is added to theirs, not replacing it"] : []),
682
- ...(capabilities.instructions === "none" ? ["your system prompt isn't applied"] : []),
683
- /* THE TWO SAFETY AXES, phrased as what the OWNER loses rather than as which seam is missing, because both
684
- * describe something they configured on a settings page and would otherwise assume was in force everywhere.
685
- *
686
- * "hooks" and "masked" are the ceiling and disclose nothing. The "approval" middle answer discloses the one
687
- * thing that genuinely differs from a hook: the vendor picks which calls it asks about, so a rule can only
688
- * reach what it chose to raise. */
689
- ...(capabilities.rulebook === "approval" ? ["your command rules apply only to calls this agent asks about"] : []),
692
+ ...(capabilities.isolation === "namespace" ? [] : ["worktree by cwd only"]),
693
+ ...(capabilities.recovery ? [] : ["no auto-resume after outage"]),
694
+ ...(capabilities.instructions === "append" ? ["system prompt appended, not replaced"] : []),
695
+ ...(capabilities.instructions === "none" ? ["system prompt not applied"] : []),
696
+ ...(capabilities.rulebook === "approval" ? ["command rules apply only to calls this agent raises"] : []),
690
697
  ...(capabilities.rulebook === "refuse-only"
691
- ? ["your command rules can stop a command here but not pause to ask: a rule set to hold refuses instead"]
698
+ ? ["command rules can refuse but not hold"]
692
699
  : []),
693
- ...(capabilities.rulebook === "none" ? ["your command rules aren't applied"] : []),
694
- ...(capabilities.secrets === "none" ? ["stored secrets reach the model unmasked, and `{{secret:name}}` isn't substituted"] : []),
700
+ ...(capabilities.rulebook === "none" ? ["command rules not applied"] : []),
701
+ ...(capabilities.secrets === "none" ? ["secrets reach the model unmasked"] : []),
695
702
  ];
696
703
 
697
704
  // Claude's compile-time model floor, shared by the daemon's catalog (claude-models.ts, its last rung, reached
@@ -54,6 +54,9 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
54
54
  * that produced it: before the field existed, every runtime was composed for as though it were the Claude
55
55
  * Code loop, and five of the six silently dropped the owner's prompt. */
56
56
  instructions: "enforced",
57
+ // turn-plan.ts reads this before an opening turn: a "prompt" runtime receives the generated catalogue as
58
+ // a typed note, while a "native" runtime is left to its own loader so the same list never arrives twice.
59
+ skillDiscovery: "enforced",
57
60
  // turn-plan.ts (honoured) plans the JS backend only where "js" is declared, so a runtime without it is
58
61
  // handed no `jsExecution` and mounts no Code tool: the same drop-what-you-can't-honour rule as `effort`.
59
62
  execution: "enforced",
@@ -111,8 +114,14 @@ describe("a descriptive claim reaches the user", () => {
111
114
  const DIMINISHED: Partial<Record<keyof AgentCapabilities, unknown>> = { secrets: "none" };
112
115
 
113
116
  it.each(fieldsWhere("descriptive"))("%s puts its own sentence in the picker when the runtime lacks it", (field) => {
114
- const floor = typeof CEILING[field] === "boolean" ? false : DIMINISHED[field];
115
- expect(floor, `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`).toBeDefined();
117
+ const boolean = typeof CEILING[field] === "boolean";
118
+ // A boolean's floor is `false` and needs no entry; every other descriptive field has to name its own.
119
+ // Asserted against the KEYS of DIMINISHED so the failure prints the floors that do exist, which is what
120
+ // tells "this field was never added" apart from "this field was renamed and its entry left behind".
121
+ expect(boolean ? [field] : Object.keys(DIMINISHED), `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`).toContain(
122
+ field,
123
+ );
124
+ const floor = boolean ? false : DIMINISHED[field];
116
125
 
117
126
  const lacking: AgentCapabilities = { ...CEILING, [field]: floor };
118
127
 
@@ -19,7 +19,7 @@ describe(`the chore book's order`, () => {
19
19
  // The sort cannot drop an entry, but a future refactor to a filter-into-groups could, so the count is
20
20
  // pinned to the ids rather than to a number, which says which one went missing.
21
21
  expect(new Set(CHORES.map((chore) => chore.id)).size).toBe(CHORES.length);
22
- expect(CHORES.length).toBe(17);
22
+ expect(CHORES.length).toBe(18);
23
23
  });
24
24
 
25
25
  it(`says "surveying" exactly when a chore is a survey`, () => {
@@ -430,6 +430,70 @@ const duplication: Chore = {
430
430
  done: `Done when every clone in the report has either a named extraction or a one-line reason it should stay.`,
431
431
  };
432
432
 
433
+ /* TEST STRENGTH. The one chore whose evidence is about the tests rather than the code, and it exists because
434
+ * nothing else in this workspace can produce it.
435
+ *
436
+ * A green suite is not evidence that the code is checked. Coverage says a line RAN; it cannot say an assertion
437
+ * depended on what the line produced. The gap between those two is where a model's tests live: they execute
438
+ * everything and assert almost nothing, and every gate here says yes to them — they type-check, they lint, they
439
+ * pass.
440
+ *
441
+ * MEASURED IN THIS REPOSITORY, on sandbox-contract's own chore module: 109 hand-written tests, and 16 of 58
442
+ * injected faults survived. The one worth reading is in `bucketOf`, whose comment in digest.ts argues at length
443
+ * that the zero boundary is load-bearing. Move that boundary and the suite stays green, because the test holding
444
+ * it is written relationally — `expect(bucketOf(0)).not.toBe(bucketOf(1))` — and with the boundary moved the two
445
+ * values are still different. The careful, un-brittle assertion is precisely the one that cannot see the change.
446
+ *
447
+ * THE FLOOR IS LOW ON PURPOSE. 60% is where Stryker's own default report turns red, and it is far under what a
448
+ * well-tested module scores, because this chore is looking for suites that are decorative rather than suites that
449
+ * are imperfect. A threshold near the good number would badge every honest package in the repo, which is how a
450
+ * maintenance surface teaches people to ignore it. */
451
+ const MUTATION_FLOOR = 60;
452
+
453
+ const testStrength: Chore = {
454
+ id: `test-strength`,
455
+ title: `Strengthen tests that would not notice a bug`,
456
+ icon: `list-check`,
457
+ description: `Whether the suite would actually fail if the code broke, which is a different question from whether it passes.`,
458
+ kind: `accruing`,
459
+ criterion: `Stryker's mutation score for the repo is under ${MUTATION_FLOOR}%.`,
460
+ stance: `act`,
461
+ needs: [`mutation`],
462
+ // Quarterly rather than monthly: a mutation score moves when tests are rewritten, which is not a weekly event,
463
+ // and the probe behind it is the most expensive one here.
464
+ cadenceMs: 90 * DAY_MS,
465
+ assess: (context) => {
466
+ const facts = factsOf(context, `mutation`);
467
+ if (facts === undefined || facts.mutation.score >= MUTATION_FLOOR) {
468
+ return undefined;
469
+ }
470
+ const { score, killed, survived, survivors } = facts.mutation;
471
+ return {
472
+ headline: `${survived} injected faults went unnoticed, ${score}% of them caught`,
473
+ // The survivors themselves, not the score. A percentage is a mood; a named line with the change that
474
+ // nothing objected to is a morning's work with the answer already in it.
475
+ detail: survivors.map((one) => `${one.file}:${one.line} · ${one.mutator} → ${one.replacement} · survived`),
476
+ /* Bucketed, via the same helper the other counting chores use, so ordinary drift does not read as
477
+ * news: a score moving 54 → 55 is not a thing to interrupt anyone about. The survivors' IDENTITIES
478
+ * ride along, so a NEW weak spot appearing speaks even while the number holds steady — which is the
479
+ * case that matters, because that is a test somebody just wrote. */
480
+ digest: digestOf(`bucket:${bucketOf(100 - score)}`, ...survivors.map((one) => `${one.file}:${one.line}`).toSorted()),
481
+ severity: `info`,
482
+ why:
483
+ `Stryker caught ${killed} of ${killed + survived} injected faults in ${repoLabel(context.repo)} (${score}%), under the ${MUTATION_FLOOR}% floor. ` +
484
+ `Code that can be changed with every test still green: ${survivors.map((one) => `${one.file}:${one.line} (${one.mutator} → ${one.replacement})`).join(`; `)}.`,
485
+ };
486
+ },
487
+ diagnosis: `Tests that run the code without checking what it produced pass whether or not the code is right, and no other check in this repository can tell the difference.`,
488
+ goal:
489
+ `Take the survivors one at a time and, for each, decide which of two things it is. Either the mutation changes behaviour somebody ` +
490
+ `depends on, in which case add the assertion that would have failed — usually at a BOUNDARY, and usually exact where the existing ` +
491
+ `test was relational: pinning \`bucketOf(0)\` to its value catches what \`not.toBe(bucketOf(1))\` cannot. Or it is an equivalent ` +
492
+ `mutant, code whose change genuinely cannot be observed, in which case say so and leave it. Do not chase the percentage: adding an ` +
493
+ `assertion nobody needs to satisfy a number is exactly the ceremony this is meant to detect.`,
494
+ done: `Done when every named survivor has either a new assertion that fails without the change, or a one-line note saying why it cannot be observed.`,
495
+ };
496
+
433
497
  /* DOCUMENTATION. The evidence is a package with no README, which IS its architecture document in this
434
498
  * workspace, so this is a stat on the package directory rather than a lookup in a parallel tree. It sounds like
435
499
  * a coverage statistic
@@ -597,7 +661,7 @@ const runtime: Chore = {
597
661
  },
598
662
  diagnosis: `An unsupported runtime stops receiving security patches, so every advisory against it stays open permanently.`,
599
663
  goal:
600
- `Establish what actually pins this runtime: the image's own base, the workspace's useNodeVersion, and each package's engines ` +
664
+ `Establish what actually pins this runtime: the image's own base, the workspace's nodeVersion, and each package's engines ` +
601
665
  `range. Propose the smallest move to a supported LTS, which of those pins have to change, in what order, and what is likely to ` +
602
666
  `break at that boundary. Make the pin changes that are mechanical; do NOT attempt the image rebuild itself.`,
603
667
  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.`,
@@ -1205,6 +1269,7 @@ const BOOK: readonly Chore[] = [
1205
1269
  dependencies,
1206
1270
  deadCode,
1207
1271
  complexity,
1272
+ testStrength,
1208
1273
  bundleWeight,
1209
1274
  frameworkIdiom,
1210
1275
  documentation,
@@ -179,6 +179,71 @@ describe(`jscpd`, () => {
179
179
  });
180
180
  });
181
181
 
182
+ /* The mutation report is the cross-tool "mutation testing report schema", and the whole risk in this parser is
183
+ * the arithmetic rather than the shape: two of the eight statuses land on the counter-intuitive side, and getting
184
+ * either one backwards produces a number that looks plausible and is wrong. So the statuses are tested by name. */
185
+ describe(`mutation`, () => {
186
+ const report = (...mutants: readonly Record<string, unknown>[]) =>
187
+ JSON.stringify({
188
+ schemaVersion: `2.0`,
189
+ files: { "src/digest.ts": { language: `typescript`, source: `…`, mutants } },
190
+ });
191
+ const mutant = (status: string, line = 1) => ({
192
+ id: `${status}-${line}`,
193
+ mutatorName: `ConditionalExpression`,
194
+ replacement: `false`,
195
+ location: { start: { line, column: 1 }, end: { line, column: 9 } },
196
+ status,
197
+ });
198
+
199
+ test(`counts Killed and Timeout as caught, Survived and NoCoverage as missed`, () => {
200
+ // Stryker's own arithmetic, restated as a test because both halves read backwards at a glance: a mutant
201
+ // that HANGS the suite was noticed by it, and a mutant nothing ran cannot have been.
202
+ const facts = parse(`mutation`, report(mutant(`Killed`), mutant(`Timeout`), mutant(`Survived`), mutant(`NoCoverage`)));
203
+ expect(facts).toMatchObject({ id: `mutation`, mutation: { killed: 2, survived: 2, score: 50 } });
204
+ });
205
+
206
+ test(`leaves mutants that never got a verdict out of the score entirely`, () => {
207
+ // One caught, one missed, and three with no answer: the score is 50%, not 20% and not 80%. Folding the
208
+ // undecided into either column is the mistake this pins.
209
+ const facts = parse(`mutation`, report(mutant(`Killed`), mutant(`Survived`), mutant(`CompileError`), mutant(`RuntimeError`), mutant(`Ignored`)));
210
+ expect(facts).toMatchObject({ id: `mutation`, mutation: { killed: 1, survived: 1, inconclusive: 3, score: 50 } });
211
+ });
212
+
213
+ test(`names each survivor with the change that went unnoticed`, () => {
214
+ const facts = parse(`mutation`, report(mutant(`Survived`, 43)));
215
+ expect(facts).toMatchObject({
216
+ mutation: { survivors: [{ file: `src/digest.ts`, line: 43, mutator: `ConditionalExpression`, replacement: `false` }] },
217
+ });
218
+ });
219
+
220
+ // A mutator that DELETES an expression reports an empty replacement, which would otherwise render as a blank
221
+ // in the panel and read as a missing field rather than as the removal it is.
222
+ test(`renders a deleted expression as a removal rather than as nothing`, () => {
223
+ const facts = parse(`mutation`, report({ ...mutant(`Survived`), replacement: `` }));
224
+ expect(facts).toMatchObject({ mutation: { survivors: [{ replacement: `(removed)` }] } });
225
+ });
226
+
227
+ test(`a run with nothing to mutate is 100%, not a division by zero`, () => {
228
+ expect(parse(`mutation`, JSON.stringify({ schemaVersion: `2.0`, files: {} }))).toMatchObject({
229
+ mutation: { score: 100, killed: 0, survived: 0 },
230
+ });
231
+ });
232
+
233
+ // The distinction the runner depends on: an empty `files` map is a real measurement, a MISSING one is output
234
+ // this parser did not understand, and reporting the second as a clean 100% is the one answer it must never give.
235
+ test(`output without a files map is a failure, not a clean repository`, () => {
236
+ expect(parse(`mutation`, JSON.stringify({ schemaVersion: `2.0` }))).toBeUndefined();
237
+ expect(parse(`mutation`, ``)).toBeUndefined();
238
+ expect(parse(`mutation`, JSON.stringify({ files: [] }))).toBeUndefined();
239
+ });
240
+
241
+ test(`survives a malformed mutant without losing the rest of the file's numbers`, () => {
242
+ const facts = parse(`mutation`, report(mutant(`Killed`), { nonsense: true }, mutant(`Survived`)));
243
+ expect(facts).toMatchObject({ mutation: { killed: 1, survived: 1, inconclusive: 1 } });
244
+ });
245
+ });
246
+
182
247
  /* The UI sweep is the one probe whose output we produce ourselves, which removes the "their JSON moved" failure
183
248
  * and replaces it with a worse one: a command of eleven piped ripgreps in which any single stage can silently
184
249
  * contribute nothing. The marker line is what tells those two apart, and most of what is below is about it. */
@@ -1,4 +1,4 @@
1
- import type { Advisory, Bundle, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas/maintenance.js";
1
+ import type { Advisory, Bundle, DeadCode, Duplication, MutationScore, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas/maintenance.js";
2
2
  import type { IdiomRule } from "./stack.js";
3
3
  import {
4
4
  BYPASS_PATTERN,
@@ -227,6 +227,103 @@ const parseJscpd = (stdout: string): ProbeFacts | undefined => {
227
227
  return { id: `jscpd`, duplication };
228
228
  };
229
229
 
230
+ /* WHAT THE SUITE WOULD NOTICE IF THE CODE BROKE, from Stryker's report.
231
+ *
232
+ * The report is the cross-tool "mutation testing report schema": `files` maps a path to `{ mutants: [{ mutatorName,
233
+ * replacement, location.start.line, status }] }`. Statuses are Killed, Survived, NoCoverage, Timeout, CompileError,
234
+ * RuntimeError, Ignored, Pending.
235
+ *
236
+ * THE ARITHMETIC IS STRYKER'S, NOT OURS, and that is a deliberate constraint rather than laziness. A timeout counts
237
+ * as DETECTED (a mutant that hangs the suite is one the suite noticed) and NoCoverage counts as UNDETECTED (nothing
238
+ * ran, so nothing could have caught it). Both are counter-intuitive enough to be worth restating here, and both are
239
+ * what the tool's own score means — a row that recomputed them differently would be quietly arguing with the
240
+ * evidence printed underneath it.
241
+ *
242
+ * `Pending` is treated as inconclusive rather than dropped: an interrupted incremental run leaves them, and
243
+ * counting them either way would move the score for a reason that has nothing to do with the tests. */
244
+ const SURVIVOR_SAMPLE = 8;
245
+ const DETECTED = new Set([`Killed`, `Timeout`]);
246
+ const UNDETECTED = new Set([`Survived`, `NoCoverage`]);
247
+
248
+ type Survivor = MutationScore["survivors"][number];
249
+
250
+ // One file's mutant list, or nothing. Every shape this cannot recognise reads as "no mutants here" rather than
251
+ // throwing: a report with one malformed entry is still worth the rest of its numbers.
252
+ const mutantsOf = (raw: unknown): readonly Record<string, unknown>[] => {
253
+ if (typeof raw !== `object` || raw === null) {
254
+ return [];
255
+ }
256
+ const mutants = (raw as Record<string, unknown>)[`mutants`];
257
+ return Array.isArray(mutants) ? mutants.filter((entry): entry is Record<string, unknown> => typeof entry === `object` && entry !== null) : [];
258
+ };
259
+
260
+ const lineOf = (mutant: Record<string, unknown>): number => {
261
+ const location = mutant[`location`];
262
+ const start = typeof location === `object` && location !== null ? (location as Record<string, unknown>)[`start`] : undefined;
263
+ const line = typeof start === `object` && start !== null ? (start as Record<string, unknown>)[`line`] : undefined;
264
+ return typeof line === `number` ? line : 0;
265
+ };
266
+
267
+ const survivorOf = (file: string, mutant: Record<string, unknown>): Survivor => ({
268
+ file,
269
+ line: lineOf(mutant),
270
+ mutator: asString(mutant[`mutatorName`]) ?? `?`,
271
+ // An empty replacement is meaningful — the mutator deleted the expression — so it is rendered as the removal
272
+ // it is rather than left blank for a reader to puzzle over.
273
+ replacement: asString(mutant[`replacement`]) ?? `(removed)`,
274
+ });
275
+
276
+ interface Tally {
277
+ killed: number;
278
+ survived: number;
279
+ inconclusive: number;
280
+ readonly survivors: Survivor[];
281
+ }
282
+
283
+ const tallyMutants = (files: Record<string, unknown>): Tally => {
284
+ const tally: Tally = { killed: 0, survived: 0, inconclusive: 0, survivors: [] };
285
+ for (const [file, raw] of Object.entries(files)) {
286
+ for (const mutant of mutantsOf(raw)) {
287
+ const status = asString(mutant[`status`]) ?? ``;
288
+ if (DETECTED.has(status)) {
289
+ tally.killed++;
290
+ continue;
291
+ }
292
+ // Anything that is neither detected nor undetected never got a verdict — it would not compile, or the
293
+ // configuration ignored it. Counting it either way would move the score for a reason that has nothing
294
+ // to do with the tests.
295
+ if (!UNDETECTED.has(status)) {
296
+ tally.inconclusive++;
297
+ continue;
298
+ }
299
+ tally.survived++;
300
+ tally.survivors.push(survivorOf(file, mutant));
301
+ }
302
+ }
303
+ return tally;
304
+ };
305
+
306
+ const parseMutation = (stdout: string): ProbeFacts | undefined => {
307
+ const files = asObject(stdout)?.[`files`];
308
+ // No `files` key at all means this is not a mutation report. An EMPTY one is a real answer (nothing was
309
+ // mutated) and a missing one is a parse failure, and the runner has to be able to tell them apart.
310
+ if (typeof files !== `object` || files === null || Array.isArray(files)) {
311
+ return undefined;
312
+ }
313
+ const tally = tallyMutants(files as Record<string, unknown>);
314
+ const valid = tally.killed + tally.survived;
315
+ const mutation: MutationScore = {
316
+ // Nothing to mutate scores 100 rather than NaN: there is no undetected fault in it, which is the honest
317
+ // reading and the one that keeps the chore quiet instead of dividing by zero.
318
+ score: valid === 0 ? 100 : Math.round((tally.killed / valid) * 100),
319
+ killed: tally.killed,
320
+ survived: tally.survived,
321
+ inconclusive: tally.inconclusive,
322
+ survivors: tally.survivors.slice(0, SURVIVOR_SAMPLE),
323
+ };
324
+ return { id: `mutation`, mutation };
325
+ };
326
+
230
327
  /* THE UI SWEEP. The only probe here whose command is COMPOSED rather than written out, because its subject is a
231
328
  * table (stack.ts) that will grow and a hand-written command would be a second copy of it going stale.
232
329
  *
@@ -370,6 +467,9 @@ const parseBundle = (stdout: string): ProbeFacts | undefined => {
370
467
  // immediately after, never something to keep, the cached ProbeResult is the artefact that survives. The same
371
468
  // path the scheduled form of this chore uses (chores.ts), so a workspace running both keeps one copy.
372
469
  const JSCPD_DIR = `/tmp/intentic-chore-jscpd`;
470
+ // Where Stryker's json reporter writes, which is its own default and not configurable per run on the CLI. Read
471
+ // from the repo directory the probe runs in, like jscpd's report above.
472
+ const MUTATION_REPORT = `reports/mutation/mutation.json`;
373
473
 
374
474
  export const PROBES: readonly ProbeSpec[] = [
375
475
  {
@@ -430,6 +530,38 @@ export const PROBES: readonly ProbeSpec[] = [
430
530
  `cat ${JSCPD_DIR}/jscpd-report.json 2>/dev/null`,
431
531
  parse: parseJscpd,
432
532
  },
533
+ {
534
+ id: `mutation`,
535
+ title: `Test strength`,
536
+ measures: `how much of the code could break with every test still green`,
537
+ /* THE MOST EXPENSIVE PROBE HERE, and the tier does not really capture it: this runs the suite once per
538
+ * injected fault. `--incremental` is what makes it liveable — Stryker keeps a result file and re-runs only
539
+ * the mutants whose code or tests changed, so the first run costs what a full run costs and every one
540
+ * after it costs the diff. The monthly TTL is set against the FIRST run, not the incremental ones.
541
+ *
542
+ * Opt-in per repository, by the presence of a `stryker.conf.json`, and that is the load-bearing part of
543
+ * the design rather than a convenience. This monorepo has 65 packages with their own vitest configs and no
544
+ * root suite; mutating all of them unasked would be hours of CPU nobody agreed to spend. A repo says which
545
+ * code is worth the measurement by writing that file, which is also where `mutate` narrows the scope. */
546
+ tier: 2,
547
+ ttlMs: 30 * DAY_MS,
548
+ timeoutMs: 90 * 60_000,
549
+ /* Two conditions, and the config is the one that matters: Stryker being installed says the tool COULD run,
550
+ * the config says somebody decided it should. Without the second this would report "unavailable" on every
551
+ * repo that merely shares this monorepo's lockfile.
552
+ *
553
+ * The repo's OWN Stryker, never a floating one, for a reason measured rather than assumed: under
554
+ * `pnpm dlx` the plugin loader resolves from the working directory and finds nothing — "Cannot find
555
+ * TestRunner plugin \"vitest\". In fact, no TestRunner plugins were loaded" — and the runner has to agree
556
+ * with the vitest the repo actually runs. Same argument as knip above. */
557
+ available: `{ test -f stryker.conf.mjs || test -f stryker.conf.json; } && pnpm exec stryker --version >/dev/null 2>&1`,
558
+ unavailable: `no stryker config in this repo`,
559
+ // `|| true` for the same reason the others carry it: Stryker exits non-zero when the score is under its
560
+ // own threshold, which is an opinion about the number, not a failure to produce one. The judgement is the
561
+ // chore's, made from the score, and the runner decides by whether the parser recognised the report.
562
+ command: `pnpm exec stryker run --reporters json --incremental >/dev/null 2>&1 || true; cat ${MUTATION_REPORT} 2>/dev/null`,
563
+ parse: parseMutation,
564
+ },
433
565
  {
434
566
  id: `ui`,
435
567
  title: `Front-end source`,