@hecer/yoke 0.8.0 → 1.0.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 (69) hide show
  1. package/.codex-plugin/plugin.json +7 -0
  2. package/CHANGELOG.md +169 -130
  3. package/README.md +61 -21
  4. package/TODOS.md +8 -0
  5. package/agents/docs.toml +6 -0
  6. package/agents/implementer.toml +6 -0
  7. package/agents/reviewer.toml +6 -0
  8. package/agents/security.toml +6 -0
  9. package/bench/README.md +45 -42
  10. package/bench/RESULTS.md +46 -36
  11. package/bench/result-schema.mjs +12 -0
  12. package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
  13. package/bench/results/codex-unavailable-1785175418318.json +15 -0
  14. package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
  15. package/bench/run-matrix.mjs +26 -0
  16. package/bench/run.mjs +127 -115
  17. package/canon/loop/prd.schema.md +5 -0
  18. package/canon/manifest.yaml +3 -1
  19. package/canon/skills/authoring-prd/SKILL.md +14 -0
  20. package/canon/skills/performance/SKILL.md +48 -0
  21. package/canon/skills/ship/SKILL.md +2 -7
  22. package/canon/tools/codex-rtk-hook.mjs +36 -0
  23. package/dist/agents/providers.js +23 -0
  24. package/dist/agents/telemetry.js +30 -0
  25. package/dist/agents/types.js +1 -0
  26. package/dist/audit/changes.js +6 -0
  27. package/dist/audit/command.js +64 -0
  28. package/dist/audit/dependencies.js +21 -0
  29. package/dist/audit/secrets.js +16 -0
  30. package/dist/audit/types.js +1 -0
  31. package/dist/cli.js +22 -4
  32. package/dist/loop/claims.js +57 -0
  33. package/dist/loop/cleanup.js +10 -4
  34. package/dist/loop/git.js +8 -2
  35. package/dist/loop/identity.js +27 -0
  36. package/dist/loop/loop.js +55 -26
  37. package/dist/loop/merge-queue.js +20 -0
  38. package/dist/loop/parallel.js +39 -0
  39. package/dist/loop/prd.js +48 -2
  40. package/dist/loop/run-command.js +63 -7
  41. package/dist/loop/runner.js +47 -31
  42. package/dist/loop/scheduler.js +8 -0
  43. package/dist/prd/command.js +6 -0
  44. package/dist/retrofit/config.js +15 -0
  45. package/dist/retrofit/planners/codex.js +64 -19
  46. package/dist/review/command.js +52 -12
  47. package/dist/review/verdict.js +45 -0
  48. package/docs/MIGRATING-TO-1.0.md +33 -0
  49. package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
  50. package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
  51. package/hooks/hooks.json +19 -0
  52. package/package.json +82 -67
  53. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
  54. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
  55. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
  56. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
  57. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
  58. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
  59. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
  60. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
  61. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
  62. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
  63. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
  64. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
  65. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
  66. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
  67. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
  68. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
  69. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
@@ -21,6 +21,17 @@ good stories (small, testable, ordered) let it run overnight.
21
21
  5. **Greenfield: STORY-1 scaffolds.** Project skeleton + runnable test suite + a criterion
22
22
  that the verify command (`verify.command` in `.yoke/config.yaml`) exits 0. Every later
23
23
  story stands on a green pipeline.
24
+ 6. **Performance requirements are acceptance criteria — with numbers.** "Should be fast" is
25
+ a vibe the loop cannot gate; "imports 1M rows in < 2s (asserted by the bench test)" is a
26
+ criterion. If the whole project has a budget, wire `perf.command` in `.yoke/config.yaml`
27
+ (see the `performance` skill) instead of repeating it per story.
28
+ 7. **Ask everything now.** Clarifying questions belong in this planning round — a loop run
29
+ has nobody to ask. A criterion that still needs a decision ("TBD", "choose a provider")
30
+ is not loop-ready; resolve it here or the agent will either guess (default) or block
31
+ (`--on-ambiguity=abort`).
32
+ 8. **Model real dependencies.** Add `needs` only for hard prerequisites, `area` for files or
33
+ subsystems that must not be edited concurrently, and `agent` only as an affinity hint.
34
+ Dependency IDs must exist; self-dependencies and cycles are invalid.
24
35
 
