@juzser/blacksmith 0.3.0 → 0.5.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/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
|
-
import {
|
|
4
|
+
import { agentNameFor, templateFileFor } from './agentNames.js';
|
|
5
|
+
import { loadBudgetPolicy, roleCapTokens } from './budgets.js';
|
|
6
|
+
import { epicBudgetPolicies } from './epicBudget.js';
|
|
5
7
|
import { SmithError } from './errors.js';
|
|
6
8
|
import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
|
|
7
9
|
import { INSPECTED_FILE_TOOLS, loadGuardrailPolicy } from './policy.js';
|
|
10
|
+
import { epicOfTaskId } from './taskId.js';
|
|
8
11
|
import { loadTaxonomy } from './taxonomy.js';
|
|
9
12
|
/**
|
|
10
13
|
* The worker harness port: *how* a turn runs, named at last.
|
|
@@ -298,7 +301,7 @@ export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
|
|
|
298
301
|
}
|
|
299
302
|
/** Where a role's prompt template lives, and whether it is there. */
|
|
300
303
|
function templateFor(role, agentsDir) {
|
|
301
|
-
const file = path.join(agentsDir,
|
|
304
|
+
const file = path.join(agentsDir, templateFileFor(role));
|
|
302
305
|
return { file, exists: existsSync(file) };
|
|
303
306
|
}
|
|
304
307
|
function serves(harness, role) {
|
|
@@ -347,17 +350,22 @@ function resolveSchema(name, harnessName) {
|
|
|
347
350
|
}
|
|
348
351
|
return { name, file };
|
|
349
352
|
}
|
|
350
|
-
function capTokensFor(role,
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
353
|
+
function capTokensFor(role, budgets) {
|
|
354
|
+
// budgets.yml prices each role on its own line, sized for the policy's
|
|
355
|
+
// effort tier. A role it prices nowhere (merger, uiux, scribe, wave-runner)
|
|
356
|
+
// gets null, which says so rather than inventing a number nobody set.
|
|
357
|
+
return roleCapTokens(budgets, role);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* budgets.yml sized for the effort tier of the epic `taskId` belongs to — the
|
|
361
|
+
* tier `cap_tokens` (and so the runner's `over_budget`) is judged at. A bare
|
|
362
|
+
* task id names no epic, and is priced at effort.yml's default tier.
|
|
363
|
+
*/
|
|
364
|
+
function budgetsForTask(taskId, planOpts) {
|
|
365
|
+
const epicId = epicOfTaskId(taskId);
|
|
366
|
+
return epicId === null
|
|
367
|
+
? loadBudgetPolicy()
|
|
368
|
+
: epicBudgetPolicies(planOpts === undefined ? {} : { planOpts })(epicId);
|
|
361
369
|
}
|
|
362
370
|
function substitute(template, values, harness) {
|
|
363
371
|
return template.replace(PLACEHOLDER_RE, (_match, token) => {
|
|
@@ -471,7 +479,7 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
471
479
|
throw new HarnessError('harness.unknown-tier', `"${request.tier}" is not a model tier. It is one of ${MODEL_TIERS.join(', ')}.`, { tier: request.tier });
|
|
472
480
|
}
|
|
473
481
|
if (request.promptFile === undefined || request.promptFile === '') {
|
|
474
|
-
throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`
|
|
482
|
+
throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`bs prompt record`), then plan the turn that carries it.', { role });
|
|
475
483
|
}
|
|
476
484
|
const name = request.harness ?? policy.defaultHarness;
|
|
477
485
|
const harness = policy.harnesses.find((h) => h.name === name);
|
|
@@ -509,7 +517,7 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
509
517
|
return {
|
|
510
518
|
...base,
|
|
511
519
|
kind: 'in-process',
|
|
512
|
-
subagentType: role,
|
|
520
|
+
subagentType: agentNameFor(role),
|
|
513
521
|
template: templateRelative,
|
|
514
522
|
};
|
|
515
523
|
}
|
|
@@ -536,11 +544,11 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
536
544
|
const roleArgs = access === 'judge' ? harness.judgeArgs : harness.workerArgs;
|
|
537
545
|
const rawArgs = [...harness.args, ...roleArgs, ...(schema !== null ? harness.schemaArgs : [])];
|
|
538
546
|
const args = rawArgs.map((arg) => substitute(arg, values, harness.name));
|
|
539
|
-
const budgets = options.budgets ??
|
|
547
|
+
const budgets = options.budgets ?? budgetsForTask(taskId, options.planOpts);
|
|
540
548
|
const budget = {
|
|
541
549
|
timeout_ms: harness.timeoutMs,
|
|
542
550
|
max_output_bytes: harness.maxOutputBytes ?? HARNESS_DEFAULT_MAX_OUTPUT_BYTES,
|
|
543
|
-
cap_tokens: capTokensFor(role,
|
|
551
|
+
cap_tokens: capTokensFor(role, budgets),
|
|
544
552
|
};
|
|
545
553
|
return {
|
|
546
554
|
...base,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// without holding the rest of the orchestrator.
|
|
3
3
|
//
|
|
4
4
|
// `.claude/hooks/guard.sh` fires on every Bash/Write/Edit/MultiEdit/
|
|
5
|
-
// NotebookEdit call an agent makes, which makes whatever it execs the most
|
|
5
|
+
// NotebookEdit/Agent/Task call an agent makes, which makes whatever it execs the most
|
|
6
6
|
// frequently run code in this repo by orders of magnitude. It used to exec
|
|
7
7
|
// `dist/cli.js policy hook`; cli.ts imports the whole orchestrator at module
|
|
8
8
|
// scope, `db/projector.js` and so drizzle-orm among it, and that graph cost
|
|
@@ -11,8 +11,49 @@
|
|
|
11
11
|
// `policyHook.ts` be an entry point whose imports are only what deciding
|
|
12
12
|
// actually needs, while `cli.ts` keeps `smith policy hook` by calling the
|
|
13
13
|
// same function rather than a second copy of it.
|
|
14
|
-
import {
|
|
14
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { roleOfAgentType } from './agentNames.js';
|
|
17
|
+
import { parseDeclaredArtifactLine } from './declaredArtifactLine.js';
|
|
18
|
+
import { JUDGE_ROLES } from './judgeRoles.js';
|
|
19
|
+
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, isProtectedBranchName, loadGuardrailPolicy, } from './policy.js';
|
|
15
20
|
import { activeSandboxFor } from './sandbox.js';
|
|
21
|
+
const JUDGE_ROLE_NAMES = new Set(JUDGE_ROLES);
|
|
22
|
+
/**
|
|
23
|
+
* A judge-role subagent dispatch must carry `Declared artifact: <absolute path>`
|
|
24
|
+
* in its prompt, because judge-stop.sh reads the prompt (never a brief file) to
|
|
25
|
+
* know what the judge owes. Same parser as judge-stop, so what is accepted here
|
|
26
|
+
* is what it reads. Any other role, prefix or missing type is allowed silently.
|
|
27
|
+
*/
|
|
28
|
+
function decideAgentDispatch(input) {
|
|
29
|
+
const type = typeof input?.subagent_type === 'string' ? input.subagent_type : '';
|
|
30
|
+
// Same mapping as judge-stop: `bs-reviewer`, `blacksmith:bs-reviewer` and the
|
|
31
|
+
// bare pre-prefix names all reach the role. Another plugin's namespace
|
|
32
|
+
// (`other:reviewer`) is not a Blacksmith agent, so it is skipped, not stripped.
|
|
33
|
+
const colon = type.indexOf(':');
|
|
34
|
+
if (colon !== -1 && type.slice(0, colon) !== 'blacksmith')
|
|
35
|
+
return null;
|
|
36
|
+
const role = roleOfAgentType(type);
|
|
37
|
+
if (role === null || !JUDGE_ROLE_NAMES.has(role))
|
|
38
|
+
return null;
|
|
39
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
40
|
+
const declared = parseDeclaredArtifactLine(prompt);
|
|
41
|
+
if (declared !== null && path.isAbsolute(declared))
|
|
42
|
+
return null;
|
|
43
|
+
const found = declared === null
|
|
44
|
+
? ''
|
|
45
|
+
: ` The prompt has "${declared}", which is relative; the path must be absolute.`;
|
|
46
|
+
return {
|
|
47
|
+
hookSpecificOutput: {
|
|
48
|
+
hookEventName: 'PreToolUse',
|
|
49
|
+
permissionDecision: 'deny',
|
|
50
|
+
permissionDecisionReason: `A "${role}" dispatch must declare the artifact it writes. The prompt needs a line reading exactly ` +
|
|
51
|
+
'`Declared artifact: <absolute path>` on its own line, not indented, and in the prompt itself ' +
|
|
52
|
+
`(judge-stop reads the prompt, not a brief file the agent reads).${found} ` +
|
|
53
|
+
'`judge dispatch` prints the line to paste, as `expected_line`.',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
16
57
|
/**
|
|
17
58
|
* Reads one PreToolUse payload and answers with the envelope to print, or
|
|
18
59
|
* `null` to print nothing.
|
|
@@ -32,8 +73,13 @@ import { activeSandboxFor } from './sandbox.js';
|
|
|
32
73
|
* again: the first is failure, the second is a real allow, and only guard.sh's
|
|
33
74
|
* fail-closed handling of a non-zero exit may turn "failure" into a denial.
|
|
34
75
|
*/
|
|
35
|
-
export function decideHookPayload(raw, fallbackCwd
|
|
76
|
+
export function decideHookPayload(raw, fallbackCwd,
|
|
77
|
+
/** Where judge leases live; the live `state/sandboxes` unless a test points elsewhere. */
|
|
78
|
+
leaseDir) {
|
|
36
79
|
const payload = JSON.parse(raw);
|
|
80
|
+
if (payload.tool_name === 'Agent' || payload.tool_name === 'Task') {
|
|
81
|
+
return decideAgentDispatch(payload.tool_input);
|
|
82
|
+
}
|
|
37
83
|
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
38
84
|
const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
|
|
39
85
|
// A `Write`/`Edit`/`MultiEdit` payload carries no command; its target is
|
|
@@ -44,34 +90,99 @@ export function decideHookPayload(raw, fallbackCwd) {
|
|
|
44
90
|
// handed. Same shape as `command`: a missing or non-string field reads as
|
|
45
91
|
// absent, never as a guess.
|
|
46
92
|
const filePath = typeof payload.tool_input?.file_path === 'string' ? payload.tool_input.file_path : null;
|
|
47
|
-
// Branch and repo root come from where the command would actually run
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// '
|
|
93
|
+
// Branch and repo root come from where the command would actually run.
|
|
94
|
+
// Resolving them from this binary's own checkout would be wrong in the case
|
|
95
|
+
// this factory spends most of its time in: guard.sh always execs the main
|
|
96
|
+
// clone's dist, while the session issuing the command is usually inside a
|
|
97
|
+
// per-task git worktree on a different branch. That gap is not academic —
|
|
98
|
+
// `git rebase` on smith/<epic>/integration is a rule-5 denial, and the merge
|
|
99
|
+
// queue performs rebases from a worktree standing on exactly that branch
|
|
100
|
+
// while the main clone stands elsewhere. `fallbackCwd` covers a payload
|
|
101
|
+
// without the field (a hand-driven invocation, or an older client), and a
|
|
102
|
+
// cwd outside any repo resolves to ''/null, which denies an out-of-bounds
|
|
103
|
+
// removal rather than allowing it.
|
|
104
|
+
//
|
|
105
|
+
// The payload's `cwd` is where the SESSION stands, though, and a command can
|
|
106
|
+
// move before it acts. `cd <worktree> && git merge main` from a session in
|
|
107
|
+
// the main clone runs on the worktree's side branch, so judging it on `main`
|
|
108
|
+
// is a false deny; `cd <main clone> && git merge x` from a worktree session
|
|
109
|
+
// is the matching false allow. So one narrow shape — `shortcutDirectories`
|
|
110
|
+
// — is judged in its target alone, dropping `cwd`; that is the ONLY way this
|
|
111
|
+
// function can ever be more lenient than judging `cwd`, so the shape is an
|
|
112
|
+
// allowlist and anything it cannot read with certainty (including a target
|
|
113
|
+
// that is not on a named branch of a repo) falls back. The fallback judges
|
|
114
|
+
// `cwd` plus every directory the command names, and the command is denied
|
|
115
|
+
// if ANY of them denies it, so it is never more lenient than `cwd` alone.
|
|
116
|
+
// A command naming no directory change is judged on `cwd` alone, as before.
|
|
60
117
|
const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : fallbackCwd;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
};
|
|
73
|
-
const
|
|
74
|
-
|
|
118
|
+
const policy = loadGuardrailPolicy();
|
|
119
|
+
// The lease binds the session a judge was handed, so it is read from `cwd`
|
|
120
|
+
// whatever the command does next: reading it only from the cd target would
|
|
121
|
+
// let `cd <elsewhere> && curl …` leave the sandbox in one hop. A lease over
|
|
122
|
+
// a target is checked too, so moving INTO a leased worktree is bound by it.
|
|
123
|
+
// No lease is the ordinary case and costs one directory read.
|
|
124
|
+
const sessionLease = activeSandboxFor(cwd, leaseDir);
|
|
125
|
+
const locate = (dir) => ({
|
|
126
|
+
dir,
|
|
127
|
+
branch: detectCurrentBranch(dir),
|
|
128
|
+
repoRoot: detectRepoRoot(dir),
|
|
129
|
+
});
|
|
130
|
+
const certain = MOVES_RE.test(command) ? shortcutDirectories(command, cwd)?.map(locate) : null;
|
|
131
|
+
// `HEAD` is what a detached worktree's branch reads as — not a name a
|
|
132
|
+
// protected-branch rule can ever match, and guardrails.md requires the
|
|
133
|
+
// shortcut's target to sit on a named branch.
|
|
134
|
+
const places = certain?.every((p) => p.branch !== '' && p.branch !== 'HEAD' && p.repoRoot !== null) === true
|
|
135
|
+
? certain
|
|
136
|
+
: fallbackDirectories(command, cwd).map(locate);
|
|
137
|
+
let reason = null;
|
|
138
|
+
let firingRuleId = null;
|
|
139
|
+
let firingDir = null;
|
|
140
|
+
for (const { dir, branch, repoRoot } of places) {
|
|
141
|
+
const targetLease = dir === cwd ? null : activeSandboxFor(dir, leaseDir);
|
|
142
|
+
for (const sandbox of distinctLeases(sessionLease, targetLease)) {
|
|
143
|
+
const context = { toolName, command, branch, repoRoot, sandbox, filePath };
|
|
144
|
+
const decision = evaluateCommand(context, policy);
|
|
145
|
+
// guard.sh only ever surfaced one reason: each rule block()ed and exited
|
|
146
|
+
// immediately on its own match, sequentially, so the first rule in
|
|
147
|
+
// guard.sh's 1-6 order to fire is the only one an agent ever saw.
|
|
148
|
+
// evaluateCommand's checks array preserves that order, so violations[0]
|
|
149
|
+
// of the first denying evaluation reproduces guard.sh's visible
|
|
150
|
+
// behaviour even though evaluateCommand (policy check's diagnostic
|
|
151
|
+
// output) reports every rule that tripped.
|
|
152
|
+
if (!decision.allowed) {
|
|
153
|
+
const violation = decision.violations[0];
|
|
154
|
+
reason = violation?.reason ?? 'guardrails.yml denied this command.';
|
|
155
|
+
firingRuleId = violation?.ruleId ?? null;
|
|
156
|
+
firingDir = dir;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (reason !== null)
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
// Rule 3 denying on the fallback (`certain === null`) because cwd itself is
|
|
164
|
+
// protected is a guess dressed as fact when the command also named another
|
|
165
|
+
// directory that is NOT protected: the fallback ran only because the shape
|
|
166
|
+
// could not be read for certain, not because cwd is provably where this
|
|
167
|
+
// runs. Swap in a reason that says so and names the readable shapes, so the
|
|
168
|
+
// same merge can go through rewritten. Every other case — no named
|
|
169
|
+
// directory, or a named directory that is protected too — keeps the
|
|
170
|
+
// original reason unchanged.
|
|
171
|
+
if (reason !== null &&
|
|
172
|
+
firingRuleId === 'merge-into-protected' &&
|
|
173
|
+
certain == null &&
|
|
174
|
+
firingDir === cwd) {
|
|
175
|
+
const namedUnprotected = places.some((p) => p.dir !== cwd &&
|
|
176
|
+
p.branch !== '' &&
|
|
177
|
+
p.branch !== 'HEAD' &&
|
|
178
|
+
p.repoRoot !== null &&
|
|
179
|
+
!isProtectedBranchName(p.branch, policy));
|
|
180
|
+
if (namedUnprotected) {
|
|
181
|
+
const cwdBranch = places.find((p) => p.dir === cwd)?.branch ?? '';
|
|
182
|
+
reason = fallbackMergeReason(cwdBranch);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (reason === null) {
|
|
75
186
|
// Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
|
|
76
187
|
// Claude Code reads an explicit `permissionDecision: "allow"` from a hook
|
|
77
188
|
// as "skip the permission system for this call" — it outranks the
|
|
@@ -87,15 +198,6 @@ export function decideHookPayload(raw, fallbackCwd) {
|
|
|
87
198
|
// load-bearing the way the old hook's was.
|
|
88
199
|
return null;
|
|
89
200
|
}
|
|
90
|
-
// guard.sh only ever surfaced one reason: each rule block()ed and exited
|
|
91
|
-
// immediately on its own match, sequentially, so the first rule in guard.sh's
|
|
92
|
-
// 1-6 order to fire is the only one an agent ever saw. evaluateCommand's
|
|
93
|
-
// checks array preserves that same order, so violations[0] reproduces
|
|
94
|
-
// guard.sh's exact visible behaviour even though evaluateCommand (policy
|
|
95
|
-
// check's diagnostic output) reports every rule that tripped, not just the
|
|
96
|
-
// first.
|
|
97
|
-
const first = decision.violations[0];
|
|
98
|
-
const reason = first ? first.reason : 'guardrails.yml denied this command.';
|
|
99
201
|
return {
|
|
100
202
|
hookSpecificOutput: {
|
|
101
203
|
hookEventName: 'PreToolUse',
|
|
@@ -104,3 +206,273 @@ export function decideHookPayload(raw, fallbackCwd) {
|
|
|
104
206
|
},
|
|
105
207
|
};
|
|
106
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Rule 3's fallback reason for the one case where "you are on {branch}"
|
|
211
|
+
* states a guess as fact: the command named another directory, that
|
|
212
|
+
* directory is NOT protected, and the only reason cwd was judged at all is
|
|
213
|
+
* that the command's shape was not one `shortcutDirectories` can read for
|
|
214
|
+
* certain — see its own doc comment for the two shapes it accepts. Says so
|
|
215
|
+
* plainly, and names those shapes, rather than telling an agent already
|
|
216
|
+
* standing on a side branch to "check out a side branch instead".
|
|
217
|
+
*/
|
|
218
|
+
function fallbackMergeReason(cwdBranch) {
|
|
219
|
+
return (`this command's shape is not one the guard can read for certain, so it ` +
|
|
220
|
+
`was also judged against your session's own directory, which is on ` +
|
|
221
|
+
`${cwdBranch}, a protected branch; git merge/pull is denied there. The ` +
|
|
222
|
+
`guard reads \`cd <dir> && git <cmd> …\` — <dir> absolute or starting ` +
|
|
223
|
+
`with \`./\`, joined only by \`&&\`, each command merge, pull or another ` +
|
|
224
|
+
`read-safe git subcommand (not checkout or switch) — or a single ` +
|
|
225
|
+
`\`git -C <dir> merge …\`; plain words only, no pipes, \`;\`, redirects, ` +
|
|
226
|
+
`quotes or variables. Write it that way and the same merge is judged in ` +
|
|
227
|
+
`<dir> alone.`);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The one shape judged in its target alone, or `null` to fall back. An
|
|
231
|
+
* allowlist, because this is the only path that drops `cwd` and so the only
|
|
232
|
+
* one that can allow what `cwd` alone would deny:
|
|
233
|
+
*
|
|
234
|
+
* - `cd <target> && <cmd> && <cmd> …`: a cd, then plain words joined by
|
|
235
|
+
* nothing but `&&` — so everything after runs in the target or not at
|
|
236
|
+
* all. A relative target must start with `./` or `../` (or be `.`/`..`),
|
|
237
|
+
* since a bare name is looked up on CDPATH first.
|
|
238
|
+
* - `git -C <target> <plain words>`, alone.
|
|
239
|
+
*
|
|
240
|
+
* A plain word is unquoted and uses only `PLAIN_WORD_RE`'s characters: no
|
|
241
|
+
* `;`, `|`, `&`, newline, backslash, `$`, backtick, quote, parens, glob or
|
|
242
|
+
* redirection. The target may also be one whole single- or double-quoted
|
|
243
|
+
* span. Every `&&`-joined command after the target must be a `git`
|
|
244
|
+
* invocation whose subcommand sits in `GIT_SUBCOMMAND_ALLOWLIST` —
|
|
245
|
+
* positively, not a denylist of mover words: any plain word could be a shell
|
|
246
|
+
* alias or function (zsh's autopushd defines `-` and `1`..`9` as `cd`
|
|
247
|
+
* shortcuts), so nothing short of "must be a git builtin that cannot run a
|
|
248
|
+
* command in another repo" is sound. `checkout` and `switch` are excluded
|
|
249
|
+
* even though they only ever touch the repo they run in: either can move the
|
|
250
|
+
* target onto a different branch mid-command, and the branch judged is read
|
|
251
|
+
* once, before the command runs — `cd <W> && git checkout main && git merge
|
|
252
|
+
* x` would be judged on W's branch before the checkout, not the `main` it
|
|
253
|
+
* actually merges onto. Git builtins cannot be shadowed by a git alias; a
|
|
254
|
+
* shell alias named `git` itself is out of scope. A mover flag
|
|
255
|
+
* (`MOVER_FLAG_RE`), a short option bundling `x` or `s`
|
|
256
|
+
* (`hasDangerousShortOption` — rebase `-x`/`-kx`, an arbitrary command; merge
|
|
257
|
+
* or checkout `-s`, a strategy or search run off PATH as `git-<name>`), or a
|
|
258
|
+
* long option starting `--e`/`--s` (`hasDangerousLongOption` — `--exec`,
|
|
259
|
+
* `--strategy`, and their `=value` forms) still forfeits, and so does a git
|
|
260
|
+
* location override anywhere. Returns the target lexically and physically
|
|
261
|
+
* when the two differ (a cd is logical, git's chdir physical), and the
|
|
262
|
+
* caller falls back too if any of them is not on a named branch of a repo.
|
|
263
|
+
*/
|
|
264
|
+
function shortcutDirectories(command, cwd) {
|
|
265
|
+
// No `.trim()`/split reliance below this line for a security decision: both
|
|
266
|
+
// read NBSP, BOM and form-feed as whitespace and drop them, while a shell's
|
|
267
|
+
// IFS does not — so a target's trailing NBSP would parse away to a clean,
|
|
268
|
+
// different directory than the one the shell actually enters.
|
|
269
|
+
if (NON_ASCII_RE.test(command))
|
|
270
|
+
return null;
|
|
271
|
+
if (GIT_LOCATION_RE.test(command) || /[\r\n]/.test(command.trim()))
|
|
272
|
+
return null;
|
|
273
|
+
const segments = command
|
|
274
|
+
.trim()
|
|
275
|
+
.split('&&')
|
|
276
|
+
.map((segment) => segment.trim().split(/[ \t]+/));
|
|
277
|
+
const [first = [], ...rest] = segments;
|
|
278
|
+
let target;
|
|
279
|
+
let after;
|
|
280
|
+
if (first.length === 2 && first[0] === 'cd' && rest.length > 0) {
|
|
281
|
+
target = first[1] ?? '';
|
|
282
|
+
after = rest.flat();
|
|
283
|
+
if (!/^\.{1,2}(?:\/|$)|^\//.test(unquote(target) ?? ''))
|
|
284
|
+
return null;
|
|
285
|
+
if (!rest.every((segment) => segment[0] === 'git' && GIT_SUBCOMMAND_ALLOWLIST.has(segment[1] ?? '')))
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
else if (segments.length === 1 && first[0] === 'git' && first[1] === '-C' && first.length > 3) {
|
|
289
|
+
target = first[2] ?? '';
|
|
290
|
+
after = first.slice(3);
|
|
291
|
+
if (!GIT_SUBCOMMAND_ALLOWLIST.has(after[0] ?? ''))
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const plain = (word) => PLAIN_WORD_RE.test(word) && !MOVER_WORDS.has(word) && !MOVER_FLAG_RE.test(word);
|
|
298
|
+
if (!after.every(plain))
|
|
299
|
+
return null;
|
|
300
|
+
if (after.some((word) => hasDangerousShortOption(word) || hasDangerousLongOption(word)))
|
|
301
|
+
return null;
|
|
302
|
+
if (!PLAIN_WORD_RE.test(target) && !/^'[^']*'$|^"[^"]*"$/.test(target))
|
|
303
|
+
return null;
|
|
304
|
+
return literalDirectories(target, cwd);
|
|
305
|
+
}
|
|
306
|
+
/** Every directory a command names, plus `cwd` — the fail-closed fallback. */
|
|
307
|
+
function fallbackDirectories(command, cwd) {
|
|
308
|
+
const dirs = [cwd];
|
|
309
|
+
for (const [, word = ''] of command.matchAll(TARGET_RE)) {
|
|
310
|
+
// A relative hop is resolved against every directory found so far, so
|
|
311
|
+
// `cd a && cd b` still reaches `a/b`: an extra candidate costs nothing
|
|
312
|
+
// but a possible denial, and a missed one is a hole.
|
|
313
|
+
for (const base of [...dirs]) {
|
|
314
|
+
for (const target of literalDirectories(word, base) ?? []) {
|
|
315
|
+
if (!dirs.includes(target))
|
|
316
|
+
dirs.push(target);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return dirs;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Anything that could put a command somewhere other than where it started.
|
|
324
|
+
* Deliberately loose — `echo cd` or `git commit -C HEAD` match too — because
|
|
325
|
+
* a match only forfeits the no-move path, never a check.
|
|
326
|
+
*/
|
|
327
|
+
const MOVES_RE = /(?:^|[\s;&|(])(?:cd|pushd|popd)(?=$|[\s;&|)])|\s-C(?:\s|$)|[()`]/;
|
|
328
|
+
/** A word the shortcut reads as itself: nothing the shell would expand, split or redirect. */
|
|
329
|
+
const PLAIN_WORD_RE = /^[A-Za-z0-9_./:@%+,=-]+$/;
|
|
330
|
+
/**
|
|
331
|
+
* Anything outside printable ASCII (tab allowed as a separator), forfeiting
|
|
332
|
+
* the shortcut before any `.trim()`/split touches the command — see
|
|
333
|
+
* `shortcutDirectories`'s note on why those cannot be trusted past this
|
|
334
|
+
* point.
|
|
335
|
+
*/
|
|
336
|
+
const NON_ASCII_RE = /[^\t\x20-\x7E]/;
|
|
337
|
+
/**
|
|
338
|
+
* Git builtins the shortcut may follow a `cd`/`-C` target with: read-only or
|
|
339
|
+
* ordinary write commands that act on the repo they are invoked in, never on
|
|
340
|
+
* another one, and that cannot themselves move the target onto a different
|
|
341
|
+
* branch than the one just read. Deliberately excludes indirect executors
|
|
342
|
+
* that run a git command somewhere else (`for-each-repo`, `submodule`),
|
|
343
|
+
* anything that rewrites history destructively outside a plain invocation
|
|
344
|
+
* (`filter-branch`, `filter-repo`, `worktree`, `bisect`), and `checkout`/
|
|
345
|
+
* `switch` — either can move the target's branch mid-command, after
|
|
346
|
+
* `shortcutDirectories`' caller has already read it once, so a later command
|
|
347
|
+
* in the same chain would be judged on the branch the target left rather
|
|
348
|
+
* than the one it moved to. A git builtin cannot be shadowed by a git alias,
|
|
349
|
+
* which is what makes this allowlist sound.
|
|
350
|
+
*/
|
|
351
|
+
const GIT_SUBCOMMAND_ALLOWLIST = new Set([
|
|
352
|
+
'status',
|
|
353
|
+
'log',
|
|
354
|
+
'diff',
|
|
355
|
+
'show',
|
|
356
|
+
'add',
|
|
357
|
+
'commit',
|
|
358
|
+
'merge',
|
|
359
|
+
'rebase',
|
|
360
|
+
'push',
|
|
361
|
+
'fetch',
|
|
362
|
+
'pull',
|
|
363
|
+
'branch',
|
|
364
|
+
'reset',
|
|
365
|
+
'rev-parse',
|
|
366
|
+
'tag',
|
|
367
|
+
'stash',
|
|
368
|
+
]);
|
|
369
|
+
/**
|
|
370
|
+
* A short option (`-x`, `-kx`, `-x./s`) that bundles `x` or `s` among its
|
|
371
|
+
* option letters, read up to the first non-letter (a stuck argument, `=`, or
|
|
372
|
+
* end of word) — `git rebase -x <cmd>`/`-kx<cmd>` runs an arbitrary command,
|
|
373
|
+
* and `git merge -s <strategy>`/`git checkout -s` (`-s` is a bundlable short
|
|
374
|
+
* flag on several subcommands) can run `git-<strategy>` off PATH. A plain
|
|
375
|
+
* number (`-5`) is exempt, since `git log -5` is common and harmless.
|
|
376
|
+
*/
|
|
377
|
+
function hasDangerousShortOption(word) {
|
|
378
|
+
if (!word.startsWith('-') || word.startsWith('--'))
|
|
379
|
+
return false;
|
|
380
|
+
if (/^-\d+$/.test(word))
|
|
381
|
+
return false;
|
|
382
|
+
const letters = /^-([A-Za-z]+)/.exec(word)?.[1] ?? '';
|
|
383
|
+
return letters.includes('x') || letters.includes('s');
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A long option that could be `--exec`/`--exec=…` (rebase, arbitrary command)
|
|
387
|
+
* or `--strategy`/`--strategy-option=…` (merge/rebase, runs `git-<name>` off
|
|
388
|
+
* PATH) by prefix rather than exact match, since either can carry `=value`.
|
|
389
|
+
* Over-forfeits `--edit`, `--squash`, and the like on purpose: forfeiting
|
|
390
|
+
* only falls back to the full check, never widens what the shortcut allows.
|
|
391
|
+
*/
|
|
392
|
+
function hasDangerousLongOption(word) {
|
|
393
|
+
return word.startsWith('--e') || word.startsWith('--s');
|
|
394
|
+
}
|
|
395
|
+
/** Commands that can move the shell, or run a command string somewhere else. */
|
|
396
|
+
const MOVER_WORDS = new Set([
|
|
397
|
+
'cd',
|
|
398
|
+
'pushd',
|
|
399
|
+
'popd',
|
|
400
|
+
'eval',
|
|
401
|
+
'source',
|
|
402
|
+
'.',
|
|
403
|
+
'exec',
|
|
404
|
+
'command',
|
|
405
|
+
'builtin',
|
|
406
|
+
'env',
|
|
407
|
+
'sudo',
|
|
408
|
+
'xargs',
|
|
409
|
+
'find',
|
|
410
|
+
'sh',
|
|
411
|
+
'bash',
|
|
412
|
+
'zsh',
|
|
413
|
+
'dash',
|
|
414
|
+
'ksh',
|
|
415
|
+
]);
|
|
416
|
+
/** Flags that move a command (`-C`, `--chdir`, `--directory`) or configure git (`-c`). */
|
|
417
|
+
const MOVER_FLAG_RE = /^(?:-C|-c$|--chdir|--directory)/;
|
|
418
|
+
/** Git location overrides: they point git at another repo whatever the cwd. */
|
|
419
|
+
const GIT_LOCATION_RE = /GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|--git-dir|--work-tree/;
|
|
420
|
+
/** One word: double-quoted, single-quoted, or bare up to a separator. */
|
|
421
|
+
const WORD = `("[^"]*"|'[^']*'|[^\\s;&|<>()]+)`;
|
|
422
|
+
/** Every `cd`/`pushd <word>` and every `-C <word>`, wherever it sits. */
|
|
423
|
+
const TARGET_RE = new RegExp(`(?:(?:^|[\\s;&|(])(?:cd|pushd)|\\s-C)\\s+${WORD}`, 'g');
|
|
424
|
+
/**
|
|
425
|
+
* `word` with its quotes removed, or `null` when it is quoted any other way
|
|
426
|
+
* than one whole span: `"a"/"b"` is two spans the shell splices, and reading
|
|
427
|
+
* it as one quoted `a"/"b` names a different directory.
|
|
428
|
+
*/
|
|
429
|
+
function unquote(word) {
|
|
430
|
+
if (/^'[^']*'$/.test(word) || /^"[^"]*"$/.test(word))
|
|
431
|
+
return word.slice(1, -1);
|
|
432
|
+
return /["'\\]/.test(word) ? null : word;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* `word` as an existing, enterable directory resolved against `base` —
|
|
436
|
+
* lexically (where a logical `cd` lands) and physically (where git's chdir
|
|
437
|
+
* lands, `..` after a symlink included), both when they differ — or `null`
|
|
438
|
+
* when it is not a literal path: an expansion (`$`, backtick, `~`), a glob, a
|
|
439
|
+
* flag or `-`, an escape, a quote this does not read, or a path that does not
|
|
440
|
+
* resolve. A cd there fails and runs nothing after its `&&`, and this is not
|
|
441
|
+
* the place to guess what it meant.
|
|
442
|
+
*/
|
|
443
|
+
function literalDirectories(word, base) {
|
|
444
|
+
const bare = unquote(word);
|
|
445
|
+
if (bare === null || bare === '' || bare.startsWith('-') || /[$`~*?[\]\\]/.test(bare)) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const lexical = path.resolve(base, bare);
|
|
449
|
+
try {
|
|
450
|
+
// `native` is realpath(3); the JS realpathSync resolves `..` lexically first.
|
|
451
|
+
const physical = realpathSync.native(path.isAbsolute(bare) ? bare : `${base}/${bare}`);
|
|
452
|
+
if (!statSync(physical).isDirectory())
|
|
453
|
+
return null;
|
|
454
|
+
accessSync(physical, constants.X_OK);
|
|
455
|
+
if (lexical === physical || !isDirectory(lexical))
|
|
456
|
+
return [physical];
|
|
457
|
+
return [lexical, physical];
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function isDirectory(dir) {
|
|
464
|
+
try {
|
|
465
|
+
return statSync(dir).isDirectory();
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** The leases to judge under — `[null]` for none, so one unleased evaluation still runs. */
|
|
472
|
+
function distinctLeases(session, target) {
|
|
473
|
+
if (session === null)
|
|
474
|
+
return [target];
|
|
475
|
+
if (target === null || target.worktreeDir === session.worktreeDir)
|
|
476
|
+
return [session];
|
|
477
|
+
return [session, target];
|
|
478
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SmithError } from './errors.js';
|
|
2
|
-
import { appendEvent } from './events.js';
|
|
2
|
+
import { appendEvent, validateEventEnvelope } from './events.js';
|
|
3
3
|
import { runGit as git } from './git.js';
|
|
4
4
|
import { run } from './testgate.js';
|
|
5
|
-
import { integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
|
|
5
|
+
import { epicBranchPrefix, integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
|
|
6
6
|
/**
|
|
7
7
|
* D-42/P9-26, second half. Moving the worktrees outside the project root
|
|
8
8
|
* (worktree.ts) fixes the tool that went red; it does not fix the reason
|
|
@@ -36,13 +36,15 @@ function branchHead(projectDir, branch) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* The current head of
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* The current head of `<prefix>/<epic>/integration` (`bs/` for a new epic,
|
|
40
|
+
* `smith/` for one that already integrates there — epicBranchPrefix decides
|
|
41
|
+
* which), or null when the epic has no integration branch (never planned,
|
|
42
|
+
* never dispatched, wrong --epic). Read here rather than inside epic.ts so
|
|
43
|
+
* the verdict stays git-free and purely a fold over events — the CLI passes
|
|
44
|
+
* the sha in.
|
|
43
45
|
*/
|
|
44
46
|
export function integrationHeadSha(projectDir, epicId) {
|
|
45
|
-
return branchHead(projectDir, integrationBranchName(epicId));
|
|
47
|
+
return branchHead(projectDir, integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)));
|
|
46
48
|
}
|
|
47
49
|
/** Last-wins fold: the most recent recorded check for this epic, or null. */
|
|
48
50
|
export function latestIntegrationCheck(events, epicId) {
|
|
@@ -75,8 +77,18 @@ export function latestIntegrationCheck(events, epicId) {
|
|
|
75
77
|
* one thing this must never do is write down a pass that nothing earned.
|
|
76
78
|
*/
|
|
77
79
|
export async function runIntegrationCheck(input, ctx, opts = {}) {
|
|
80
|
+
// Refused before the check suite below spends a full run on it: a bad
|
|
81
|
+
// --causal-parent used to surface only at the append this function makes
|
|
82
|
+
// once the suite has already finished (D-42's shape, one level up — the
|
|
83
|
+
// waste here is CPU time, not a git mutation, but the same envelope
|
|
84
|
+
// `appendEvent` would refuse anyway, so refuse it first).
|
|
85
|
+
await validateEventEnvelope({
|
|
86
|
+
session_id: ctx.sessionId,
|
|
87
|
+
event_type: INTEGRATION_CHECK_EVENT,
|
|
88
|
+
causal_parent: ctx.causalParent,
|
|
89
|
+
}, opts);
|
|
78
90
|
const { epicId, projectDir } = input;
|
|
79
|
-
const branch = integrationBranchName(epicId);
|
|
91
|
+
const branch = integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId));
|
|
80
92
|
// testgate.run([]) returns pass: true for an empty list — correct there
|
|
81
93
|
// (a task claiming no checks has nothing to short-circuit), and a silent
|
|
82
94
|
// forgery here. "No checks configured" must never read back as "the
|