@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +3 -1
- package/TODOS.md +6 -0
- package/canon/manifest.yaml +1 -1
- package/canon/skills/visual-verification/SKILL.md +25 -2
- package/canon/tools/codex-rtk-hook.mjs +6 -16
- package/dist/cli.js +79 -0
- package/dist/code-intelligence/adapters/mcp.js +1 -0
- package/dist/code-intelligence/coordinator.js +3 -1
- package/dist/code-intelligence/index.js +1 -0
- package/dist/code-intelligence/mcp-client.js +15 -4
- package/dist/code-intelligence/mcp-server.js +4 -1
- package/dist/code-intelligence/preflight.js +71 -0
- package/dist/loop/cache-isolation.js +36 -0
- package/dist/loop/loop.js +24 -4
- package/dist/loop/parallel-adapters.js +35 -2
- package/dist/loop/parallel-command.js +17 -2
- package/dist/loop/proof-retention.js +70 -0
- package/dist/loop/reporter.js +1 -1
- package/dist/loop/run-command.js +6 -1
- package/dist/loop/runner.js +1 -1
- package/dist/loop/worker.js +7 -0
- package/dist/observability/history.js +1 -0
- package/dist/observability/local-report.js +120 -0
- package/dist/observability/usage.js +2 -0
- package/dist/retrofit/config.js +3 -1
- package/dist/retrofit/gitignore.js +10 -0
- package/dist/retrofit/planners/codex.js +20 -20
- package/dist/routing/router.js +2 -0
- package/dist/smoke/command.js +87 -13
- package/dist/update/check.js +1 -1
- package/docs/DELIVERY-JOURNEYS.md +8 -1
- package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
- package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
- package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
- package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
- package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
- package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
- package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
- package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
- package/gemini-extension.json +1 -1
- 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.
|
|
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",
|
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)
|
|
12
12
|

|
|
13
13
|
|
|
14
|
-
<!-- yoke:version:start -->1.
|
|
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.
|
package/canon/manifest.yaml
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
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
|
|
6
|
-
const result = spawnSync('rtk', ['hook', '
|
|
7
|
-
return result.status === 0
|
|
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,
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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');
|
|
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
|
-
|
|
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 =>
|
|
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
|
+
}
|