@juzser/blacksmith 0.1.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 +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { runGit, runGitRaw } from './git.js';
|
|
6
|
+
import { WORKTREE_POLICY_PATH } from './paths.js';
|
|
7
|
+
export class ClaimsError extends SmithError {
|
|
8
|
+
}
|
|
9
|
+
function splitPathSegments(p) {
|
|
10
|
+
return p.split('/').filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
function isSegmentPrefix(a, b) {
|
|
13
|
+
const len = Math.min(a.length, b.length);
|
|
14
|
+
for (let i = 0; i < len; i++) {
|
|
15
|
+
if (a[i] !== b[i])
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One concrete string that would satisfy a glob's final path segment: `*`/`?`
|
|
22
|
+
* become a literal placeholder character, a brace set picks its first
|
|
23
|
+
* alternative, a bracket class picks a placeholder — enough to construct a
|
|
24
|
+
* real candidate path, not to enumerate every string the segment accepts.
|
|
25
|
+
*/
|
|
26
|
+
export function synthesizeLiteralSegment(segment) {
|
|
27
|
+
return segment
|
|
28
|
+
.replace(/\{([^,}]+)(,[^}]*)?\}/g, '$1')
|
|
29
|
+
.replace(/\[[^\]]*\]/g, 'x')
|
|
30
|
+
.replace(/\*/g, 'x')
|
|
31
|
+
.replace(/\?/g, 'x');
|
|
32
|
+
}
|
|
33
|
+
/** A concrete final-path-segment candidate satisfying `pattern`. */
|
|
34
|
+
function syntheticTail(pattern) {
|
|
35
|
+
const segments = pattern.split('/').filter((s) => s.length > 0);
|
|
36
|
+
const last = segments[segments.length - 1] ?? '';
|
|
37
|
+
if (last === '**')
|
|
38
|
+
return '__smith_probe__'; // pattern ends in /** — any filename qualifies
|
|
39
|
+
return synthesizeLiteralSegment(last);
|
|
40
|
+
}
|
|
41
|
+
function joinBaseAndTail(base, tail) {
|
|
42
|
+
return base ? `${base}/${tail}` : tail;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* When neither prefix-containment nor a literal-path check settles it (both
|
|
46
|
+
* sides still have glob magic, e.g. "src/**" vs "**\/config.ts" or
|
|
47
|
+
* "src/**\/*.ts" vs "**\/*.ts"): synthesize concrete candidate paths from
|
|
48
|
+
* each pattern's own literal parts (base of one + synthesized tail of the
|
|
49
|
+
* other, in both directions) and test whether any candidate satisfies BOTH
|
|
50
|
+
* patterns at once. This is an existence check, not exhaustive glob-language
|
|
51
|
+
* intersection — good enough to disprove overlap only when every candidate
|
|
52
|
+
* genuinely fails one side (e.g. disjoint literal extensions).
|
|
53
|
+
*/
|
|
54
|
+
function couldJointlyMatch(globA, globB, baseA, baseB) {
|
|
55
|
+
const matchA = picomatch(globA);
|
|
56
|
+
const matchB = picomatch(globB);
|
|
57
|
+
const candidates = [
|
|
58
|
+
joinBaseAndTail(baseA, syntheticTail(globB)),
|
|
59
|
+
joinBaseAndTail(baseB, syntheticTail(globA)),
|
|
60
|
+
];
|
|
61
|
+
return candidates.some((candidate) => matchA(candidate) && matchB(candidate));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One concrete path a glob grants, for asking whether another glob grants it
|
|
65
|
+
* too. A literal pattern is its own path; anything else is its static base
|
|
66
|
+
* joined to a synthesized tail.
|
|
67
|
+
*/
|
|
68
|
+
function concretePath(glob) {
|
|
69
|
+
const scan = picomatch.scan(glob);
|
|
70
|
+
if (!scan.isGlob)
|
|
71
|
+
return glob;
|
|
72
|
+
return joinBaseAndTail(scan.base, syntheticTail(glob));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Does `outer` grant every path `inner` grants? Containment, not
|
|
76
|
+
* intersection — a strictly narrower question than `globsOverlap`, and the
|
|
77
|
+
* one the serialize-always rule actually asks.
|
|
78
|
+
*
|
|
79
|
+
* Approximated the way the rest of this file approximates glob algebra:
|
|
80
|
+
* `inner` must live under `outer`'s static root, and `outer` must accept a
|
|
81
|
+
* concrete path synthesized from `inner`'s own literal parts. Errs toward
|
|
82
|
+
* "no", which is the safe direction here because a false "yes" would
|
|
83
|
+
* serialize two tasks that never shared a file.
|
|
84
|
+
*/
|
|
85
|
+
function globContains(outer, inner) {
|
|
86
|
+
if (outer === inner)
|
|
87
|
+
return true;
|
|
88
|
+
const outerScan = picomatch.scan(outer);
|
|
89
|
+
if (!outerScan.isGlob)
|
|
90
|
+
return false; // a literal grants only itself, and inner is not it
|
|
91
|
+
const outerBase = splitPathSegments(outerScan.base);
|
|
92
|
+
if (outerBase.length > 0) {
|
|
93
|
+
const innerBase = splitPathSegments(picomatch.scan(inner).base);
|
|
94
|
+
if (innerBase.length < outerBase.length)
|
|
95
|
+
return false;
|
|
96
|
+
if (!isSegmentPrefix(outerBase, innerBase))
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return picomatch(outer)(concretePath(inner));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Practical glob-vs-glob overlap check: two globs overlap when one's static
|
|
103
|
+
* (non-glob) root path is a path-segment prefix of the other's — the
|
|
104
|
+
* standard "directory ownership" heuristic (claims are directory/file-
|
|
105
|
+
* subtree grants, not arbitrary regex sets) — or, when at least one side has
|
|
106
|
+
* no static prefix at all (globstar-anchored, e.g. "**\/pnpm-lock.yaml"),
|
|
107
|
+
* when a literal path on the other side actually matches it, or a
|
|
108
|
+
* synthesized joint-match candidate satisfies both patterns simultaneously.
|
|
109
|
+
* Errs conservative: only concludes "no overlap" once every check has
|
|
110
|
+
* failed to produce a shared match.
|
|
111
|
+
*/
|
|
112
|
+
export function globsOverlap(globA, globB) {
|
|
113
|
+
if (globA === globB)
|
|
114
|
+
return true;
|
|
115
|
+
const scanA = picomatch.scan(globA);
|
|
116
|
+
const scanB = picomatch.scan(globB);
|
|
117
|
+
const baseA = splitPathSegments(scanA.base);
|
|
118
|
+
const baseB = splitPathSegments(scanB.base);
|
|
119
|
+
if (baseA.length > 0 && baseB.length > 0) {
|
|
120
|
+
return isSegmentPrefix(baseA, baseB) || isSegmentPrefix(baseB, baseA);
|
|
121
|
+
}
|
|
122
|
+
if (!scanA.isGlob && picomatch(globB)(globA))
|
|
123
|
+
return true;
|
|
124
|
+
if (!scanB.isGlob && picomatch(globA)(globB))
|
|
125
|
+
return true;
|
|
126
|
+
if (!scanA.isGlob || !scanB.isGlob)
|
|
127
|
+
return false; // one side literal and it didn't match -> disjoint
|
|
128
|
+
return couldJointlyMatch(globA, globB, scanA.base, scanB.base);
|
|
129
|
+
}
|
|
130
|
+
export function claimsOverlap(taskA, taskB) {
|
|
131
|
+
const offendingGlobs = [];
|
|
132
|
+
for (const globA of taskA.claims) {
|
|
133
|
+
for (const globB of taskB.claims) {
|
|
134
|
+
if (globsOverlap(globA, globB))
|
|
135
|
+
offendingGlobs.push({ globA, globB });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { overlaps: offendingGlobs.length > 0, offendingGlobs };
|
|
139
|
+
}
|
|
140
|
+
export function loadWorktreePolicy(filePath = WORKTREE_POLICY_PATH) {
|
|
141
|
+
const doc = parseYaml(readFileSync(filePath, 'utf8'));
|
|
142
|
+
const globs = doc.serialize_always_globs?.globs;
|
|
143
|
+
if (!Array.isArray(globs)) {
|
|
144
|
+
throw new ClaimsError('claims.invalid-worktree-policy', `${filePath} is missing serialize_always_globs.globs.`);
|
|
145
|
+
}
|
|
146
|
+
return { serializeAlwaysGlobs: globs.map(String) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Which of the policy's serialize-always globs does this task's claim set
|
|
150
|
+
* touch?
|
|
151
|
+
*
|
|
152
|
+
* Containment in either direction, not overlap. The distinction is the
|
|
153
|
+
* difference between a gate that works and one that never opens: under
|
|
154
|
+
* `globsOverlap`, `src/a/**` "touches" `**\/pnpm-lock.yaml`, because a
|
|
155
|
+
* synthesized `src/a/pnpm-lock.yaml` satisfies both patterns at once. Every
|
|
156
|
+
* realistic claim is a subtree grant, so every pair of tasks touched the
|
|
157
|
+
* lockfile guard together and every wave of two was refused —
|
|
158
|
+
* `serializeAlwaysViolations` on claims that share no file and never would.
|
|
159
|
+
* The old fixture in claims.test.ts states the trap plainly and then steps
|
|
160
|
+
* around it by claiming individual files.
|
|
161
|
+
*
|
|
162
|
+
* A subtree grant is not a lockfile edit. A task that means to edit a
|
|
163
|
+
* protected file claims it (`pnpm-lock.yaml`), or claims a region that holds
|
|
164
|
+
* every such file (`**\/pnpm-lock.yaml`), or is itself inside the protected
|
|
165
|
+
* region (`src/types/foo.ts` under `src/types/**`). Those are the three
|
|
166
|
+
* shapes containment accepts, and they are the three shapes the policy in
|
|
167
|
+
* worktree.yml is written about.
|
|
168
|
+
*/
|
|
169
|
+
export function touchesSerializeAlways(task, serializeAlwaysGlobs) {
|
|
170
|
+
const touched = new Set();
|
|
171
|
+
for (const claim of task.claims) {
|
|
172
|
+
for (const guard of serializeAlwaysGlobs) {
|
|
173
|
+
if (globContains(claim, guard) || globContains(guard, claim))
|
|
174
|
+
touched.add(guard);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return [...touched];
|
|
178
|
+
}
|
|
179
|
+
/** A closed vocabulary, so it can be named in an error without quoting a value. */
|
|
180
|
+
function describeType(value) {
|
|
181
|
+
if (value === null)
|
|
182
|
+
return 'null';
|
|
183
|
+
if (Array.isArray(value))
|
|
184
|
+
return 'a list';
|
|
185
|
+
return typeof value;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refuse a claim set no comparison can read, before it is compared.
|
|
189
|
+
*
|
|
190
|
+
* The codebase already settles claims-shape questions by asking which
|
|
191
|
+
* direction is safe for the reader asking them. `ownershipFromPlan` drops an
|
|
192
|
+
* unreadable set because "a task without claims owns no file, and is dropped
|
|
193
|
+
* rather than defaulted to owning everything"; spec.ts drops one because that
|
|
194
|
+
* "leaves its sites unclaimed rather than silently covered". Both are right,
|
|
195
|
+
* and both answer a question whose safe direction is *less*.
|
|
196
|
+
*
|
|
197
|
+
* This gate asks the opposite question — may these tasks edit the repo at the
|
|
198
|
+
* same time — and its safe direction is *no*. An empty claim set is pairwise
|
|
199
|
+
* disjoint with every other task in the wave, so defaulting to `[]` here does
|
|
200
|
+
* not fail safe: it returns the single answer that cannot be re-checked
|
|
201
|
+
* afterwards, because by then the wave is already running in parallel
|
|
202
|
+
* worktrees. A claim written as a bare string is worse than empty, since
|
|
203
|
+
* iterating a string yields its characters and 's' overlaps no path: the gate
|
|
204
|
+
* reports a disjointness it computed from letters.
|
|
205
|
+
*
|
|
206
|
+
* The unreadable value is never quoted back. It comes from an unvalidated
|
|
207
|
+
* plan file or from the append-only log, and an error message is not where
|
|
208
|
+
* file content gets to appear (D-198) — the type is a closed vocabulary, the
|
|
209
|
+
* value is not.
|
|
210
|
+
*/
|
|
211
|
+
export function readClaimList(task) {
|
|
212
|
+
const { claims } = task;
|
|
213
|
+
if (!Array.isArray(claims)) {
|
|
214
|
+
throw new ClaimsError('claims.unreadable-claims', `Task "${task.task_id}" declares claims as ${describeType(claims)}, not a list of globs, ` +
|
|
215
|
+
'so this wave cannot be checked for claim overlap. Claims are a list of path globs ' +
|
|
216
|
+
'(task-spec.schema.json); a single glob is a one-element list, not a bare string.', { task_id: task.task_id, received: describeType(claims) });
|
|
217
|
+
}
|
|
218
|
+
const badIndex = claims.findIndex((glob) => typeof glob !== 'string');
|
|
219
|
+
if (badIndex !== -1) {
|
|
220
|
+
throw new ClaimsError('claims.unreadable-claims', `Task "${task.task_id}" declares claim ${badIndex} as ` +
|
|
221
|
+
`${describeType(claims[badIndex])}, not a path glob, so this wave cannot be checked ` +
|
|
222
|
+
'for claim overlap.', { task_id: task.task_id, index: badIndex, received: describeType(claims[badIndex]) });
|
|
223
|
+
}
|
|
224
|
+
if (claims.length === 0) {
|
|
225
|
+
throw new ClaimsError('claims.empty-claims', `Task "${task.task_id}" claims no files, so it is disjoint from every other task in the ` +
|
|
226
|
+
'wave for the reason that it may not edit anything. Admitting it defers the mistake to ' +
|
|
227
|
+
'the first out-of-claim edit; task-spec.schema.json requires at least one claim.', { task_id: task.task_id });
|
|
228
|
+
}
|
|
229
|
+
return { task_id: task.task_id, claims: claims };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Refuse an edge list no traversal can read, before it is traversed.
|
|
233
|
+
*
|
|
234
|
+
* Same door as `readClaimList`, and the same reason: `wave check` reads the
|
|
235
|
+
* plan with a bare `JSON.parse(...) as PlanFile`, so `edges` holds whatever
|
|
236
|
+
* JSON held. The safe direction here is the one that costs a re-plan, not the
|
|
237
|
+
* one that costs a wave — a value that is not a list of edges must not
|
|
238
|
+
* iterate to zero pairs, because zero pairs is the answer that admits.
|
|
239
|
+
*
|
|
240
|
+
* The unreadable value is never quoted back (D-198): the type is a closed
|
|
241
|
+
* vocabulary, plan-file content is not.
|
|
242
|
+
*/
|
|
243
|
+
export function readEdgeList(edges) {
|
|
244
|
+
if (!Array.isArray(edges)) {
|
|
245
|
+
throw new ClaimsError('claims.unreadable-edges', `The plan declares edges as ${describeType(edges)}, not a list of dependency edges, so ` +
|
|
246
|
+
'this wave cannot be checked for tasks the plan orders. Edges are a list of ' +
|
|
247
|
+
'{ task, dependsOn } records; a plan that declares none declares an empty list.', { received: describeType(edges) });
|
|
248
|
+
}
|
|
249
|
+
return edges.map((edge, index) => {
|
|
250
|
+
const record = edge;
|
|
251
|
+
if (typeof record?.task !== 'string' || typeof record?.dependsOn !== 'string') {
|
|
252
|
+
throw new ClaimsError('claims.unreadable-edges', `Edge ${index} declares task as ${describeType(record?.task)} and dependsOn as ` +
|
|
253
|
+
`${describeType(record?.dependsOn)}; both are task ids, so this wave cannot be ` +
|
|
254
|
+
'checked for tasks the plan orders.', {
|
|
255
|
+
index,
|
|
256
|
+
task: describeType(record?.task),
|
|
257
|
+
dependsOn: describeType(record?.dependsOn),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return { task: record.task, dependsOn: record.dependsOn };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Which tasks in the wave does the plan order after another task in the wave?
|
|
265
|
+
*
|
|
266
|
+
* Transitive, not direct: `{a, c}` under `c <- b <- a` has no edge joining its
|
|
267
|
+
* own two members, and b's absence from the wave is what makes a and c
|
|
268
|
+
* concurrent, not what makes them safe. Every edge counts, whatever its
|
|
269
|
+
* `edge_type` — `artifact`, `claim-order`, `spec-clause`, `regression-test`
|
|
270
|
+
* and `research-brief` all mean "runs after" (factory/policies/taxonomy.yml).
|
|
271
|
+
*
|
|
272
|
+
* A breadth-first walk gives the shortest chain, and `visited` bounds it: the
|
|
273
|
+
* plan may hold a cycle (only `smith plan validate` rejects those) and this
|
|
274
|
+
* gate must still answer.
|
|
275
|
+
*/
|
|
276
|
+
function findDependencyViolations(tasks, edges) {
|
|
277
|
+
const inWave = new Set(tasks.map((task) => task.task_id));
|
|
278
|
+
const dependencies = new Map();
|
|
279
|
+
for (const edge of edges) {
|
|
280
|
+
const existing = dependencies.get(edge.task);
|
|
281
|
+
if (existing)
|
|
282
|
+
existing.push(edge.dependsOn);
|
|
283
|
+
else
|
|
284
|
+
dependencies.set(edge.task, [edge.dependsOn]);
|
|
285
|
+
}
|
|
286
|
+
const violations = [];
|
|
287
|
+
for (const task of tasks) {
|
|
288
|
+
const visited = new Set([task.task_id]);
|
|
289
|
+
let frontier = [[task.task_id]];
|
|
290
|
+
while (frontier.length > 0) {
|
|
291
|
+
const next = [];
|
|
292
|
+
for (const chain of frontier) {
|
|
293
|
+
// A task the plan never mentions has no dependencies, which is not the
|
|
294
|
+
// same fact as "an empty list of them" -- so ask the map, and skip.
|
|
295
|
+
const deps = dependencies.get(chain[chain.length - 1]);
|
|
296
|
+
if (!deps)
|
|
297
|
+
continue;
|
|
298
|
+
for (const dep of deps) {
|
|
299
|
+
if (visited.has(dep))
|
|
300
|
+
continue;
|
|
301
|
+
visited.add(dep);
|
|
302
|
+
const extended = [...chain, dep];
|
|
303
|
+
if (inWave.has(dep)) {
|
|
304
|
+
violations.push({ task: task.task_id, dependsOn: dep, chain: extended });
|
|
305
|
+
}
|
|
306
|
+
// Kept walking either way: a prerequisite inside the wave may itself
|
|
307
|
+
// sit above another one.
|
|
308
|
+
next.push(extended);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
frontier = next;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return violations;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Verify a proposed wave (NOT computing the largest admissible set — that is
|
|
318
|
+
* the planner's job): tasks must be pairwise claim-disjoint, none may touch a
|
|
319
|
+
* serialize-always glob concurrently with another task in the wave, and none
|
|
320
|
+
* may be one the plan orders after another task in the same wave.
|
|
321
|
+
*
|
|
322
|
+
* `edges` is required rather than defaulted, and that is the point of D-212.
|
|
323
|
+
* `factory/policies/worktree.yml` — the file this gate's own policy comes
|
|
324
|
+
* from — states the remedy it exists to make stick: "tasks with overlapping
|
|
325
|
+
* claims are never scheduled concurrently; they get a dependency edge
|
|
326
|
+
* (edge_type: claim-order) and run serially instead". Cutting that edge is
|
|
327
|
+
* also what narrows the claims, so the plan that took the advice is the plan
|
|
328
|
+
* whose claims this gate then has nothing left to object to. A caller that
|
|
329
|
+
* cannot supply the plan's edges is a caller that cannot answer the question.
|
|
330
|
+
*
|
|
331
|
+
* Every claim set is read before any pair is compared, so a wave carrying one
|
|
332
|
+
* unreadable set is refused as unanswerable rather than answered from the
|
|
333
|
+
* pairs that happened to be readable.
|
|
334
|
+
*/
|
|
335
|
+
export function validateWave(proposed, policy, edges) {
|
|
336
|
+
const tasks = proposed.map(readClaimList);
|
|
337
|
+
const dependencyViolations = findDependencyViolations(tasks, readEdgeList(edges));
|
|
338
|
+
const overlapViolations = [];
|
|
339
|
+
const serializeAlwaysViolations = [];
|
|
340
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
341
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
342
|
+
const taskA = tasks[i];
|
|
343
|
+
const taskB = tasks[j];
|
|
344
|
+
const overlap = claimsOverlap(taskA, taskB);
|
|
345
|
+
if (overlap.overlaps) {
|
|
346
|
+
overlapViolations.push({
|
|
347
|
+
taskA: taskA.task_id,
|
|
348
|
+
taskB: taskB.task_id,
|
|
349
|
+
offendingGlobs: overlap.offendingGlobs,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const touchedA = touchesSerializeAlways(taskA, policy.serializeAlwaysGlobs);
|
|
353
|
+
const touchedB = touchesSerializeAlways(taskB, policy.serializeAlwaysGlobs);
|
|
354
|
+
for (const glob of touchedA) {
|
|
355
|
+
if (touchedB.includes(glob)) {
|
|
356
|
+
serializeAlwaysViolations.push({ taskA: taskA.task_id, taskB: taskB.task_id, glob });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (overlapViolations.length === 0 &&
|
|
362
|
+
serializeAlwaysViolations.length === 0 &&
|
|
363
|
+
dependencyViolations.length === 0) {
|
|
364
|
+
return { valid: true };
|
|
365
|
+
}
|
|
366
|
+
return { valid: false, overlapViolations, serializeAlwaysViolations, dependencyViolations };
|
|
367
|
+
}
|
|
368
|
+
/** Repo-relative, forward-slashed, no leading "./" — the form claims are written in. */
|
|
369
|
+
function normalizeRepoPath(filePath) {
|
|
370
|
+
return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Does one claim glob cover one file path? Exported because ownership
|
|
374
|
+
* resolution (below), dispatch-time finding context and the stale-evidence
|
|
375
|
+
* check all ask exactly this question, and two implementations of "does this
|
|
376
|
+
* task's claims cover this finding's file" would be two different answers to
|
|
377
|
+
* one question — a bug, not a duplication (P9-15).
|
|
378
|
+
*/
|
|
379
|
+
export function claimCoversPath(claim, filePath) {
|
|
380
|
+
return picomatch(claim)(normalizeRepoPath(filePath));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* How specifically a claim names a path, most significant component first:
|
|
384
|
+
* an exact literal claim ("src/parse.ts") outranks any glob, and among globs
|
|
385
|
+
* the one with the deeper static base ("src/parser/**" over "src/**") wins.
|
|
386
|
+
* Deliberately coarse — a finer scale would start inventing an ordering
|
|
387
|
+
* between patterns that genuinely name the same file with equal precision,
|
|
388
|
+
* which is the case this must report rather than resolve.
|
|
389
|
+
*/
|
|
390
|
+
function claimSpecificity(claim, normalizedPath) {
|
|
391
|
+
const scan = picomatch.scan(claim);
|
|
392
|
+
const exact = !scan.isGlob && normalizeRepoPath(claim) === normalizedPath ? 1 : 0;
|
|
393
|
+
return [exact, splitPathSegments(scan.base).length];
|
|
394
|
+
}
|
|
395
|
+
function compareSpecificity(a, b) {
|
|
396
|
+
return a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1];
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Who owns a finding anchored to `filePath`: the task whose claims cover it,
|
|
400
|
+
* read off the plan's claims map (D-41/P9-24). Ownership is a property of the
|
|
401
|
+
* file, not of whichever task happened to be at the gate when a judge spoke —
|
|
402
|
+
* gating on the latter blocks an unrelated diff and leaves the real owner
|
|
403
|
+
* untouched.
|
|
404
|
+
*
|
|
405
|
+
* Two tasks can legitimately match the same file across different waves
|
|
406
|
+
* (`validateWave` only enforces disjointness *within* a wave), so a
|
|
407
|
+
* specificity tie-break settles the common case. A genuine tie is reported as
|
|
408
|
+
* `ambiguous` rather than guessed — same precedent as `resolveTaskId`
|
|
409
|
+
* (D-46/P9-29): an id nobody can pin down is an operator's call.
|
|
410
|
+
*/
|
|
411
|
+
export function resolveFindingOwner(filePath, tasks) {
|
|
412
|
+
const normalizedPath = normalizeRepoPath(filePath);
|
|
413
|
+
// Best (most specific) matching claim per task, so one task listing both
|
|
414
|
+
// "src/*.ts" and "src/**" is never ambiguous with itself.
|
|
415
|
+
const best = new Map();
|
|
416
|
+
for (const task of tasks) {
|
|
417
|
+
const taskId = task.task_id;
|
|
418
|
+
if (taskId === undefined)
|
|
419
|
+
continue;
|
|
420
|
+
for (const claim of task.claims) {
|
|
421
|
+
if (!claimCoversPath(claim, normalizedPath))
|
|
422
|
+
continue;
|
|
423
|
+
const score = claimSpecificity(claim, normalizedPath);
|
|
424
|
+
const current = best.get(taskId);
|
|
425
|
+
if (current === undefined || compareSpecificity(score, current.score) > 0) {
|
|
426
|
+
best.set(taskId, { claim, score });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (best.size === 0)
|
|
431
|
+
return { owner: 'unclaimed' };
|
|
432
|
+
const ranked = [...best.entries()].sort(([, a], [, b]) => compareSpecificity(b.score, a.score));
|
|
433
|
+
const [topId, top] = ranked[0];
|
|
434
|
+
const tied = ranked.filter(([, entry]) => compareSpecificity(entry.score, top.score) === 0);
|
|
435
|
+
if (tied.length > 1) {
|
|
436
|
+
return {
|
|
437
|
+
owner: 'ambiguous',
|
|
438
|
+
candidates: tied.map(([taskId, entry]) => ({ taskId, claim: entry.claim })),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return { owner: 'resolved', taskId: topId, claim: top.claim };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Task statuses that mean "this task's diff is closed to further work". A
|
|
445
|
+
* finding routed to one of these has nobody to fix it — the branch already
|
|
446
|
+
* landed (`completed`), the team already accepted the gap (`waived`), or a
|
|
447
|
+
* later plan version replaced the task outright (`superseded`).
|
|
448
|
+
*
|
|
449
|
+
* `failed` and `escalated` are deliberately NOT here: those tasks are still
|
|
450
|
+
* open work an operator is holding, and a finding about their files belongs
|
|
451
|
+
* on them rather than on a new task competing for the same claims.
|
|
452
|
+
*/
|
|
453
|
+
const CLOSED_TO_FURTHER_WORK = new Set(['completed', 'waived', 'superseded']);
|
|
454
|
+
/**
|
|
455
|
+
* Where a finding should land, given who owns its file (D-41/P9-24).
|
|
456
|
+
*
|
|
457
|
+
* `statusOf` is a lookup rather than a map so the caller can defer reading the
|
|
458
|
+
* event log until a finding actually resolves to somebody else — the common
|
|
459
|
+
* case is a judge speaking about the very task at the gate, and that case
|
|
460
|
+
* should cost nothing extra. An owner the log has never heard of counts as
|
|
461
|
+
* open: a task in the plan but not yet in the log is `todo`, not merged.
|
|
462
|
+
*/
|
|
463
|
+
export function decideFindingAttribution(ownership, gatedTaskId, statusOf) {
|
|
464
|
+
if (ownership.owner === 'unclaimed') {
|
|
465
|
+
return { attribution: 'follow-up', reason: 'No task in the plan claims this file.' };
|
|
466
|
+
}
|
|
467
|
+
if (ownership.owner === 'ambiguous') {
|
|
468
|
+
// A tie is a reason to escalate only when nothing present can break it.
|
|
469
|
+
// The task at the gate can: it is holding the file right now, and the
|
|
470
|
+
// diff the judge just read is where the fix belongs. Escalating one of
|
|
471
|
+
// the tied owners' own findings minted a follow-up competing for claims
|
|
472
|
+
// that task still held, and — since gate.ts admits nothing but `gated`
|
|
473
|
+
// into `blocking` — let the finding land un-blocked (D-173).
|
|
474
|
+
if (ownership.candidates.some((c) => c.taskId === gatedTaskId)) {
|
|
475
|
+
return { attribution: 'gated' };
|
|
476
|
+
}
|
|
477
|
+
const candidates = ownership.candidates
|
|
478
|
+
.map((c) => `${c.taskId} (${c.claim})`)
|
|
479
|
+
.sort()
|
|
480
|
+
.join(', ');
|
|
481
|
+
return {
|
|
482
|
+
attribution: 'follow-up',
|
|
483
|
+
reason: `Two or more tasks claim this file with equal specificity: ${candidates}.`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (ownership.taskId === gatedTaskId)
|
|
487
|
+
return { attribution: 'gated' };
|
|
488
|
+
const status = statusOf(ownership.taskId);
|
|
489
|
+
if (status !== undefined && CLOSED_TO_FURTHER_WORK.has(status)) {
|
|
490
|
+
return {
|
|
491
|
+
attribution: 'follow-up',
|
|
492
|
+
reason: `Owning task ${ownership.taskId} is already ${status}.`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return { attribution: 'reassigned', taskId: ownership.taskId, claim: ownership.claim };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The classifier half of the check (P9-3): given a changed-file set from
|
|
499
|
+
* *some* collector, split it by pattern. Knows nothing about git, branches or
|
|
500
|
+
* worktrees, which is the whole point — the planner and scribe write outside
|
|
501
|
+
* any worktree on an ordinary branch, and before this split the matching logic
|
|
502
|
+
* was reachable only through a collector that threw on their branch names.
|
|
503
|
+
*
|
|
504
|
+
* An empty pattern list puts every change out of bounds rather than passing
|
|
505
|
+
* everything: a role with no declared write root is one that should not be
|
|
506
|
+
* writing.
|
|
507
|
+
*/
|
|
508
|
+
export function classifyChanges(changedFiles, patterns, violationCode = 'contract.claim-violation') {
|
|
509
|
+
const isClaimed = patterns.length > 0 ? picomatch([...patterns]) : () => false;
|
|
510
|
+
const inClaim = [];
|
|
511
|
+
const outOfClaim = [];
|
|
512
|
+
for (const file of changedFiles) {
|
|
513
|
+
if (isClaimed(file))
|
|
514
|
+
inClaim.push(file);
|
|
515
|
+
else
|
|
516
|
+
outOfClaim.push(file);
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
inClaim,
|
|
520
|
+
outOfClaim,
|
|
521
|
+
violation: outOfClaim.length > 0 ? { error: violationCode, files: outOfClaim } : null,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Which integration branch a task worktree was cut from, by convention. Its own
|
|
526
|
+
* export because the symbol-impact check (impact.ts) asks the same question of
|
|
527
|
+
* the same worktree, and two derivations of one convention would be two
|
|
528
|
+
* answers.
|
|
529
|
+
*
|
|
530
|
+
* Collector A (P9-3): what this task branch committed, against the integration
|
|
531
|
+
* branch it was cut from — derived from the branch naming convention
|
|
532
|
+
* smith/<epic>/<task-id> -> smith/<epic>/integration (worktree.yml
|
|
533
|
+
* `integration_branch.pattern`; a bare "smith/<epic>" ref cannot coexist with
|
|
534
|
+
* "smith/<epic>/<task-id>" refs in real git — D/F ref conflict).
|
|
535
|
+
*
|
|
536
|
+
* The convention is load-bearing here and that is fine: only an agent working
|
|
537
|
+
* in a task worktree has a claims list, and only a task worktree is on such a
|
|
538
|
+
* branch. What used to be wrong was that this was the *only* way to reach the
|
|
539
|
+
* classifier.
|
|
540
|
+
*/
|
|
541
|
+
export function integrationBranchFor(worktreeDir) {
|
|
542
|
+
const branch = runGit(worktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
543
|
+
const lastSlash = branch.lastIndexOf('/');
|
|
544
|
+
if (lastSlash === -1) {
|
|
545
|
+
throw new ClaimsError('claims.cannot-derive-integration-branch', `Branch "${branch}" does not follow the smith/<epic>/<task-id> convention.`, { branch });
|
|
546
|
+
}
|
|
547
|
+
return `${branch.slice(0, lastSlash)}/integration`;
|
|
548
|
+
}
|
|
549
|
+
export function collectCommittedChanges(worktreeDir) {
|
|
550
|
+
const integrationBranch = integrationBranchFor(worktreeDir);
|
|
551
|
+
const diffOutput = runGit(worktreeDir, ['diff', '--name-only', `${integrationBranch}...HEAD`]);
|
|
552
|
+
return diffOutput
|
|
553
|
+
.split('\n')
|
|
554
|
+
.map((f) => f.trim())
|
|
555
|
+
.filter((f) => f.length > 0);
|
|
556
|
+
}
|
|
557
|
+
/** `git status --porcelain -z` record: two status chars, a space, then the path. */
|
|
558
|
+
const PORCELAIN_STATUS_WIDTH = 3;
|
|
559
|
+
/**
|
|
560
|
+
* Collector B (P9-3): everything uncommitted at `rootDir` —
|
|
561
|
+
* staged, unstaged and untracked alike — with no branch-name convention and no
|
|
562
|
+
* integration branch, so it works on `main` or any ordinary branch.
|
|
563
|
+
*
|
|
564
|
+
* `-z` rather than plain `--porcelain`: git quotes and backslash-escapes paths
|
|
565
|
+
* with spaces or non-ASCII in the default format, and a quoted path silently
|
|
566
|
+
* fails to match a claim glob it should have matched. `--untracked-files=all`
|
|
567
|
+
* because a role's first write is usually a *new* file, and the default
|
|
568
|
+
* `normal` mode would collapse a whole new directory into one entry.
|
|
569
|
+
*
|
|
570
|
+
* Renames report BOTH paths (git emits the new one, then the source): moving a
|
|
571
|
+
* file out of a write root touches two paths, and reporting only the
|
|
572
|
+
* destination hides the more serious half.
|
|
573
|
+
*
|
|
574
|
+
* Paths come back repo-root-relative — git's porcelain format is anchored to
|
|
575
|
+
* the repo root regardless of cwd — which is the form claims and write roots
|
|
576
|
+
* are written in, so a subdirectory root is safe to pass.
|
|
577
|
+
*/
|
|
578
|
+
export function collectWorkingTreeChanges(rootDir, options = {}) {
|
|
579
|
+
const statusOutput = runGitRaw(rootDir, [
|
|
580
|
+
'status',
|
|
581
|
+
'--porcelain=v1',
|
|
582
|
+
'-z',
|
|
583
|
+
'--untracked-files=all',
|
|
584
|
+
]);
|
|
585
|
+
const fields = statusOutput.split('\0');
|
|
586
|
+
const changed = [];
|
|
587
|
+
for (let i = 0; i < fields.length; i++) {
|
|
588
|
+
const field = fields[i];
|
|
589
|
+
if (field.length <= PORCELAIN_STATUS_WIDTH)
|
|
590
|
+
continue;
|
|
591
|
+
const status = field.slice(0, 2);
|
|
592
|
+
changed.push(field.slice(PORCELAIN_STATUS_WIDTH));
|
|
593
|
+
if (status.startsWith('R') || status.startsWith('C')) {
|
|
594
|
+
const source = fields[i + 1];
|
|
595
|
+
if (source)
|
|
596
|
+
changed.push(source);
|
|
597
|
+
i++;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (options.since !== undefined) {
|
|
601
|
+
let diffOutput;
|
|
602
|
+
try {
|
|
603
|
+
diffOutput = runGit(rootDir, ['diff', '--name-only', options.since, '--']);
|
|
604
|
+
}
|
|
605
|
+
catch (cause) {
|
|
606
|
+
throw new ClaimsError('claims.cannot-resolve-since-ref', `git could not diff against "${options.since}": ${cause.message}`, { since: options.since, rootDir });
|
|
607
|
+
}
|
|
608
|
+
for (const file of diffOutput.split('\n')) {
|
|
609
|
+
const trimmed = file.trim();
|
|
610
|
+
if (trimmed.length > 0)
|
|
611
|
+
changed.push(trimmed);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return [...new Set(changed)];
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Did an agent working in a task worktree stay inside its claims? Collector A
|
|
618
|
+
* plus the classifier.
|
|
619
|
+
*/
|
|
620
|
+
export function postRunCheck(worktreeDir, claims) {
|
|
621
|
+
return classifyChanges(collectCommittedChanges(worktreeDir), claims);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Did a role that writes *outside* a worktree stay inside its write root
|
|
625
|
+
* (P9-3)? Collector B plus the classifier. The planner
|
|
626
|
+
* (`factory/specs/active/<epic-id>/**`) and the scribe (`state/lessons/**`)
|
|
627
|
+
* are the two: before this, their write roots existed as a sentence in a
|
|
628
|
+
* template and nothing else, and the `/bs` contract could only tell the
|
|
629
|
+
* operator to read `git status` themselves.
|
|
630
|
+
*/
|
|
631
|
+
export function writeRootCheck(rootDir, writeRoots, options = {}) {
|
|
632
|
+
return classifyChanges(collectWorkingTreeChanges(rootDir, options), writeRoots, 'contract.write-root-violation');
|
|
633
|
+
}
|