25
36
  ## Format (`.yoke/prd.yaml`)
26
37
 
@@ -35,6 +46,9 @@ good stories (small, testable, ordered) let it run overnight.
35
46
  - id: STORY-2
36
47
  title: add the sum command
37
48
  priority: 2
49
+ needs: [STORY-1]
50
+ area: cli
51
+ agent: codex
38
52
  acceptance:
39
53
  - "cli sum 1 2 prints 3"
40
54
  - "non-numeric input exits 1 with an error message"
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: performance
3
+ description: Use when a task has efficiency requirements or touches a hot path — make performance a measured requirement (benchmarks as tests, budgets as gates), keep interfaces clean and optimizations local, and version the WHY so future agents don't "clean up" fast code back to slow.
4
+ ---
5
+
6
+ # Performance (measured, not vibed)
7
+
8
+ "Efficient" is a requirement, not a code style. Untested performance claims rot exactly like
9
+ untested behavior claims. This skill makes efficiency mechanical — the same move Yoke makes
10
+ for everything else.
11
+
12
+ ## The decision ladder
13
+
14
+ 1. **Default: clean + minimal.** For ~90% of code, the `minimal-code` rules ARE the
15
+ performance strategy — less code, fewer layers, no speculative abstraction. Do not
16
+ micro-optimize code that no measurement flagged (premature optimization).
17
+ 2. **Performance requirement? Make it an acceptance criterion.** A number, not an adjective:
18
+ - Good: "imports 1M rows in < 2s", "p95 request latency < 50ms in the bench test",
19
+ "no allocation inside the render loop (verified by the bench assertion)"
20
+ - Bad: "should be fast", "optimize the importer"
21
+ 3. **Whole-project budget? Use the perf gate.** Set `perf.command` in `.yoke/config.yaml`
22
+ (a benchmark script; exit 0 = within budget). The loop runs it after verify — a story
23
+ that breaks the budget is blocked, no matter how clean its diff is.
24
+
25
+ ## Writing efficient code that agents can maintain
26
+
27
+ - **Clean at the boundaries, aggressive in the leaves.** Interfaces, data flow, and names
28
+ stay simple and obvious. Optimization lives inside a few clearly-bounded leaf functions
29
+ whose contracts are pinned by tests. An ugly-fast function is maintainable; an
30
+ ugly-fast architecture is not.
31
+ - **Profile before optimizing.** Find the actual hot 5% (a profiler, a timing harness, the
32
+ bench script) — never optimize from intuition. Record the measurement in the PR/commit.
33
+ - **Benchmarks are tests. Commit them.** An optimization without a committed benchmark is
34
+ one refactor away from silently disappearing. The bench script doubles as `perf.command`.
35
+ - **Version the WHY.** Every non-obvious optimization gets a one-line comment
36
+ (`perf: avoids N+1 — see bench/import.mjs`) and, if it shaped a design, a line in
37
+ `context/DECISIONS.md`. The most common AI maintenance accident is a later agent
38
+ "simplifying" fast code back to slow because nothing said why it was shaped that way.
39
+ - **Know the classics before reaching for cleverness:** right data structure (map vs list
40
+ scan), batching over per-item round trips (N+1), streaming over buffering, avoiding
41
+ repeated work in loops, caching only with a measured hit rate and an invalidation story.
42
+
43
+ ## Red flags
44
+
45
+ - Optimizing without a measurement or a budget → stop, measure first.
46
+ - A "refactor" or "cleanup" story touching code with `perf:` comments → re-run the bench
47
+ before AND after; keep the numbers in the story outcome.
48
+ - Hand-rolled cleverness where the stdlib is already O(right) → `minimal-code` wins.
@@ -526,15 +526,10 @@ Analyze the diff and group changes into logical commits. Each commit should repr
526
526
 
