@hecer/yoke 1.22.0 → 1.23.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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -1
  5. package/TODOS.md +6 -0
  6. package/canon/manifest.yaml +1 -1
  7. package/canon/skills/visual-verification/SKILL.md +25 -2
  8. package/canon/tools/codex-rtk-hook.mjs +6 -16
  9. package/dist/cli.js +79 -0
  10. package/dist/code-intelligence/adapters/mcp.js +1 -0
  11. package/dist/code-intelligence/coordinator.js +3 -1
  12. package/dist/code-intelligence/index.js +1 -0
  13. package/dist/code-intelligence/mcp-client.js +15 -4
  14. package/dist/code-intelligence/mcp-server.js +4 -1
  15. package/dist/code-intelligence/preflight.js +71 -0
  16. package/dist/loop/cache-isolation.js +36 -0
  17. package/dist/loop/loop.js +24 -4
  18. package/dist/loop/parallel-adapters.js +35 -2
  19. package/dist/loop/parallel-command.js +17 -2
  20. package/dist/loop/proof-retention.js +70 -0
  21. package/dist/loop/reporter.js +1 -1
  22. package/dist/loop/run-command.js +6 -1
  23. package/dist/loop/runner.js +1 -1
  24. package/dist/loop/worker.js +7 -0
  25. package/dist/observability/history.js +1 -0
  26. package/dist/observability/local-report.js +120 -0
  27. package/dist/observability/usage.js +2 -0
  28. package/dist/retrofit/config.js +3 -1
  29. package/dist/retrofit/gitignore.js +10 -0
  30. package/dist/retrofit/planners/codex.js +20 -20
  31. package/dist/routing/router.js +2 -0
  32. package/dist/smoke/command.js +87 -13
  33. package/dist/update/check.js +1 -1
  34. package/docs/DELIVERY-JOURNEYS.md +8 -1
  35. package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
  36. package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
  37. package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
  38. package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
  39. package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
  40. package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
  41. package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
  42. package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
  43. package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
  44. package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
  45. package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
  46. package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
  47. package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
  48. package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
  49. package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
  50. package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
  51. package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
  52. package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
  53. package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
  54. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
  55. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
  56. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
  57. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
  58. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
  59. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
  60. package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
  61. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
  62. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
  63. package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
  64. package/gemini-extension.json +1 -1
  65. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "yoke",
4
4
  "displayName": "Yoke",
5
- "version": "1.22.0",
5
+ "version": "1.23.0",
6
6
  "description": "Cross-agent coding harness for Claude, Codex, Gemini, Qwen, OpenCode, Kilo, Pi and Hermes: one curated skill canon plus mechanical safety gates and an autonomous loop via the yoke CLI.",
7
7
  "author": { "name": "HECer", "url": "https://github.com/HECer" },
8
8
  "homepage": "https://github.com/HECer/yoke#readme",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yoke",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Cross-agent coding discipline, mechanical gates, and release workflows for eight supported harnesses",
5
5
  "skills": "./canon/skills/",
6
6
  "hooks": "./hooks/hooks.json"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.23.0 — 2026-10-04
4
+
5
+ ### Added
6
+ - Add read-only `yoke tools-preflight` and bounded local `yoke usage` reports. Separate configured tools from available capabilities, recorded usage from unknown coverage, and overlapping worker time from summed process durations.
7
+ - Retain immutable hashed runtime proofs before isolated candidate cleanup, with source, configuration and environment bindings. Preserve candidates when transfer or validation fails.
8
+ - Add optional `smoke.sourceIdentity: { path, sha256 }` configuration; production browser smoke now requires it and verifies served bytes before launch and after journeys.
9
+
10
+ ### Fixed
11
+ - Make setup/retrofit help read-only and reject unknown or contradictory mutating flags before dispatch while preserving recovery options.
12
+ - Ignore supervision runtime files, diagnose already tracked copies, render actual parallel workers and integration states, and stop reporting success merely when an integrator becomes idle.
13
+ - Preserve redacted browser launch causes instead of reporting every failure as a missing package. Keep OAuth, authorization and configured form secrets out of diagnostics.
14
+ - Migrate Yoke-owned Codex RTK hooks to the native protocol without deleting foreign hooks; honor configured Code Intelligence workspace identifiers and await MCP shutdown before temporary directory cleanup.
15
+ - Validate actual candidate writes and sibling collisions before and after integration gates. Reject shared writable worktree runtime caches and completion commands that change source or final assets.
16
+ - Preserve partial/missing usage markers through routing and include recorded serial implementation durations without counting parent/child usage twice.
17
+
18
+ ### Migration and validation limits
19
+ - Existing browser smoke projects must pin an app-specific served source path and its lowercase SHA-256. Update the pin intentionally after source changes; a static byte pin establishes response identity, not an independent deployment attestation.
20
+ - Shared package download stores remain allowed. Worktrees need their own `node_modules` root and writable `.vite-temp`, `.vite` and `.cache` directories. The guard covers these conventional roots, not arbitrary application-defined cache paths or concurrent changes after inspection.
21
+ - No dependency installer or retry policy was added: Yoke does not own one. Full-product model speed/cost and genuine npm/provider cold/warm cache comparisons remain unmeasured. The paired help experiment is a local regression check only.
22
+ - See the [analysis](docs/benchmarks/2026-10-04-efficiency/ANALYSE.md) and [validation record](docs/RELEASE-VALIDATION-1.23.0.md). Version metadata is prepared locally; publication requires independent review and release gates, followed by verified CI/npm evidence.
23
+
3
24
  ## 1.22.0 — 2026-10-03
