@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.
- package/.codex-plugin/plugin.json +7 -0
- package/CHANGELOG.md +169 -130
- package/README.md +61 -21
- package/TODOS.md +8 -0
- package/agents/docs.toml +6 -0
- package/agents/implementer.toml +6 -0
- package/agents/reviewer.toml +6 -0
- package/agents/security.toml +6 -0
- package/bench/README.md +45 -42
- package/bench/RESULTS.md +46 -36
- package/bench/result-schema.mjs +12 -0
- package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
- package/bench/results/codex-unavailable-1785175418318.json +15 -0
- package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
- package/bench/run-matrix.mjs +26 -0
- package/bench/run.mjs +127 -115
- package/canon/loop/prd.schema.md +5 -0
- package/canon/manifest.yaml +3 -1
- package/canon/skills/authoring-prd/SKILL.md +14 -0
- package/canon/skills/performance/SKILL.md +48 -0
- package/canon/skills/ship/SKILL.md +2 -7
- package/canon/tools/codex-rtk-hook.mjs +36 -0
- package/dist/agents/providers.js +23 -0
- package/dist/agents/telemetry.js +30 -0
- package/dist/agents/types.js +1 -0
- package/dist/audit/changes.js +6 -0
- package/dist/audit/command.js +64 -0
- package/dist/audit/dependencies.js +21 -0
- package/dist/audit/secrets.js +16 -0
- package/dist/audit/types.js +1 -0
- package/dist/cli.js +22 -4
- package/dist/loop/claims.js +57 -0
- package/dist/loop/cleanup.js +10 -4
- package/dist/loop/git.js +8 -2
- package/dist/loop/identity.js +27 -0
- package/dist/loop/loop.js +55 -26
- package/dist/loop/merge-queue.js +20 -0
- package/dist/loop/parallel.js +39 -0
- package/dist/loop/prd.js +48 -2
- package/dist/loop/run-command.js +63 -7
- package/dist/loop/runner.js +47 -31
- package/dist/loop/scheduler.js +8 -0
- package/dist/prd/command.js +6 -0
- package/dist/retrofit/config.js +15 -0
- package/dist/retrofit/planners/codex.js +64 -19
- package/dist/review/command.js +52 -12
- package/dist/review/verdict.js +45 -0
- package/docs/MIGRATING-TO-1.0.md +33 -0
- package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
- package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
- package/hooks/hooks.json +19 -0
- package/package.json +82 -67
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
- 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**
|
|
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 "
|
|
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
|
+
}
|
package/dist/loop/cleanup.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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 ? '
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|