527
527
  **Each commit must be independently valid** — no broken imports, no references to code that doesn't exist yet.
528
528
 
529
- The **final commit** (VERSION + CHANGELOG) gets the version tag and co-author trailer:
529
+ The **final commit** contains VERSION + CHANGELOG. The project's commit identity and co-author policy always wins; never add an AI co-author trailer unless the project explicitly allows it.
530
530
 
531
531
  ```bash
532
- git commit -m "$(cat <<'EOF'
533
- chore: bump version and changelog (vX.Y.Z.W)
534
-
535
- Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
536
- EOF
537
- )"
532
+ git commit -m "chore: bump version and changelog (vX.Y.Z.W)"
538
533
  ```
539
534
 
540
535
  ---
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
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() : ''
8
+ }
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
+ }
23
+ }
24
+
25
+ async function main() {
26
+ let raw = ''
27
+ for await (const chunk of process.stdin) raw += chunk
28
+ try {
29
+ const output = rewriteHookInput(JSON.parse(raw))
30
+ if (output) process.stdout.write(JSON.stringify(output))
31
+ } catch {
32
+ // Compression is an optimization. Malformed input must never block Codex.
33
+ }
34
+ }
35
+
36
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) await main()
@@ -0,0 +1,23 @@
1
+ const argsFor = (agent, permissions) => {
2
+ if (agent === 'claude') {
3
+ const mode = permissions === 'unsafe' ? 'bypassPermissions' : permissions === 'read-only' ? 'plan' : 'auto';
4
+ const args = ['-p', '--permission-mode', mode];
5
+ if (permissions === 'unsafe')
6
+ args.push('--dangerously-skip-permissions');
7
+ return [...args, '--output-format', 'stream-json', '--verbose'];
8
+ }
9
+ if (agent === 'codex') {
10
+ if (permissions === 'unsafe')
11
+ return ['exec', '--dangerously-bypass-approvals-and-sandbox', '--json'];
12
+ if (permissions === 'read-only')
13
+ return ['exec', '--sandbox', 'read-only', '--json'];
14
+ return ['exec', '--full-auto', '--json'];
15
+ }
16
+ if (permissions === 'unsafe')
17
+ return ['--yolo', '--output-format', 'stream-json'];
18
+ const approval = permissions === 'read-only' ? 'plan' : 'auto_edit';
19
+ return ['--approval-mode', approval, '--sandbox', '--output-format', 'stream-json'];
20
+ };
21
+ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe') {
22
+ return { command: agent, args: argsFor(agent, permissions), input: prompt, cwd };
23
+ }
@@ -0,0 +1,30 @@
1
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
2
+ export function parseProviderTelemetry(agent, lines) {
3
+ let inputTokens;
4
+ let outputTokens;
5
+ let model;
6
+ for (const line of lines) {
7
+ let event;
8
+ try {
9
+ event = JSON.parse(line);
10
+ }
11
+ catch {
12
+ continue;
13
+ }
14
+ const message = event.message && typeof event.message === 'object' ? event.message : undefined;
15
+ const usage = (event.usage && typeof event.usage === 'object' ? event.usage : message?.usage);
16
+ const inValue = finite(usage?.input_tokens ?? usage?.inputTokens ?? usage?.prompt_tokens);
17
+ const outValue = finite(usage?.output_tokens ?? usage?.outputTokens ?? usage?.completion_tokens);
18
+ if (inValue !== undefined)
19
+ inputTokens = inValue;
20
+ if (outValue !== undefined)
21
+ outputTokens = outValue;
22
+ const eventModel = event.model ?? message?.model;
23
+ if (typeof eventModel === 'string' && eventModel)
24
+ model = eventModel;
25
+ }
26
+ if (inputTokens === undefined && outputTokens === undefined)
27
+ return { usageAvailable: false };
28
+ const tokens = { inputTokens: inputTokens ?? 0, outputTokens: outputTokens ?? 0, ...(model ? { model } : {}) };
29
+ return { usageAvailable: true, tokens };
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ const SENSITIVE = /(^|\/)(\.github\/workflows|auth|permissions?|secrets?|infra|terraform)(\/|\.|$)/i;
2
+ export function scanSensitiveChanges(files) {
3
+ return [...new Set(files)].filter(file => SENSITIVE.test(file.replace(/\\/g, '/'))).sort().map(file => ({
4
+ ruleId: 'changes.sensitive-path', severity: 'medium', message: 'Security-sensitive path changed; review explicitly', file,
5
+ }));
6
+ }
@@ -0,0 +1,64 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { dependencyAuditCommand, parseDependencyAudit } from './dependencies.js';
5
+ import { scanSensitiveChanges } from './changes.js';
6
+ import { scanSecrets } from './secrets.js';
7
+ const lines = (value) => value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
8
+ export function applySuppressions(findings, suppressions = [], now = new Date()) {
9
+ return findings.filter(finding => !suppressions.some(s => s.reason.trim() && s.ruleId === finding.ruleId && (!s.file || s.file === finding.file) && (!s.expires || Date.parse(s.expires) >= now.getTime())));
10
+ }
11
+ export function runAudit(targetDir, opts = {}) {
12
+ try {
13
+ const files = opts.files?.() ?? lines(execFileSync('git', ['ls-files'], { cwd: targetDir }).toString());
14
+ const changed = opts.changed?.() ?? lines(execFileSync('git', ['diff', '--name-only', 'HEAD'], { cwd: targetDir }).toString());
15
+ const read = opts.read ?? ((file) => readFileSync(join(targetDir, file), 'utf8'));
16
+ let findings = [];
17
+ for (const file of files) {
18
+ try {
19
+ findings.push(...scanSecrets(file, read(file)));
20
+ }
21
+ catch { /* binary/deleted file */ }
22
+ }
23
+ findings.push(...scanSensitiveChanges(changed));
24
+ if (opts.command) {
25
+ try {
26
+ execSync(opts.command, { cwd: targetDir, stdio: 'pipe' });
27
+ }
28
+ catch {
29
+ findings.push({ ruleId: 'audit.custom-command', severity: 'high', message: `Audit command failed: ${opts.command}`, file: '.yoke/config.yaml' });
30
+ }
31
+ }
32
+ else {
33
+ const dependency = opts.dependency ?? ((repoFiles) => {
34
+ const command = dependencyAuditCommand(repoFiles);
35
+ if (!command)
36
+ return [];
37
+ try {
38
+ return parseDependencyAudit(execFileSync(command[0], command[1], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'] }).toString());
39
+ }
40
+ catch (error) {
41
+ return parseDependencyAudit(String(error.stdout ?? '{}'));
42
+ }
43
+ });
44
+ findings.push(...dependency(files));
45
+ }
46
+ findings = applySuppressions(findings, opts.suppressions).sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId));
47
+ return { code: findings.some(f => f.severity === 'high' || f.severity === 'critical') ? 1 : 0, findings };
48
+ }
49
+ catch (error) {
50
+ return { code: 2, findings: [], error: error.message };
51
+ }
52
+ }
53
+ export function printAudit(result, json = false) {
54
+ if (json) {
55
+ console.log(JSON.stringify(result));
56
+ return;
57
+ }
58
+ for (const finding of result.findings)
59
+ console.log(`${finding.severity.toUpperCase()} ${finding.ruleId} ${finding.file}${finding.line ? `:${finding.line}` : ''} — ${finding.message}`);
60
+ if (result.error)
61
+ console.error(`Audit unavailable: ${result.error}`);
62
+ else
63
+ console.log(result.code === 0 ? '✓ audit passed' : `✗ audit found ${result.findings.length} issue(s)`);
64
+ }
@@ -0,0 +1,21 @@
1
+ export function dependencyAuditCommand(files) {
2
+ if (files.includes('package-lock.json'))
3
+ return ['npm', ['audit', '--json']];
4
+ if (files.includes('pnpm-lock.yaml'))
5
+ return ['pnpm', ['audit', '--json']];
6
+ if (files.includes('yarn.lock'))
7
+ return ['yarn', ['npm', 'audit', '--json']];
8
+ return null;
9
+ }
10
+ export function parseDependencyAudit(output) {
11
+ let value;
12
+ try {
13
+ value = JSON.parse(output);
14
+ }
15
+ catch {
16
+ return [{ ruleId: 'dependencies.audit-error', severity: 'high', message: 'Dependency audit returned invalid JSON', file: 'package manifest' }];
17
+ }
18
+ const vulnerabilities = value?.metadata?.vulnerabilities;
19
+ const count = Number(vulnerabilities?.high ?? 0) + Number(vulnerabilities?.critical ?? 0);
20
+ return count > 0 ? [{ ruleId: 'dependencies.high', severity: 'high', message: `${count} high/critical dependency vulnerabilities`, file: 'package lock' }] : [];
21
+ }
@@ -0,0 +1,16 @@
1
+ const RULES = [
2
+ { ruleId: 'secret.github-token', severity: 'critical', pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g, message: 'GitHub token detected' },
3
+ { ruleId: 'secret.aws-access-key', severity: 'critical', pattern: /\bAKIA[0-9A-Z]{16}\b/g, message: 'AWS access key detected' },
4
+ { ruleId: 'secret.private-key', severity: 'critical', pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, message: 'Private key material detected' },
5
+ ];
6
+ export function scanSecrets(file, content) {
7
+ const findings = [];
8
+ for (const rule of RULES) {
9
+ rule.pattern.lastIndex = 0;
10
+ for (const match of content.matchAll(rule.pattern)) {
11
+ const line = content.slice(0, match.index).split(/\r?\n/).length;
12
+ findings.push({ ruleId: rule.ruleId, severity: rule.severity, message: rule.message, file, line });
13
+ }
14
+ }
15
+ return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId));
16
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { runLoopCleanup } from './loop/cleanup.js';
13
13
  import { runFlowSmoke } from './smoke/command.js';