4
25
 
5
26
  ### Added
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
12
12
  ![Node.js 20+](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
13
13
 
14
- <!-- yoke:version:start -->1.22.0<!-- yoke:version:end --> · <!-- yoke:tests:start -->1610<!-- yoke:tests:end --> test cases · <!-- yoke:skills:start -->34<!-- yoke:skills:end --> skills
14
+ <!-- yoke:version:start -->1.23.0<!-- yoke:version:end --> · <!-- yoke:tests:start -->1697<!-- yoke:tests:end --> test cases · <!-- yoke:skills:start -->34<!-- yoke:skills:end --> skills
15
15
 
16
16
  <!-- yoke:agents:start -->Claude | Codex | Gemini | Qwen | OpenCode | Kilo | Pi | Hermes<!-- yoke:agents:end -->
17
17
 
@@ -21,6 +21,8 @@ Yoke turns a goal into acceptance-tested stories, coordinates one or more coding
21
21
 
22
22
  ## How it works
23
23
 
24
+ Version 1.23 adds local `yoke usage [dir] --json` and `yoke tools-preflight [dir] --json` diagnostics. Browser smoke now requires `smoke.sourceIdentity` with an app-specific same-origin path and the lowercase SHA-256 of its served bytes. Pin a stable source response and refresh it deliberately after source changes; see [browser delivery configuration](docs/DELIVERY-JOURNEYS.md). Local version preparation and validation limits are recorded in [1.23 validation](docs/RELEASE-VALIDATION-1.23.0.md).
25
+
24
26
  ```mermaid
25
27
  flowchart LR
26
28
  A[Goal and acceptance criteria] --> B[PRD stories and dependencies]
package/TODOS.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Yoke follow-up work
2
2
 
3
+ ## Deferred from the user-authorized 1.23.0 release
4
+
5
+ - E7: establish dependency-installer ownership before enforcing lockfile-keyed install reuse and offline/network failure classification; measure genuine cold/warm setup conditions.
6
+ - E8: emit explicit failure categories at production boundaries where the cause is known; preserve unknown observer/guardian/approval coverage.
7
+ - E9: collect paired full-product benchmarks and rerun the populated NEXUS layout regression with a platform-correct dependency tree. The copied macOS dependencies could not start Vite on Windows. No general speed/cost improvement is established yet.
8
+
3
9
  - Add provider-native output schemas when all three CLIs expose compatible stable APIs.
4
10
  - Expand benchmark fixtures and collect multiple authenticated samples per provider/model.
5
11
  - Add signed provenance and attestations to npm and GitHub releases.
@@ -1,5 +1,5 @@
1
1
  name: yoke-canon
2
- version: 1.22.0
2
+ version: 1.23.0
3
3
  agents: [claude, codex, gemini, qwen, opencode, kilo, pi, hermes]
4
4
  skills:
5
5
  - { id: tdd, path: skills/tdd, kind: methodology, invocation: auto }
@@ -24,6 +24,9 @@ Configure the key user flows once in `.yoke/config.yaml`:
24
24
  ```yaml
25
25
  smoke:
26
26
  baseUrl: http://localhost:3000
27
+ sourceIdentity:
28
+ path: /assets/app-specific-static-source.js
29
+ sha256: "<replace with SHA-256 of the served bytes>"
27
30
  flows:
28
31
  - name: home
29
32
  path: /
@@ -33,7 +36,26 @@ smoke:
33
36
  landmark: "form"
34
37
  ```
35
38
 
36
- With that in place, the `yoke flow-smoke .` step from the section-1 pipeline is live.
39
+ The example contains a required hash placeholder. Before running production smoke, replace
40
+ the resource path with a stable, app-specific source resource and the hash with its actual
41
+ 64-character lowercase SHA-256. The schema allows the field to be absent for existing
42
+ configuration readers; production `flow-smoke` requires it.
43
+
44
+ Start the intended server and confirm its checkout/build and port. Fetch the resource from
45
+ the effective `baseUrl` origin, using the exact body bytes returned by the server. Hash those
46
+ bytes, not a local source file that a dev server may transform. The resource must return a
47
+ successful HTTP response without redirects, finish within five seconds and contain at most
48
+ 1 MiB. Keep it stable for the whole smoke run. When `--url` changes the origin, verify that
49
+ origin serves the intended build before deriving the pin.
50
+
51
+ Refresh the expected hash explicitly after an intentional source/build change and after
52
+ confirming the server serves that change. A mismatch must fail the gate; do not automatically
53
+ overwrite the expected hash with whatever a port happens to serve. A shared health response
54
+ or unchanged marker cannot identify changed app code. This static pin proves only that the
55
+ selected resource matches before and after the run; retain the checkout fingerprint and
56
+ acceptance evidence for broader source binding.
57
+
58
+ With a real pin in place, the `yoke flow-smoke .` step from the section-1 pipeline is live.
37
59
  `yoke flow-smoke` loads each route against the running dev server, waits for the landmark,
38
60
  fails on any console error, and **always** saves a screenshot to `.yoke/proof/<story>/`
39
61
  (the loop labels the folder with the current story id via `YOKE_STORY`; standalone runs use
@@ -50,5 +72,6 @@ already handles the failure case.
50
72
 
51
73
  ## Rule
52
74
 
53
- Green pipeline = types + units + no design-slop over budget + every flow renders without
75
+ Green pipeline = types + units + no design-slop over budget + served-source identity matches
76
+ before and after the run + every flow renders without
54
77
  console errors, with a screenshot to prove it. Only then is the story actually done.
@@ -2,24 +2,14 @@ import { spawnSync } from 'node:child_process'
2
2
  import { resolve } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
 
5
- function rtkCheck(command) {
6
- const result = spawnSync('rtk', ['hook', 'check', command], { encoding: 'utf8', timeout: 3000 })
7
- return result.status === 0 ? result.stdout.trim() : ''
5
+ function nativeHook(input) {
6
+ const result = spawnSync('rtk', ['hook', 'codex'], { input: JSON.stringify(input), encoding: 'utf8', timeout: 3000 })
7
+ return result.status === 0 && result.stdout.trim() ? JSON.parse(result.stdout) : null
8
8
  }
9
9
 
10
- export function rewriteHookInput(input, check = rtkCheck) {
11
- if (input?.tool_name !== 'Bash' && input?.toolName !== 'Bash') return null
12
- const toolInput = input.tool_input ?? input.toolInput
13
- const command = toolInput?.command
14
- if (typeof command !== 'string' || command.trim() === '') return null
15
- const rewritten = check(command)
16
- if (!rewritten || rewritten === command) return null
17
- return {
18
- hookSpecificOutput: {
19
- hookEventName: 'PreToolUse',
20
- updatedInput: { ...toolInput, command: rewritten },
21
- },
22
- }
10
+ export function rewriteHookInput(input, processHook = nativeHook) {
11
+ // Native RTK owns Codex schema, permission-mode handling and fail-open rules.
12
+ try { return processHook(input) ?? null } catch { return null }
23
13
  }
24
14
 
25
15
  async function main() {
package/dist/cli.js CHANGED
@@ -26,6 +26,8 @@ import { printAudit, runAudit } from './audit/command.js';
26
26
  import { runSetup } from './setup/command.js';
27
27
  import { runCodeIntelligenceServer } from './code-intelligence/mcp-server.js';
28
28
  import { pendingChanges, queueChange } from './change/inbox.js';
29
+ import { localUsageReport } from './observability/local-report.js';
30
+ import { runToolPreflight } from './code-intelligence/preflight.js';
29
31
  import { answerPendingDecision, answeredDecisionResumeIsValid, clearDecisionResume, decisionProcessingExists, decisionResumeMatchesCurrent, finalizeCommittedDecisionResume, formatPendingDecision, readDecisionResume, readPendingDecision, writeDecisionResume, } from './loop/decision.js';
30
32
  export { runRetrofit } from './retrofit/command.js';
31
33
  export function runValidate(canonDir) {
@@ -149,7 +151,83 @@ function parseExploreLimitFlag(args, explore) {
149
151
  }
150
152
  export function main(argv) {
151
153
  const [cmd, ...rest] = argv;
154
+ // Help must exit before any command can write configuration or start a process.
155
+ if (argv.includes('--help') || argv.includes('-h'))
156
+ return main([]);
157
+ const mutationFlags = {
158
+ setup: ['--yes', '--host=', '--agent=', '--runner=', '--code-graph=', '--code-intelligence=', '--decision-policy=', '--loop', '--no-loop', '--routing', '--no-routing', '--routing-strategy=', '--model-provider=', '--runner-model=', '--runner-reasoning=', '--model=', '--reasoning=', '--clean-worktrees', '--configure-models', '--routing-preset'],
159
+ retrofit: ['--loop', '--agent=', '--code-graph=', '--code-intelligence=', '--clean-worktrees', '--runner-model=', '--runner-reasoning=', '--model=', '--reasoning='],
160
+ context: [],
161
+ projects: [],
162
+ worktrees: ['--all', '--force'],
163
+ dashboard: ['--port=', '--no-register'],
164
+ goal: ['--objective=', '--attempts=', '--minutes=', '--wall-minutes=', '--tokens=', '--criteria=', '--clear-token-budget', '--runner=', '--model=', '--effort=', '--bare', '--native-goal', '--no-native-goal'],
165
+ check: ['--json', '--protect', '--refresh', '--requirement='],
166
+ change: ['--idea='],
167
+ loop: ['--compact', '--remove-worktrees', '--discard-stale-recovery', '--discard', '--explore', '--explore-interval=', '--explore-limit=', '--quality', '--no-quality', '--quality-rounds=', '--quality-minutes=', '--quality-policy=', '--quality-unbounded', '--candidates=', '--choice=', '--rationale=', '--no-resume', '--max=', '--runner=', '--reviewer=', '--review', '--allow-self-review', '--routing', '--no-routing', '--isolate', '--no-isolate', '--unsafe', '--timeout=', '--decision-policy=', '--parallel=', '--json', '--on-ambiguity=', '--resume-worktree'],
168
+ new: ['--idea=', '--agent=', '--runner=', '--loop'],
169
+ prd: ['--idea=', '--runner=', '--story=', '--apply', '--reassess', '--force', '--timeout='],
170
+ review: ['--reviewer=', '--base=', '--focus=', '--allow-self-review', '--json', '--timeout='],
171
+ audit: ['--json'],
172
+ 'flow-smoke': ['--url=', '--label='],
173
+ 'design-scan': ['--report', '--max='],
174
+ usage: ['--json', '--from=', '--to=', '--run='],
175
+ 'tools-preflight': ['--json'],
176
+ };
177
+ const accepted = mutationFlags[cmd];
178
+ if (accepted) {
179
+ const unknown = rest.find(value => value.startsWith('-') && !accepted.some(flag => flag.endsWith('=') ? value.startsWith(flag) && value.length > flag.length : value === flag));
180
+ if (unknown) {
181
+ console.error(`Unknown or incomplete ${cmd} flag: ${unknown}`);
182
+ return 1;
183
+ }
184
+ for (const toggle of ['loop', 'routing', 'isolate', 'native-goal', 'quality']) {
185
+ if (rest.includes(`--${toggle}`) && rest.includes(`--no-${toggle}`)) {
186
+ console.error(`Cannot use --${toggle} with --no-${toggle}.`);
187
+ return 1;
188
+ }
189
+ }
190
+ }
152
191
  switch (cmd) {
192
+ case 'tools-preflight': {
193
+ return runToolPreflight(rest.find(arg => !arg.startsWith('-')) ?? '.').then(report => {
194
+ if (rest.includes('--json'))
195
+ console.log(JSON.stringify(report));
196
+ else {
197
+ console.log(`RTK: ${report.rtk.status}; native rewrite=${report.rtk.nativeRewrite}; nested code mode=${report.rtk.nestedCodeMode}`);
198
+ console.log(`Code intelligence: ${report.codeIntelligence.status}; mode=${report.codeIntelligence.mode}; workspace=${report.codeIntelligence.workspaceId}`);
199
+ console.log(` ${report.rtk.fallback}\n ${report.codeIntelligence.fallback}; index freshness=${report.codeIntelligence.indexFreshness}`);
200
+ }
201
+ return 0;
202
+ }).catch(error => { console.error(`Tool preflight unavailable: ${error.message}`); return 2; });
203
+ }
204
+ case 'usage': {
205
+ const value = (name) => rest.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
206
+ const to = value('to') === undefined ? Date.now() : Date.parse(value('to'));
207
+ const from = value('from') === undefined ? to - 30 * 86400000 : Date.parse(value('from'));
208
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to || to - from > 366 * 86400000) {
209
+ console.error('Invalid usage time range: choose --from/--to dates spanning at most 366 days.');
210
+ return 2;
211
+ }
212
+ try {
213
+ const report = localUsageReport(rest.find(arg => !arg.startsWith('-')) ?? '.', { from, to, runId: value('run') });
214
+ if (rest.includes('--json'))
215
+ console.log(JSON.stringify(report));
216
+ else {
217
+ console.log(`Usage ${report.from} to ${report.to}: input=${report.total.inputTokens ?? 'unknown'} cache=${report.total.cachedInputTokens ?? 'unknown'} output=${report.total.outputTokens ?? 'unknown'} cost=${report.total.totalCostUsd ?? 'unknown'} (${report.total.coverage}; cost ${report.total.costCoverage})`);
218
+ console.log(`Guardian=${report.hostCoverage.guardian} approval=${report.hostCoverage.approval}; phase sum=${report.time.phaseDurationSumMs ?? 'unknown'}ms union=${report.time.phaseDurationUnionMs ?? 'unknown'}ms`);
219
+ for (const limit of report.limitations)
220
+ console.log(` ${limit}`);
221
+ for (const error of report.errors)
222
+ console.error(`Usage history: ${error}`);
223
+ }
224
+ return report.errors.length ? 2 : 0;
225
+ }
226
+ catch (error) {
227
+ console.error(`Usage report unavailable: ${error.message}`);
228
+ return 2;
229
+ }
230
+ }
153
231
  case 'code-intelligence-server':
154
232
  return runCodeIntelligenceServer(rest).then(() => 0).catch(error => { console.error(`Code intelligence server: ${error.message}`); return 2; });
155
233
  case 'setup': {
@@ -841,6 +919,7 @@ export function main(argv) {
841
919
  case 'upgrade':
842
920
  return runUpgrade();
843
921
  default:
922
+ console.log('Local diagnostics: yoke tools-preflight [dir] [--json] | usage [dir] [--from=ISO] [--to=ISO] [--run=id] [--json]');
844
923
  console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|bind|assess|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
845
924
  console.log(`usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=${AGENT_LIST}|all] [--code-graph=graphify|serena] [--code-intelligence=off|shadow|active] [--clean-worktrees] [--runner-model=<model>] [--runner-reasoning=<effort>] [--model=<agent>:<model>] [--reasoning=<agent>:<effort>] [--loop] | worktrees <list|prune> [dir] [--all] [--force] | change <add|status> [dir] | code-intelligence-server --workspace=<dir> --mode=<mode> | prd <draft|check|assess|decompose> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] | design-scan [dir] | flow-smoke [dir] | upgrade>`);
846
925
  return cmd ? 1 : 0;
@@ -54,5 +54,6 @@ export class McpBackendAdapter {
54
54
  const tool = this.aliases[request.tool] ?? request.tool;
55
55
  return valueFromResult(await this.client.call(tool, request.arguments, timeoutMs));
56
56
  }
57
+ probe(timeoutMs) { return this.client.listTools(timeoutMs); }
57
58
  close() { return this.client.close(); }
58
59
  }
@@ -53,7 +53,7 @@ export class CodeIntelligenceCoordinator {
53
53
  adapters;
54
54
  constructor(root, options = {}) {
55
55
  this.root = root;
56
- this.workspace_id = workspaceId(root);
56
+ this.workspace_id = options.workspaceId ?? workspaceId(root);
57
57
  this.options = options;
58
58
  this.adapters = {
59
59
  graft: options.adapters?.graft ?? createGraftAdapter(root, options.graft),
@@ -141,6 +141,8 @@ export class CodeIntelligenceCoordinator {
141
141
  if (candidate.workspace_id && candidate.workspace_id !== this.workspace_id)
142
142
  throw new Error('workspace is not permitted');
143
143
  const snapshot = candidate.snapshot_id ? loadSnapshot(this.root, candidate.snapshot_id) : createSnapshot(this.root, this.options.policy, this.workspace_id);
144
+ if (snapshot.workspace_id !== this.workspace_id)
145
+ throw new Error('snapshot workspace identifier differs from the configured workspace; create a new snapshot');
144
146
  if (candidate.snapshot_id)
145
147
  assertSnapshotCurrent(this.root, snapshot, this.options.policy);
146
148
  return snapshot;
@@ -3,3 +3,4 @@ export * from './coordinator.js';
3
3
  export * from './edit-plans.js';
4
4
  export * from './snapshots.js';
5
5
  export * from './transactions.js';
6
+ export * from './preflight.js';
@@ -12,6 +12,7 @@ export class McpStdioClient {
12
12
  pending = new Map();
13
13
  nextId = 1;
14
14
  stderrBytes = 0;
15
+ closing;
15
16
  constructor(command, args, cwd, framing = 'content-length', maxBytes = 2_000_000) {
16
17
  this.command = command;
17
18
  this.args = args;
@@ -29,9 +30,17 @@ export class McpStdioClient {
29
30
  }
30
31
  return this.request('tools/call', { name: tool, arguments: arguments_ }, remaining);
31
32
  }
33
+ async listTools(timeoutMs) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ await this.start(timeoutMs);
36
+ const result = await this.request('tools/list', {}, deadline - Date.now());
37
+ if (!Array.isArray(result.tools))
38
+ throw new Error('MCP backend did not advertise tools');
39
+ return result.tools.map(tool => tool.name).filter((name) => typeof name === 'string');
40
+ }
32
41
  async close() {
33
42
  if (!this.child)
34
- return;
43
+ return this.closing;
35
44
  const child = this.child;
36
45
  this.child = undefined;
37
46
  for (const pending of this.pending.values()) {
@@ -39,12 +48,14 @@ export class McpStdioClient {
39
48
  pending.reject(new Error('MCP process closed'));
40
49
  }
41
50
  this.pending.clear();
42
- child.kill('SIGTERM');
43
- await new Promise(resolve => {
51
+ this.closing = new Promise(resolve => {
44
52
  const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
45
- child.kill('SIGKILL'); resolve(); }, 500);
53
+ child.kill('SIGKILL'); }, 500);
46
54
  child.once('close', () => { clearTimeout(timer); resolve(); });
47
55
  });
56
+ child.kill('SIGTERM');
57
+ await this.closing;
58
+ this.closing = undefined;
48
59
  }
49
60
  async start(timeoutMs) {
50
61
  if (this.child)
@@ -2,6 +2,7 @@
2
2
  import { CodeIntelligenceCoordinator } from './coordinator.js';
3
3
  import { TOOL_DEFINITIONS } from './contracts.js';
4
4
  import { loadConfig } from '../retrofit/config.js';
5
+ import { workspaceId } from './snapshots.js';
5
6
  const INPUT_SCHEMAS = {
6
7
  code_context: { type: 'object', required: ['workspace_id', 'query'], properties: { workspace_id: { type: 'string' }, query: { type: 'string' }, snapshot_id: { type: 'string' }, mode: { type: 'string', enum: ['orient', 'locate', 'explain'] }, paths: { type: 'array', items: { type: 'string' } }, include_docs: { type: 'boolean' }, token_budget: { type: 'integer' }, timeout_ms: { type: 'integer' } } },
7
8
  code_symbol: { type: 'object', required: ['workspace_id', 'snapshot_id'], properties: { workspace_id: { type: 'string' }, snapshot_id: { type: 'string' }, symbol_id: { type: 'string' }, query: { type: 'string' }, path: { type: 'string' }, include: { type: 'array' }, token_budget: { type: 'integer', minimum: 128, maximum: 16000 }, timeout_ms: { type: 'integer', minimum: 100, maximum: 60000 } } },
@@ -16,8 +17,10 @@ export async function runCodeIntelligenceServer(argv = process.argv.slice(2), io
16
17
  const workspace = arg('workspace', argv) ?? process.cwd();
17
18
  const configured = loadConfig(workspace)?.codeIntelligence;
18
19
  const mode = arg('mode', argv) ?? configured?.mode ?? 'active';
20
+ const workspaceIdentifier = configured?.workspaceId ?? workspaceId(workspace);
19
21
  const coordinator = new CodeIntelligenceCoordinator(workspace, {
20
22
  mode,
23
+ workspaceId: workspaceIdentifier,
21
24
  policy: { excludePatterns: configured?.policy?.excludePatterns, maxFileBytes: configured?.policy?.maxFileBytes },
22
25
  limits: configured?.limits,
23
26
  graft: configured?.graft, graphify: configured?.graphify, serena: configured?.serena,
@@ -39,7 +42,7 @@ export async function runCodeIntelligenceServer(argv = process.argv.slice(2), io
39
42
  return;
40
43
  }
41
44
  if (message.method === 'tools/list') {
42
- send({ jsonrpc: '2.0', id: message.id, result: { tools: TOOL_DEFINITIONS.map(tool => ({ ...tool, inputSchema: INPUT_SCHEMAS[tool.name] })) } });
45
+ send({ jsonrpc: '2.0', id: message.id, result: { tools: TOOL_DEFINITIONS.map(tool => ({ ...tool, inputSchema: { ...INPUT_SCHEMAS[tool.name], properties: { ...INPUT_SCHEMAS[tool.name].properties, workspace_id: { type: 'string', const: workspaceIdentifier, description: 'Accepted identifier for this configured workspace.' } } } })) } });
43
46
  return;
44
47
  }
45
48
  if (message.method === 'tools/call') {
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { loadConfig } from '../retrofit/config.js';
6
+ import { workspaceId } from './snapshots.js';
7
+ import { createGraftAdapter, createGraphifyAdapter, createSerenaAdapter } from './adapters/index.js';
8
+ /** Probe protocol capabilities in an empty disposable cwd, never create a target snapshot.
9
+ * Configured commands are arbitrary code and cannot be promised read-only: leave them unprobed. */
10
+ export async function runToolPreflight(root, options = {}) {
11
+ const configured = loadConfig(root)?.codeIntelligence;
12
+ const timeout = Math.min(10000, Math.max(100, options.timeoutMs ?? 2000));
13
+ const version = spawnSync('rtk', ['--version'], { encoding: 'utf8', timeout });
14
+ const native = spawnSync('rtk', ['hook', 'codex'], {
15
+ encoding: 'utf8', timeout,
16
+ input: JSON.stringify({ hook_event_name: 'PreToolUse', permission_mode: 'default', tool_name: 'Bash', tool_input: { command: 'git status' } }),
17
+ });
18
+ let nativeRewrite = false;
19
+ try {
20
+ const result = JSON.parse(native.stdout);
21
+ nativeRewrite = native.status === 0 && result.hookSpecificOutput?.permissionDecision === 'allow' && result.hookSpecificOutput?.updatedInput?.command === 'rtk git status';
22
+ }
23
+ catch { /* native support unavailable */ }
24
+ let hookRegistration = 'missing';
25
+ const hooksPath = join(root, '.codex/hooks.json');
26
+ if (existsSync(hooksPath)) {
27
+ try {
28
+ const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')).hooks?.PreToolUse;
29
+ if (Array.isArray(hooks) && hooks.some(block => ['Bash', '^Bash$'].includes(block.matcher) && Array.isArray(block.hooks) && block.hooks.some((hook) => hook.type === 'command' && hook.command === 'rtk hook codex')))
30
+ hookRegistration = 'configured';
31
+ }
32
+ catch {
33
+ hookRegistration = 'invalid';
34
+ }
35
+ }
36
+ const mode = configured?.mode ?? 'off';
37
+ const probeRoot = mkdtempSync(join(tmpdir(), 'yoke-tool-probe-'));
38
+ const backends = {};
39
+ const definitions = [
40
+ ['graft', configured?.graft, createGraftAdapter, ['graft_find_code', 'graft_trace_calls']],
41
+ ['graphify', configured?.graphify, createGraphifyAdapter, ['query_graph', 'get_node']],
42
+ ['serena-lsp', configured?.serena, createSerenaAdapter, ['find_symbol', 'find_referencing_symbols']],
43
+ ];
44
+ try {
45
+ for (const [name, custom, factory, required] of definitions) {
46
+ if (mode === 'off' || custom) {
47
+ backends[name] = { status: 'unprobed', tools: [], missingTools: [...required], reason: mode === 'off' ? 'Configured mode is off.' : 'Custom command/arguments were not executed; read-only behavior cannot be verified.' };
48
+ continue;
49
+ }
50
+ const adapter = factory(probeRoot);
51
+ try {
52
+ const tools = await adapter.probe(timeout);
53
+ const missingTools = required.filter(tool => !tools.includes(tool));
54
+ backends[name] = { status: missingTools.length ? 'unavailable' : 'available', tools, missingTools };
55
+ }
56
+ catch {
57
+ backends[name] = { status: 'unavailable', tools: [], missingTools: [...required], reason: 'Backend protocol initialization or tools/list failed; target indexing was not attempted.' };
58
+ }
59
+ finally {
60
+ await adapter.close();
61
+ }
62
+ }
63
+ }
64
+ finally {
65
+ rmSync(probeRoot, { recursive: true, force: true });
66
+ }
67
+ return {
68
+ rtk: { status: nativeRewrite ? 'available' : 'degraded', version: version.status === 0 ? version.stdout.trim() : null, nativeRewrite, hookRegistration, hostActivation: 'unverified', nestedCodeMode: 'unverified', fallback: nativeRewrite ? 'Explicit RTK prefixes remain required for unverified nested code-mode calls; project-hook activation depends on host support and trust.' : 'Use explicit RTK prefixes; update RTK outside this preflight if native Codex support is needed. Use rtk init --codex --dry-run to inspect supported setup flags.' },
69
+ codeIntelligence: { mode, workspaceId: configured?.workspaceId ?? workspaceId(root), status: mode === 'off' ? 'off' : Object.values(backends).every(backend => backend.status === 'available') ? 'available' : 'degraded', backends, probeScope: 'isolated protocol capabilities only', indexFreshness: 'unverified', fallback: 'Use rg and direct source reads; verify backend candidates against source. Target index freshness and exhaustive coverage are unverified.' },
70
+ };
71
+ }
@@ -0,0 +1,36 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ const CACHE_PATHS = ['node_modules', '.vite-temp', '.vite', '.cache', 'node_modules/.vite-temp', 'node_modules/.vite', 'node_modules/.cache'];
4
+ /** Check writable roots only: ordinary package links and download stores remain supported. */
5
+ export function cacheIsolationProblem(worktree) {
6
+ let root;
7
+ try {
8
+ root = realpathSync.native(worktree);
9
+ }
10
+ catch {
11
+ return 'Cannot verify isolated worktree cache boundary. Restore the candidate directory before running gates.';
12
+ }
13
+ for (const path of CACHE_PATHS) {
14
+ const candidate = join(root, path);
15
+ try {
16
+ lstatSync(candidate);
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT')
20
+ continue;
21
+ return `Cannot inspect isolated cache ${path}. Repair permissions before running gates.`;
22
+ }
23
+ let actual;
24
+ try {
25
+ actual = realpathSync.native(candidate);
26
+ }
27
+ catch {
28
+ return `Cannot resolve isolated cache ${path}. Repair its dangling link or permissions before running gates.`;
29
+ }
30
+ const rel = relative(root, actual);
31
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) {
32
+ return `Isolated cache ${path} resolves outside the candidate. Use worktree-local node_modules and writable runtime caches; shared package download stores and package-level links are allowed. Repair this link before retrying; do not relax the sandbox.`;
33
+ }
34
+ }
35
+ return undefined;
36
+ }
package/dist/loop/loop.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { cacheIsolationProblem } from './cache-isolation.js';
2
+ import { retainRuntimeProof } from './proof-retention.js';
1
3
  import { knownInfrastructureFailure } from "../routing/capability.js";
2
4
  import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
5
  import { acceptanceProtectionProblem } from '../check/command.js';
@@ -182,6 +184,8 @@ function runCompletionGate(opts, stories) {
182
184
  const verdict = opts.completion(opts.targetDir);
183
185
  if (!verdict.passed)
184
186
  reason = `integrated system did not verify: ${verdict.summary}`;
187
+ else if (!opts.git.isClean(opts.targetDir))
188
+ reason = 'completion command left source or final assets dirty; preserve changes and move rerun proofs to ignored runtime paths before resuming';
185
189
  }
186
190
  catch (error) {
187
191
  reason = `integrated completion gate failed: ${error.message}`;
@@ -203,6 +207,9 @@ function runCompletionGate(opts, stories) {
203
207
  return { status: 'blocked', iterations: 0, reason, failure: observed.failure, finalProgress: progress(stories) };
204
208
  }
205
209
  function runCriterionGates(opts, executionDir, story) {
210
+ const cacheProblem = opts.isolate && executionDir !== opts.targetDir ? cacheIsolationProblem(executionDir) : undefined;
211
+ if (cacheProblem)
212
+ return { passed: false, summary: cacheProblem };
206
213
  const protectionProblem = acceptanceProtectionProblem(executionDir, opts.targetDir);
207
214
  if (protectionProblem)
208
215
  return { passed: false, summary: protectionProblem };
@@ -214,10 +221,10 @@ function runCriterionGates(opts, executionDir, story) {
214
221
  }
215
222
  if (!opts.verifyCriterion)
216
223
  return { passed: false, summary: `story ${story.id} has criteria but no criterion verifier` };
217
- const evidence = criteria.map(criterion => ({
218
- criterion,
219
- result: opts.verifyCriterion(executionDir, story, criterion),
220
- }));
224
+ const evidence = criteria.map(criterion => {
225
+ const problem = opts.isolate && executionDir !== opts.targetDir ? cacheIsolationProblem(executionDir) : undefined;
226
+ return { criterion, result: problem ? { passed: false, summary: problem } : opts.verifyCriterion(executionDir, story, criterion) };
227
+ });
221
228
  try {
222
229
  writeCriterionEvidence(opts.targetDir, story, evidence);
223
230
  }
@@ -230,6 +237,15 @@ function runCriterionGates(opts, executionDir, story) {
230
237
  : { passed: true, summary: `${evidence.length} acceptance criteria verified` };
231
238
  }
232
239
  export function runLoop(opts) {
240
+ if (opts.isolate)
241
+ for (const name of ['verify', 'design', 'perf', 'audit']) {
242
+ const gate = opts[name];
243
+ if (gate)
244
+ opts = { ...opts, [name]: (dir) => {
245
+ const problem = dir !== opts.targetDir ? cacheIsolationProblem(dir) : undefined;
246
+ return problem ? { passed: false, summary: problem } : gate(dir);
247
+ } };
248
+ }
233
249
  let iterations = 0;
234
250
  const reporter = opts.reporter ?? noopReporter;
235
251
  for (;;) {
@@ -445,6 +461,7 @@ export function runLoop(opts) {
445
461
  });
446
462
  const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
447
463
  savePrd(wtPrd, updated);
464
+ retainRuntimeProof(wt, story.id, opts.targetDir);
448
465
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
449
466
  opts.git.integrate(opts.targetDir, wt);
450
467
  result.routing?.recordOutcome(true);
@@ -623,6 +640,9 @@ function runImplementation(opts, dir, story, reporter) {
623
640
  let feedback = opts.feedback;
624
641
  for (let attempt = 0;; attempt++) {
625
642
  const result = opts.runner({ targetDir: dir, story, feedback });
643
+ const cacheProblem = opts.isolate && dir !== opts.targetDir ? cacheIsolationProblem(dir) : undefined;
644
+ if (cacheProblem)
645
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: cacheProblem } };
626
646
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
627
647
  return { result };
628
648
  if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
@@ -3,11 +3,14 @@ import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
6
- import { realGitOps, stageImplementation } from './git.js';
6
+ import { realGitOps, stageImplementation, RUNTIME_EXCLUDES } from './git.js';
7
7
  import { isPidAlive } from './lock.js';
8
8
  import { storyPathSegment } from './prd.js';
9
9
  import { killProcessTreeForCleanup } from './watchdog.js';
10
10
  import { parallelAcceptanceDigest, recoverParallelWorktree, retainParallelWorktree } from './recovery.js';
11
+ import { cacheIsolationProblem } from './cache-isolation.js';
12
+ import { retainRuntimeProof } from './proof-retention.js';
13
+ import { writeScopesOverlap } from './scheduler.js';
11
14
  import { statePath } from '../workspace/state.js';
12
15
  export function makeParallelAdapters(targetDir, identity, injectedGit) {
13
16
  return injectedGit
@@ -78,7 +81,13 @@ function productionAdapters(targetDir, identity) {
78
81
  git: {
79
82
  isClean: dir => realGitOps.isClean(dir),
80
83
  rebase: input => rebaseCandidate(targetDir, input),
81
- commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
84
+ commit: input => {
85
+ const unexpected = unexpectedWrites(input, changedPaths(input.worktree.path, gitText(input.worktree.path, ['rev-parse', 'HEAD'])));
86
+ if (unexpected.length)
87
+ throw new Error(`Candidate writes outside declared scopes after integrated gates: ${unexpected.join(', ')}`);
88
+ retainRuntimeProof(input.worktree.path, input.story.id, targetDir);
89
+ realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity);
90
+ },
82
91
  integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
83
92
  },
84
93
  candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens, acceptanceDigests.get(primary.story.id)),
@@ -177,7 +186,18 @@ function writeCandidateStatus(targetDir, input, worktree, state, reason) {
177
186
  writeFileSync(file, JSON.stringify({ storyId: input.storyId, candidateId: input.candidateId, worktree, state, ...(reason ? { reason } : {}) }));
178
187
  }
179
188
  function rebaseCandidate(targetDir, input) {
189
+ const cacheProblem = cacheIsolationProblem(input.worktree.path);
190
+ if (cacheProblem)
191
+ return { kind: 'reopen', reason: cacheProblem };
180
192
  const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
193
+ const actual = changedPaths(input.worktree.path, input.worktree.baseCommit);
194
+ const unexpected = unexpectedWrites(input, actual);
195
+ const siblings = currentHead === input.worktree.baseCommit ? [] : changedPaths(targetDir, input.worktree.baseCommit);
196
+ const collisions = actual.filter(path => writeScopesOverlap([path], siblings));
197
+ if (unexpected.length)
198
+ return { kind: 'reopen', reason: `Candidate writes outside declared scopes: ${unexpected.join(', ')}${collisions.length ? `; integrated sibling collisions: ${collisions.join(', ')}` : ''}` };
199
+ if (collisions.length)
200
+ return { kind: 'reopen', reason: `Candidate writes collide with integrated sibling changes: ${collisions.join(', ')}` };
181
201
  const candidateHead = gitText(input.worktree.path, ['rev-parse', 'HEAD']);
182
202
  try {
183
203
  execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, 'HEAD'], { cwd: input.worktree.path, stdio: 'pipe' });
@@ -247,3 +267,16 @@ function gitText(dir, args) {
247
267
  function errorMessage(error) {
248
268
  return error instanceof Error ? error.message : String(error);
249
269
  }
270
+ function changedPaths(directory, base) {
271
+ const read = (args) => execFileSync('git', args, { cwd: directory, stdio: 'pipe' }).toString().split('\0').filter(Boolean);
272
+ return [...new Set([...read(['diff', '--no-renames', '--name-only', '-z', base, '--', '.', ...RUNTIME_EXCLUDES]), ...read(['ls-files', '--others', '--exclude-standard', '-z', '--', '.', ...RUNTIME_EXCLUDES])])].filter(path => !/^(?:\.yoke\/(?:proof|context)\/|\.yoke\/prd\.(?:yaml|json)$)/u.test(path));
273
+ }
274
+ function unexpectedWrites(input, paths) {
275
+ if (input.story.writes === undefined)
276
+ return [];
277
+ const normalize = (value) => {
278
+ const path = value.replace(/\\/gu, '/').replace(/\/$/u, '');
279
+ return process.platform === 'win32' ? path.toLowerCase() : path;
280
+ };
281
+ return paths.filter(path => !input.story.writes.some(scope => normalize(path) === normalize(scope) || normalize(path).startsWith(`${normalize(scope)}/`)));
282
+ }