14
14
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
15
15
  import { runUpgrade } from './update/upgrade.js';
16
+ import { printAudit, runAudit } from './audit/command.js';
16
17
  export { runRetrofit } from './retrofit/command.js';
17
18
  export function runValidate(canonDir) {
18
19
  const issues = validateCanon(canonDir);
@@ -90,7 +91,7 @@ function main(argv) {
90
91
  return 0;
91
92
  }
92
93
  if (sub === 'cleanup')
93
- return runLoopCleanup(targetDir);
94
+ return runLoopCleanup(targetDir, { removeWorktrees: rest.includes('--remove-worktrees') });
94
95
  if (sub === 'run') {
95
96
  const maxArg = rest.find(a => a.startsWith('--max='));
96
97
  const rawMax = maxArg ? Number(maxArg.slice('--max='.length)) : 25;
@@ -116,6 +117,14 @@ function main(argv) {
116
117
  reviewer = reviewerArg;
117
118
  }
118
119
  const review = rest.includes('--review');
120
+ const allowSelfReview = rest.includes('--allow-self-review');
121
+ const permissions = rest.includes('--unsafe') ? 'unsafe' : undefined;
122
+ const parallelArg = rest.find(a => a.startsWith('--parallel='));
123
+ const parallel = parallelArg ? Number(parallelArg.slice('--parallel='.length)) : 1;
124
+ if (!Number.isInteger(parallel) || parallel < 1) {
125
+ console.error(`Invalid --parallel value: ${parallelArg}`);
126
+ return 1;
127
+ }
119
128
  const json = rest.includes('--json');
120
129
  const toArg = rest.find(a => a.startsWith('--timeout='));
121
130
  let timeoutMinutes;
@@ -132,9 +141,9 @@ function main(argv) {
132
141
  console.error(`Invalid --on-ambiguity value: ${oaArg} (expected resolve|abort)`);
133
142
  return 1;
134
143
  }
135
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json, onAmbiguity: oaArg });
144
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, onAmbiguity: oaArg, permissions });
136
145
  }
137
- console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
146
+ console.log('usage: yoke loop <on|off|status|cleanup [--remove-worktrees]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--isolate] [--unsafe] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
138
147
  return 1;
139
148
  }
140
149
  case 'new': {
@@ -213,6 +222,8 @@ function main(argv) {
213
222
  }
214
223
  const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length);
215
224
  const focus = rest.find(a => a.startsWith('--focus='))?.slice('--focus='.length);
225
+ const allowSelfReview = rest.includes('--allow-self-review');
226
+ const json = rest.includes('--json');
216
227
  const toArg = rest.find(a => a.startsWith('--timeout='));
217
228
  let timeoutMinutes;
218
229
  if (toArg) {
@@ -223,7 +234,14 @@ function main(argv) {
223
234
  }
224
235
  timeoutMinutes = v;
225
236
  }
226
- return runReview(targetDir, { reviewer: reviewerArg, base, focus, timeoutMinutes });
237
+ return runReview(targetDir, { reviewer: reviewerArg, base, focus, allowSelfReview, json, timeoutMinutes });
238
+ }
239
+ case 'audit': {
240
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
241
+ const json = rest.includes('--json');
242
+ const result = runAudit(targetDir);
243
+ printAudit(result, json);
244
+ return result.code;
227
245
  }
228
246
  case 'flow-smoke': {
229
247
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
@@ -0,0 +1,57 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const pathFor = (dir, id) => join(dir, '.yoke', 'claims', `${id}.json`);
4
+ export function acquireClaim(dir, storyId, owner, opts = {}) {
5
+ const path = pathFor(dir, storyId);
6
+ mkdirSync(join(dir, '.yoke', 'claims'), { recursive: true });
7
+ const now = opts.now ?? new Date();
8
+ try {
9
+ const old = JSON.parse(readFileSync(path, 'utf8'));
10
+ if (now.getTime() - Date.parse(old.claimedAt) <= (opts.staleMs ?? 30 * 60_000))
11
+ return null;
12
+ rmSync(path, { force: true });
13
+ }
14
+ catch { /* absent or malformed */ }
15
+ const claim = { storyId, owner, pid: opts.pid ?? process.pid, claimedAt: now.toISOString() };
16
+ try {
17
+ const fd = openSync(path, 'wx');
18
+ writeFileSync(fd, JSON.stringify(claim));
19
+ closeSync(fd);
20
+ return claim;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function releaseClaim(dir, storyId, owner) {
27
+ const path = pathFor(dir, storyId);
28
+ try {
29
+ const claim = JSON.parse(readFileSync(path, 'utf8'));
30
+ if (claim.owner !== owner)
31
+ return false;
32
+ rmSync(path, { force: true });
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function cleanupClaims(dir, owner) {
40
+ const claimsDir = join(dir, '.yoke', 'claims');
41
+ let removed = 0;
42
+ try {
43
+ for (const file of readdirSync(claimsDir)) {
44
+ const path = join(claimsDir, file);
45
+ try {
46
+ const claim = JSON.parse(readFileSync(path, 'utf8'));
47
+ if (!owner || claim.owner === owner) {
48
+ rmSync(path, { force: true });
49
+ removed++;
50
+ }
51
+ }
52
+ catch { /* scoped cleanup never guesses ownership */ }
53
+ }
54
+ }
55
+ catch { /* no claims */ }
56
+ return removed;
57
+ }
@@ -53,6 +53,10 @@ export function runLoopCleanup(targetDir, opts = {}) {
53
53
  if (existsSync(wtDir)) {
54
54
  for (const name of readdirSync(wtDir)) {
55
55
  const path = join(wtDir, name);
56
+ if (!opts.removeWorktrees) {
57
+ console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
58
+ continue;
59
+ }
56
60
  try {
57
61
  git(['worktree', 'remove', '--force', path], targetDir);
58
62
  removed++;
@@ -62,10 +66,12 @@ export function runLoopCleanup(targetDir, opts = {}) {
62
66
  failed++;
63
67
  }
64
68
  }
65
- try {
66
- git(['worktree', 'prune'], targetDir);
69
+ if (opts.removeWorktrees) {
70
+ try {
71
+ git(['worktree', 'prune'], targetDir);
72
+ }
73
+ catch { /* best-effort */ }
67
74
  }
68
- catch { /* best-effort */ }
69
75
  }
70
76
  const lockFile = lockPath(targetDir);
71
77
  if (existsSync(lockFile)) {
@@ -78,6 +84,6 @@ export function runLoopCleanup(targetDir, opts = {}) {
78
84
  console.log('Removed stale loop lock.');
79
85
  }
80
86
  }
81
- console.log(removed === 0 && failed === 0 ? 'Nothing to clean.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
87
+ console.log(removed === 0 && failed === 0 ? 'No destructive cleanup performed.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
82
88
  return failed === 0 ? 0 : 1;
83
89
  }
package/dist/loop/git.js CHANGED
@@ -1,16 +1,22 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { sanitizeCommitMessage } from './identity.js';
2
3
  export const realGitOps = {
3
4
  isClean(dir) {
4
5
  const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
5
6
  return out.trim() === '';
6
7
  },
7
- commitAll(dir, message) {
8
+ commitAll(dir, message, identity) {
8
9
  execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
9
10
  const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
10
11
  if (status === '') {
11
12
  throw new Error('nothing to commit after agent run');
12
13
  }
13
- execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', message], { cwd: dir, stdio: 'pipe' });
14
+ const identityArgs = identity
15
+ ? ['-c', `user.name=${identity.authorName}`, '-c', `user.email=${identity.authorEmail}`]
16
+ : [];
17
+ const authorArgs = identity ? ['--author', `${identity.authorName} <${identity.authorEmail}>`] : [];
18
+ const cleanMessage = sanitizeCommitMessage(message, identity?.allowCoAuthors ?? false);
19
+ execFileSync('git', [...identityArgs, '-c', 'commit.gpgsign=false', 'commit', ...authorArgs, '-m', cleanMessage], { cwd: dir, stdio: 'pipe' });
14
20
  },
15
21
  addWorktree(repoDir, worktreePath) {
16
22
  execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
@@ -0,0 +1,27 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const readGitConfig = (key, targetDir) => {
3
+ try {
4
+ return execFileSync('git', ['config', '--get', key], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
5
+ }
6
+ catch {
7
+ return '';
8
+ }
9
+ };
10
+ export function resolveCommitIdentity(targetDir, config, gitConfig = readGitConfig) {
11
+ const authorName = config?.authorName?.trim() || gitConfig('user.name', targetDir).trim();
12
+ const authorEmail = config?.authorEmail?.trim() || gitConfig('user.email', targetDir).trim();
13
+ if (!authorName)
14
+ throw new Error('Commit author name is missing. Set commit.authorName in .yoke/config.yaml or git config user.name.');
15
+ if (!authorEmail)
16
+ throw new Error('Commit author email is missing. Set commit.authorEmail in .yoke/config.yaml or git config user.email.');
17
+ return { authorName, authorEmail, allowCoAuthors: config?.allowCoAuthors ?? false };
18
+ }
19
+ export function sanitizeCommitMessage(message, allowCoAuthors) {
20
+ if (allowCoAuthors)
21
+ return message;
22
+ return message
23
+ .split(/\r?\n/)
24
+ .filter(line => !/^Co-Authored-By:/i.test(line.trim()))
25
+ .join('\n')
26
+ .trim();
27
+ }
package/dist/loop/loop.js CHANGED
@@ -26,6 +26,21 @@ export function pauseFilePath(targetDir) {
26
26
  export function ambiguityFilePath(dir) {
27
27
  return join(dir, '.yoke', 'ambiguity.md');
28
28
  }
29
+ // Run a gate command with the story id exposed via YOKE_STORY (restored after),
30
+ // so cumulative fixtures and story-aware benchmarks know which story is on trial.
31
+ function runGate(gate, dir, storyId) {
32
+ const prev = process.env.YOKE_STORY;
33
+ process.env.YOKE_STORY = storyId;
34
+ try {
35
+ return gate(dir);
36
+ }
37
+ finally {
38
+ if (prev === undefined)
39
+ delete process.env.YOKE_STORY;
40
+ else
41
+ process.env.YOKE_STORY = prev;
42
+ }
43
+ }
29
44
  function consumeAmbiguity(dir) {
30
45
  const file = ambiguityFilePath(dir);
31
46
  if (!existsSync(file))
@@ -106,18 +121,7 @@ export function runLoop(opts) {
106
121
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
107
122
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
108
123
  reporter.phase('verifying');
109
- const prevStory = process.env.YOKE_STORY;
110
- process.env.YOKE_STORY = story.id;
111
- let verdict;
112
- try {
113
- verdict = opts.verify(wt);
114
- }
115
- finally {
116
- if (prevStory === undefined)
117
- delete process.env.YOKE_STORY;
118
- else
119
- process.env.YOKE_STORY = prevStory;
120
- }
124
+ const verdict = runGate(opts.verify, wt, story.id);
121
125
  if (!verdict.passed) {
122
126
  const base = result.success
123
127
  ? `story ${story.id} did not verify: ${verdict.summary}`
@@ -126,6 +130,24 @@ export function runLoop(opts) {
126
130
  reporter.blocked(reason);
127
131
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
128
132
  }
133
+ if (opts.perf) {
134
+ reporter.phase('perf');
135
+ const perfVerdict = runGate(opts.perf, wt, story.id);
136
+ if (!perfVerdict.passed) {
137
+ const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
138
+ reporter.blocked(reason);
139
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
140
+ }
141
+ }
142
+ if (opts.audit) {
143
+ reporter.phase('audit');
144
+ const auditVerdict = runGate(opts.audit, wt, story.id);
145
+ if (!auditVerdict.passed) {
146
+ const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
147
+ reporter.blocked(reason);
148
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
149
+ }
150
+ }
129
151
  const summary = result.success
130
152
  ? result.summary
131
153
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -149,7 +171,7 @@ export function runLoop(opts) {
149
171
  });
150
172
  const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
151
173
  savePrd(wtPrd, updated);
152
- opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
174
+ opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
153
175
  opts.git.integrate(opts.targetDir, wt);
154
176
  landed = progress(updated);
155
177
  }
@@ -181,18 +203,7 @@ export function runLoop(opts) {
181
203
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
182
204
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
183
205
  reporter.phase('verifying');
184
- const prevStory = process.env.YOKE_STORY;
185
- process.env.YOKE_STORY = story.id;
186
- let verdict;
187
- try {
188
- verdict = opts.verify(opts.targetDir);
189
- }
190
- finally {
191
- if (prevStory === undefined)
192
- delete process.env.YOKE_STORY;
193
- else
194
- process.env.YOKE_STORY = prevStory;
195
- }
206
+ const verdict = runGate(opts.verify, opts.targetDir, story.id);
196
207
  if (!verdict.passed) {
197
208
  const base = result.success
198
209
  ? `story ${story.id} did not verify: ${verdict.summary}`
@@ -206,6 +217,24 @@ export function runLoop(opts) {
206
217
  finalProgress: progress(stories),
207
218
  };
208
219
  }
220
+ if (opts.perf) {
221
+ reporter.phase('perf');
222
+ const perfVerdict = runGate(opts.perf, opts.targetDir, story.id);
223
+ if (!perfVerdict.passed) {
224
+ const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
225
+ reporter.blocked(reason);
226
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
227
+ }
228
+ }
229
+ if (opts.audit) {
230
+ reporter.phase('audit');
231
+ const auditVerdict = runGate(opts.audit, opts.targetDir, story.id);
232
+ if (!auditVerdict.passed) {
233
+ const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
234
+ reporter.blocked(reason);
235
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
236
+ }
237
+ }
209
238
  const summary = result.success
210
239
  ? result.summary
211
240
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -236,7 +265,7 @@ export function runLoop(opts) {
236
265
  const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
237
266
  savePrd(opts.prdPath, updated);
238
267
  try {
239
- opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
268
+ opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
240
269
  }
241
270
  catch (e) {
242
271
  savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit