@juzser/blacksmith 0.1.0 → 0.1.1
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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +1 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +103 -0
- package/.claude/skills/bs/audit.md +250 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +33 -0
- package/.claude/skills/bs/plan.md +155 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +325 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +12 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +261 -0
- package/README.md +119 -65
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/cli.js +107 -7
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +14 -0
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +38 -4
- package/factory/orchestrator/dist/harness.js +367 -0
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +167 -15
- package/factory/orchestrator/dist/plan.js +1 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +24 -5
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +6 -3
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/package.json +8 -6
|
@@ -232,6 +232,36 @@ export async function recordJudgeDispatch(input, ctx, opts = {}) {
|
|
|
232
232
|
declared_artifact: input.artifactPath,
|
|
233
233
|
}, input.taskId, ctx, opts);
|
|
234
234
|
}
|
|
235
|
+
/** The one judge whose artifact is a verdict document rather than a findings list. */
|
|
236
|
+
const GRADER_ROLE = 'grader';
|
|
237
|
+
/**
|
|
238
|
+
* Count what a grader's result document holds against the task.
|
|
239
|
+
*
|
|
240
|
+
* The grader's contract is `state/results/<task-id>.grader-r<round>.json`:
|
|
241
|
+
* `{run_status, structured_output: {round, criteria[], overall}}`, the file
|
|
242
|
+
* `gate run --grader` reads. Its findings are the criteria it did not pass —
|
|
243
|
+
* `fail` and `partial` both send the task back — so that is the count the
|
|
244
|
+
* report carries. A `dead` grader is the grader's own word for "nothing here
|
|
245
|
+
* is gradable" (`checkGraderVerdict`): it reported, and found nothing to
|
|
246
|
+
* count. Anything else that is not a `criteria` array is some other shape and
|
|
247
|
+
* gets `undefined`, so the caller refuses it the way it refuses every other
|
|
248
|
+
* non-list. Schema validation stays with the gate: this decides whether the
|
|
249
|
+
* grader *reported*, not whether its verdict holds.
|
|
250
|
+
*/
|
|
251
|
+
function graderFindingCount(parsed) {
|
|
252
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
253
|
+
return undefined;
|
|
254
|
+
const document = parsed;
|
|
255
|
+
if (document.run_status === 'dead')
|
|
256
|
+
return 0;
|
|
257
|
+
const structured = document.structured_output;
|
|
258
|
+
if (typeof structured !== 'object' || structured === null)
|
|
259
|
+
return undefined;
|
|
260
|
+
const criteria = structured.criteria;
|
|
261
|
+
if (!Array.isArray(criteria))
|
|
262
|
+
return undefined;
|
|
263
|
+
return criteria.filter((c) => !(typeof c === 'object' && c !== null && c.status === 'pass')).length;
|
|
264
|
+
}
|
|
235
265
|
/**
|
|
236
266
|
* Read a judge's artifact and answer how many findings it holds.
|
|
237
267
|
*
|
|
@@ -239,10 +269,16 @@ export async function recordJudgeDispatch(input, ctx, opts = {}) {
|
|
|
239
269
|
* different things to do about it: the file is not there (re-poke the agent),
|
|
240
270
|
* it is there and is prose (the agent narrated instead of reporting), it is
|
|
241
271
|
* there and parses but is not a findings list (the agent wrote some other
|
|
242
|
-
* shape
|
|
243
|
-
*
|
|
272
|
+
* shape).
|
|
273
|
+
*
|
|
274
|
+
* The grader is the one role whose declared shape is not a list: it writes a
|
|
275
|
+
* verdict document, and refusing that document left its turn closable only by
|
|
276
|
+
* a hand-written evidence file (FD-1, csb-audit-1). For `role === 'grader'`
|
|
277
|
+
* the grader result document is accepted beside the list, and the count is
|
|
278
|
+
* its non-pass criteria. No other role gets that reading — a reviewer that
|
|
279
|
+
* wrote a verdict wrote the wrong shape.
|
|
244
280
|
*/
|
|
245
|
-
export function readJudgeArtifact(artifactPath) {
|
|
281
|
+
export function readJudgeArtifact(artifactPath, role) {
|
|
246
282
|
let raw;
|
|
247
283
|
try {
|
|
248
284
|
raw = readFileSync(artifactPath, 'utf8');
|
|
@@ -257,10 +293,15 @@ export function readJudgeArtifact(artifactPath) {
|
|
|
257
293
|
catch (err) {
|
|
258
294
|
throw new JudgeError('judges.artifact-unparseable', `Judge artifact "${artifactPath}" is not JSON. Prose in the artifact slot is the wave-4 failure exactly: fluent text that reads like a verdict and is not one.`, { artifact_path: artifactPath, cause: err instanceof Error ? err.message : String(err) });
|
|
259
295
|
}
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
296
|
+
if (Array.isArray(parsed))
|
|
297
|
+
return parsed.length;
|
|
298
|
+
const graderCount = role === GRADER_ROLE ? graderFindingCount(parsed) : undefined;
|
|
299
|
+
if (graderCount !== undefined)
|
|
300
|
+
return graderCount;
|
|
301
|
+
const accepted = role === GRADER_ROLE
|
|
302
|
+
? 'a findings-evidence array or the grader result document ({run_status, structured_output: {criteria: [...]}})'
|
|
303
|
+
: 'a findings-evidence array';
|
|
304
|
+
throw new JudgeError('judges.artifact-not-a-list', `Judge artifact "${artifactPath}" parsed to ${parsed === null ? 'null' : typeof parsed}, not ${accepted}. An empty review is "[]", written out.`, { artifact_path: artifactPath, agent_role: role ?? null });
|
|
264
305
|
}
|
|
265
306
|
function notDispatchedMessage(input, turn, forRole) {
|
|
266
307
|
if (forRole.length > 1) {
|
|
@@ -291,7 +332,7 @@ export async function recordJudgeReport(input, ctx, opts = {}) {
|
|
|
291
332
|
});
|
|
292
333
|
}
|
|
293
334
|
const artifactPath = input.noFindings ? null : (input.artifactPath ?? turn.declaredArtifact);
|
|
294
|
-
const findingCount = artifactPath === null ? 0 : readJudgeArtifact(artifactPath)
|
|
335
|
+
const findingCount = artifactPath === null ? 0 : readJudgeArtifact(artifactPath, input.role);
|
|
295
336
|
await emit(JUDGE_REPORT_EVENT_TYPE, {
|
|
296
337
|
agent_role: input.role,
|
|
297
338
|
round: turn.round,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// a small fixed marker list (never/not/don't/...) — a
|
|
10
10
|
// same-shingle-shape contradiction phrased another way (e.g. "avoid"
|
|
11
11
|
// vs "prefer") still auto-novelty-rejects silently. Documented, not
|
|
12
|
-
// hidden — see docs/guide/operator-guide.md.
|
|
12
|
+
// hidden — see docs/guide/operator-guide/lessons-and-daemon.md.
|
|
13
13
|
// (b) compileLessons() — approved lessons -> factory/policies/lessons.md,
|
|
14
14
|
// round-tripping through severity.ts's parseLessons() (the same-mistake
|
|
15
15
|
// gate's own reader). Every bullet value is rendered through
|
|
@@ -33,7 +33,7 @@ import { isOperatorActor } from './actors.js';
|
|
|
33
33
|
import { foldLessons } from './db/projector.js';
|
|
34
34
|
import { SmithError } from './errors.js';
|
|
35
35
|
import { appendEvent, readLineageEvents } from './events.js';
|
|
36
|
-
import { AGENTS_DIR,
|
|
36
|
+
import { AGENTS_DIR, lessonsReadPath } from './paths.js';
|
|
37
37
|
import { LESSON_SCOPES, parseLessons } from './severity.js';
|
|
38
38
|
import { loadTaxonomy, validateTag } from './taxonomy.js';
|
|
39
39
|
export class LessonsError extends SmithError {
|
|
@@ -128,7 +128,7 @@ export function effectiveNoveltyThreshold(a, b, threshold, shingleSize = DEFAULT
|
|
|
128
128
|
* This is NOT real negation detection — it will miss a prohibition phrased
|
|
129
129
|
* without one of these words (e.g. "avoid" vs "prefer"). Residual limitation,
|
|
130
130
|
* documented rather than hidden: see this file's header comment and
|
|
131
|
-
* docs/guide/operator-guide.md.
|
|
131
|
+
* docs/guide/operator-guide/lessons-and-daemon.md.
|
|
132
132
|
*
|
|
133
133
|
* What is compared is WHETHER a statement prohibits, not which word it
|
|
134
134
|
* prohibits with. English spells a prohibition four ways in this list alone,
|
|
@@ -588,7 +588,7 @@ export function renderLessonBlock(role, scopes, lessons) {
|
|
|
588
588
|
*/
|
|
589
589
|
export function lessonsForDispatch(role, claimPaths, opts = {}) {
|
|
590
590
|
const scopes = scopesForRole(role, opts);
|
|
591
|
-
const lessonsPath = opts.lessonsPath ??
|
|
591
|
+
const lessonsPath = opts.lessonsPath ?? lessonsReadPath();
|
|
592
592
|
let markdown;
|
|
593
593
|
try {
|
|
594
594
|
markdown = readFileSync(lessonsPath, 'utf8');
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { SmithError } from './errors.js';
|
|
21
|
-
import { PROJECTS_DIR,
|
|
21
|
+
import { PROJECTS_DIR, roadmapReadPath, SCAFFOLD_DIR, WORKSPACES_DIR } from './paths.js';
|
|
22
22
|
import { loadRoadmap, ownsEpic, readRoadmapText, roadmapDeclaresId, } from './roadmap.js';
|
|
23
23
|
import { copyTemplateDir, mergePackageJson, PACKAGE_FRAGMENT_NAME, validateProjectName, } from './scaffold.js';
|
|
24
|
+
import { ensureWritableRoadmap } from './workroot.js';
|
|
24
25
|
export class McpError extends SmithError {
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
@@ -293,7 +294,7 @@ export function addMcpSurface(opts) {
|
|
|
293
294
|
* before the project's final milestone, and `smith epic close` refuses while
|
|
294
295
|
* checkManifest is red.
|
|
295
296
|
*/
|
|
296
|
-
export function registerMcpMilestone(projectName, roadmapPath =
|
|
297
|
+
export function registerMcpMilestone(projectName, roadmapPath = ensureWritableRoadmap()) {
|
|
297
298
|
const id = `${projectName}-${MCP_MILESTONE_SUFFIX}`;
|
|
298
299
|
const text = readRoadmapText(roadmapPath);
|
|
299
300
|
// roadmapDeclaresId, not `text.includes('- id: ' + id)`: the substring is
|
|
@@ -450,7 +451,7 @@ export function runMcpCheck(opts) {
|
|
|
450
451
|
validateProjectName(opts.projectName);
|
|
451
452
|
const { targetDir, source } = resolveMcpTarget(opts);
|
|
452
453
|
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
|
453
|
-
const roadmapPath = opts.roadmapPath ??
|
|
454
|
+
const roadmapPath = opts.roadmapPath ?? roadmapReadPath();
|
|
454
455
|
if (!existsSync(manifestPath)) {
|
|
455
456
|
throw new McpError('mcp.no-surface', `${manifestPath} does not exist. Run \`smith mcp init ${opts.projectName}\` before checking the surface.`, { manifestPath, projectName: opts.projectName });
|
|
456
457
|
}
|
|
@@ -1,33 +1,159 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
// factory/orchestrator/src/paths.ts -> repo root is three levels up.
|
|
4
5
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
/**
|
|
7
|
+
* What the factory *is*: the policies, schemas, scaffold, migrations and role
|
|
8
|
+
* templates the CLI reads and never writes. Derived from this module's own
|
|
9
|
+
* location, so under an npm install it is the package directory inside
|
|
10
|
+
* `node_modules` -- which is the right answer, because every one of those
|
|
11
|
+
* assets ships in the tarball. `packaging.test.ts` holds that claim.
|
|
12
|
+
*/
|
|
5
13
|
export const REPO_ROOT = path.resolve(here, '..', '..', '..');
|
|
6
14
|
/**
|
|
7
|
-
*
|
|
15
|
+
* Is this module running out of the blacksmith clone, or out of an installed
|
|
16
|
+
* copy of the package?
|
|
17
|
+
*
|
|
18
|
+
* `.git` is the signal because a tarball never carries one: npm strips it, and
|
|
19
|
+
* `package.json#files` could not ship it if it tried. A linked worktree spells
|
|
20
|
+
* it as a file rather than a directory, which `existsSync` reads either way.
|
|
21
|
+
*/
|
|
22
|
+
const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
|
|
23
|
+
/**
|
|
24
|
+
* Where the work root goes when the CLI is not standing in a clone.
|
|
25
|
+
*
|
|
26
|
+
* A dot-directory rather than a bare `state/` and `factory/`, because under an
|
|
27
|
+
* install the operator's own project is the working directory and two
|
|
28
|
+
* top-level directories appearing in it are the tool making itself at home in
|
|
29
|
+
* somebody else's repo. One is enough, and it is the same name the audit store
|
|
30
|
+
* already uses (`<project>/.blacksmith/findings.jsonl`).
|
|
31
|
+
*/
|
|
32
|
+
const WORK_DIR_NAME = '.blacksmith';
|
|
33
|
+
/**
|
|
34
|
+
* Where everything this CLI *writes* is rooted -- state, epic plans, the
|
|
35
|
+
* operator's `.env`.
|
|
36
|
+
*
|
|
37
|
+
* Pure, and exported for the test: the constants below call it once at module
|
|
38
|
+
* load, but the install case can only be stated by a test that passes its own
|
|
39
|
+
* roots in. `env.SMITH_HOME` wins outright; otherwise a clone keeps writing
|
|
40
|
+
* into itself, and an install writes beside the operator instead of into
|
|
41
|
+
* `node_modules/@juzser/blacksmith`, which the next `npm i` replaces wholesale.
|
|
42
|
+
*
|
|
43
|
+
* The layout *under* the root is identical in both cases on purpose. A path
|
|
44
|
+
* like `state/events/<session>.jsonl` is spelled in runbooks, in agent
|
|
45
|
+
* templates and in `guardrails.yml`'s write roots, and all of those stay true
|
|
46
|
+
* if only the anchor moves.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveWorkRoot(repoRoot, cwd, env, isClone) {
|
|
49
|
+
const declared = env.SMITH_HOME?.trim();
|
|
50
|
+
// An empty `SMITH_HOME=` is how an unset variable gets spelled by accident in
|
|
51
|
+
// a shell profile or a CI matrix; resolved literally it would mean cwd.
|
|
52
|
+
if (declared)
|
|
53
|
+
return path.resolve(cwd, declared);
|
|
54
|
+
return isClone ? repoRoot : path.join(cwd, WORK_DIR_NAME);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Where `smith new` puts a project when `--target-dir` says nothing.
|
|
58
|
+
*
|
|
59
|
+
* Beside the clone, never inside it (D-42) -- but the parent of an *installed*
|
|
60
|
+
* package is `node_modules/@juzser`, which is not a place a project may be
|
|
61
|
+
* written. Without a clone "beside the clone" has no referent, and the honest
|
|
62
|
+
* substitute is the directory the operator is standing in.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveProjectsDir(repoRoot, cwd, isClone) {
|
|
65
|
+
return isClone ? path.dirname(repoRoot) : cwd;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The third kind of path: a file the factory *ships a default of* and the
|
|
69
|
+
* operator then edits.
|
|
70
|
+
*
|
|
71
|
+
* Most constants below are one thing or the other -- an asset the CLI only
|
|
72
|
+
* reads, which ships and hangs off REPO_ROOT, or state the CLI writes, which
|
|
73
|
+
* must not ship and hangs off WORK_ROOT. `factory/specs/roadmap.md` and
|
|
74
|
+
* `factory/policies/stack.yml` are both: each ships as a starting point and is
|
|
75
|
+
* then written, the roadmap by `smith new` and stack.yml by the operator
|
|
76
|
+
* answering INSTALL.md Step 5 "in place".
|
|
77
|
+
*
|
|
78
|
+
* Anchored on REPO_ROOT, as both were, those writes land inside the installed
|
|
79
|
+
* package, which the next `npm i` replaces wholesale -- so the answers an
|
|
80
|
+
* operator typed survive until their first upgrade and then silently do not.
|
|
81
|
+
*
|
|
82
|
+
* So each is declared twice: `X_DEFAULT_PATH` under REPO_ROOT, which ships and
|
|
83
|
+
* is only ever read, and `X_PATH` under WORK_ROOT, which is written. This
|
|
84
|
+
* function is the read side -- the operator's copy when it exists, the shipped
|
|
85
|
+
* default before anyone has answered. `exists` is a parameter because the
|
|
86
|
+
* install case is only statable by a test that supplies its own.
|
|
87
|
+
*
|
|
88
|
+
* In a clone the two roots are one directory, so both halves name the same
|
|
89
|
+
* file and every read and write goes where it always went.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveOverlayRead(personal, shipped, exists = existsSync) {
|
|
92
|
+
return exists(personal) ? personal : shipped;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Which root the *written* half of an overlay hangs off -- and the one place
|
|
96
|
+
* the work root is deliberately not it.
|
|
97
|
+
*
|
|
98
|
+
* `SMITH_HOME` beats `isClone` in `resolveWorkRoot`, on purpose: it is how an
|
|
99
|
+
* operator says "keep one fixed home for several projects", and state is
|
|
100
|
+
* exactly the thing that should follow them there. The two overlay files are
|
|
101
|
+
* not state. `factory/specs/roadmap.md` and `factory/policies/stack.yml` are
|
|
102
|
+
* tracked files in this repo -- declarations about *this* checkout, committed
|
|
103
|
+
* beside the code they describe -- and a clone that redirected them to
|
|
104
|
+
* `$SMITH_HOME` would leave its own tracked copies still sitting in the editor
|
|
105
|
+
* looking authoritative while every reader had quietly moved on. Split brain,
|
|
106
|
+
* no error.
|
|
107
|
+
*
|
|
108
|
+
* So a clone keeps its declarations, whatever `SMITH_HOME` says about its
|
|
109
|
+
* state, and only an install -- which has no tracked copy to orphan, and whose
|
|
110
|
+
* REPO_ROOT is a directory `npm i` replaces -- moves them to the work root.
|
|
111
|
+
* This is what makes "an existing checkout changes nothing" true without a
|
|
112
|
+
* condition attached to it.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveOverlayRoot(repoRoot, workRoot, isClone) {
|
|
115
|
+
return isClone ? repoRoot : workRoot;
|
|
116
|
+
}
|
|
117
|
+
export const WORK_ROOT = resolveWorkRoot(REPO_ROOT, process.cwd(), process.env, IS_CLONE);
|
|
118
|
+
/** Where the operator's copy of a shipped-then-edited file lives. */
|
|
119
|
+
export const OVERLAY_ROOT = resolveOverlayRoot(REPO_ROOT, WORK_ROOT, IS_CLONE);
|
|
120
|
+
/**
|
|
121
|
+
* The operator's own `.env`, read at CLI start by `loadDotEnv`. Gitignored, and
|
|
8
122
|
* the one place `docs/runbooks/providers.md` tells an operator to put a
|
|
9
|
-
* provider key. Anchored on
|
|
10
|
-
* from a worktree, and the key belongs to the clone
|
|
123
|
+
* provider key. Anchored on the work root, not on cwd: an agent runs `smith`
|
|
124
|
+
* from a worktree, and the key belongs to the operator's clone -- and not on
|
|
125
|
+
* REPO_ROOT either, since under an install that is a file `npm i` deletes.
|
|
11
126
|
*/
|
|
12
|
-
export const DOTENV_PATH = path.join(
|
|
127
|
+
export const DOTENV_PATH = path.join(WORK_ROOT, '.env');
|
|
13
128
|
export const TAXONOMY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'taxonomy.yml');
|
|
14
129
|
export const SCHEMA_DIR = path.join(REPO_ROOT, 'factory', 'specs', 'schema');
|
|
15
|
-
export const SPECS_ACTIVE_DIR = path.join(
|
|
16
|
-
export const STATE_EVENTS_DIR = path.join(
|
|
130
|
+
export const SPECS_ACTIVE_DIR = path.join(WORK_ROOT, 'factory', 'specs', 'active');
|
|
131
|
+
export const STATE_EVENTS_DIR = path.join(WORK_ROOT, 'state', 'events');
|
|
17
132
|
/** Where a task's declared artifacts must live: `state/artifacts/<task-id>/` (P9-22). */
|
|
18
|
-
export const STATE_ARTIFACTS_DIR = path.join(
|
|
133
|
+
export const STATE_ARTIFACTS_DIR = path.join(WORK_ROOT, 'state', 'artifacts');
|
|
19
134
|
export const WORKTREE_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'worktree.yml');
|
|
20
|
-
export const STATE_DB_PATH = path.join(
|
|
135
|
+
export const STATE_DB_PATH = path.join(WORK_ROOT, 'state', 'smith.db');
|
|
21
136
|
/** The background watcher's lock and last tick: `state/daemon/{daemon.pid,status.json}`. */
|
|
22
|
-
export const STATE_DAEMON_DIR = path.join(
|
|
137
|
+
export const STATE_DAEMON_DIR = path.join(WORK_ROOT, 'state', 'daemon');
|
|
23
138
|
export const DB_MIGRATIONS_DIR = path.join(REPO_ROOT, 'factory', 'orchestrator', 'drizzle');
|
|
24
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The roadmap as it ships: one heading per milestone, and the file `smith new`
|
|
141
|
+
* appends to. Read-only under this name; see ROADMAP_PATH for the copy that is
|
|
142
|
+
* written and `resolveOverlayRead` for why there are two.
|
|
143
|
+
*/
|
|
144
|
+
export const ROADMAP_DEFAULT_PATH = path.join(REPO_ROOT, 'factory', 'specs', 'roadmap.md');
|
|
145
|
+
/** The operator's roadmap -- the one `registerProjectInRoadmap` writes into. */
|
|
146
|
+
export const ROADMAP_PATH = path.join(OVERLAY_ROOT, 'factory', 'specs', 'roadmap.md');
|
|
147
|
+
/** The roadmap to read: the operator's if they have one, the shipped one if not. */
|
|
148
|
+
export function roadmapReadPath() {
|
|
149
|
+
return resolveOverlayRead(ROADMAP_PATH, ROADMAP_DEFAULT_PATH);
|
|
150
|
+
}
|
|
25
151
|
export const SCAFFOLD_DIR = path.join(REPO_ROOT, 'factory', 'scaffold');
|
|
26
152
|
/**
|
|
27
153
|
* `workspaces/` inside this clone. Still a legal place to keep a project, and
|
|
28
154
|
* still searched for ones already there -- no longer where a new one goes.
|
|
29
155
|
*/
|
|
30
|
-
export const WORKSPACES_DIR = path.join(
|
|
156
|
+
export const WORKSPACES_DIR = path.join(WORK_ROOT, 'workspaces');
|
|
31
157
|
/**
|
|
32
158
|
* Where a project lands when nothing says otherwise: beside this clone.
|
|
33
159
|
*
|
|
@@ -39,9 +165,25 @@ export const WORKSPACES_DIR = path.join(REPO_ROOT, 'workspaces');
|
|
|
39
165
|
* it there on their own, since worktree.ts places them next to the project it
|
|
40
166
|
* is handed rather than at a path of its own choosing.
|
|
41
167
|
*/
|
|
42
|
-
export const PROJECTS_DIR =
|
|
168
|
+
export const PROJECTS_DIR = resolveProjectsDir(REPO_ROOT, process.cwd(), IS_CLONE);
|
|
43
169
|
export const SCHEDULER_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'scheduler.yml');
|
|
44
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The compiled lessons as they ship. Read-only under this name; see
|
|
172
|
+
* LESSONS_MD_PATH for the copy `smith lessons compile` writes.
|
|
173
|
+
*/
|
|
174
|
+
export const LESSONS_MD_DEFAULT_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'lessons.md');
|
|
175
|
+
/**
|
|
176
|
+
* The operator's compiled lessons -- the third file that is both shipped and
|
|
177
|
+
* written. Nobody types it, so it is not an answer in the sense the other two
|
|
178
|
+
* are; it is this factory's accumulated memory, compiled from the operator's
|
|
179
|
+
* own event log by `smith lessons compile`, and an upgrade that resets it
|
|
180
|
+
* loses exactly as much.
|
|
181
|
+
*/
|
|
182
|
+
export const LESSONS_MD_PATH = path.join(OVERLAY_ROOT, 'factory', 'policies', 'lessons.md');
|
|
183
|
+
/** The lessons to read: the operator's if they have compiled any, the shipped set if not. */
|
|
184
|
+
export function lessonsReadPath() {
|
|
185
|
+
return resolveOverlayRead(LESSONS_MD_PATH, LESSONS_MD_DEFAULT_PATH);
|
|
186
|
+
}
|
|
45
187
|
/** The shipped role templates — read at dispatch for their `<!-- LESSONS:<scope> -->` markers (P9-2). */
|
|
46
188
|
export const AGENTS_DIR = path.join(REPO_ROOT, '.claude', 'agents');
|
|
47
189
|
export const CROSSCHECK_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'crosscheck.yml');
|
|
@@ -70,7 +212,17 @@ export const SEVERITY_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies',
|
|
|
70
212
|
* and the scaffolder reads it instead of the prose in docs/standards/stack.md
|
|
71
213
|
* — which described one operator's stack as if it were everyone's.
|
|
72
214
|
*/
|
|
73
|
-
export const
|
|
215
|
+
export const STACK_POLICY_DEFAULT_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'stack.yml');
|
|
216
|
+
/**
|
|
217
|
+
* The operator's own answers. INSTALL.md Step 5 has them change the values in
|
|
218
|
+
* place, so this is the copy that gets edited -- under the work root, where an
|
|
219
|
+
* upgrade cannot reach it.
|
|
220
|
+
*/
|
|
221
|
+
export const STACK_POLICY_PATH = path.join(OVERLAY_ROOT, 'factory', 'policies', 'stack.yml');
|
|
222
|
+
/** The stack answers to read: the operator's if they have any, the shipped questionnaire if not. */
|
|
223
|
+
export function stackPolicyReadPath() {
|
|
224
|
+
return resolveOverlayRead(STACK_POLICY_PATH, STACK_POLICY_DEFAULT_PATH);
|
|
225
|
+
}
|
|
74
226
|
/**
|
|
75
227
|
* Open judge sandbox leases, one file per worktree (sandbox.ts).
|
|
76
228
|
*
|
|
@@ -79,4 +231,4 @@ export const STACK_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'st
|
|
|
79
231
|
* judge cannot revoke its own guard by deleting a file it is not allowed to
|
|
80
232
|
* reach.
|
|
81
233
|
*/
|
|
82
|
-
export const SANDBOX_LEASE_DIR = path.join(
|
|
234
|
+
export const SANDBOX_LEASE_DIR = path.join(WORK_ROOT, 'state', 'sandboxes');
|
|
@@ -204,7 +204,7 @@ export function validatePlan(plan, opts = {}) {
|
|
|
204
204
|
for (const field of unreadTaskBudgetFields(budget)) {
|
|
205
205
|
errors.push({
|
|
206
206
|
path: `/tasks/${t.task_id}/budget/${field}`,
|
|
207
|
-
message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it, so declaring it here states a limit the task will never be held to.
|
|
207
|
+
message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it per task, so declaring it here states a limit the task will never be held to. The turn ceiling is the role template's maxTurns (.claude/agents/<role>.md), enforced by the harness per role; the dispatch prompt restates that number (agent-interviews.md M-4, corrected 2026-09-11).`,
|
|
208
208
|
});
|
|
209
209
|
}
|
|
210
210
|
// D-7: the ref is what result.schema.json defers structured_output's shape
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadBudgetPolicy } from './budgets.js';
|
|
2
2
|
import { loadCrosscheckPolicy, } from './crosscheck.js';
|
|
3
3
|
import { appendEvent } from './events.js';
|
|
4
|
-
import { livePlanTasks, loadPlan, planRefTaskId, } from './plan.js';
|
|
4
|
+
import { livePlanTasks, loadPlan, PlanError, planRefTaskId, } from './plan.js';
|
|
5
5
|
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
6
6
|
// Defensive field readers: TaskSpecRecord is Record<string, unknown>-ish
|
|
7
7
|
// (schema-validated elsewhere, not here), so every field read below narrows
|
|
@@ -27,6 +27,10 @@ function taskNonfunctionalClauses(t) {
|
|
|
27
27
|
const clauses = contract.nonfunctional_clauses;
|
|
28
28
|
return Array.isArray(clauses) ? clauses.filter((c) => typeof c === 'string') : [];
|
|
29
29
|
}
|
|
30
|
+
function taskAcceptanceCriteria(t) {
|
|
31
|
+
const criteria = t.acceptance_criteria;
|
|
32
|
+
return Array.isArray(criteria) ? criteria.filter((c) => typeof c === 'string') : [];
|
|
33
|
+
}
|
|
30
34
|
function taskConfidence(t) {
|
|
31
35
|
return typeof t.confidence === 'number' ? t.confidence : undefined;
|
|
32
36
|
}
|
|
@@ -185,11 +189,16 @@ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
|
185
189
|
* Pure prompt builder, mirrors quorum.ts's findingJudgeRequest() and
|
|
186
190
|
* epic.ts's epicVerdictJudgeRequest() style and trust boundary: the prompt
|
|
187
191
|
* carries the CLAIM ONLY (epic id, plan version, task ids/objectives/
|
|
188
|
-
* case/token budgets,
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* a
|
|
192
|
+
* case/token budgets, each task's acceptance criteria and nonfunctional
|
|
193
|
+
* clauses, edge count, fired triggers with their evidence) — never file
|
|
194
|
+
* contents, never a diff. The criteria are part of the claim, not evidence
|
|
195
|
+
* for it: FD-46 (csb-signing-policy-1) had both external critics refute
|
|
196
|
+
* plan-v1 for missing a case its task already listed as a criterion, because
|
|
197
|
+
* the prompt showed them the objective and nothing under it. The tasks it
|
|
198
|
+
* lists are `livePlanTasks`, not `plan.tasks`: this prompt is the whole of
|
|
199
|
+
* what the critic sees, so a superseded record listed here is an objective
|
|
200
|
+
* the plan withdrew, offered to a judge as the plan's current ask (D-185).
|
|
201
|
+
* Carries
|
|
193
202
|
* asymmetric_roles.critic_mandate ("refute, not confirm"): the judge's
|
|
194
203
|
* mandate is to REFUTE the plan's soundness; it critiques the plan, it does
|
|
195
204
|
* not authorize any change to it.
|
|
@@ -201,7 +210,18 @@ export function planQuorumJudgeRequest(plan, triggers, budget) {
|
|
|
201
210
|
.map((t) => {
|
|
202
211
|
const caseValue = taskCase(t) ?? '(no case)';
|
|
203
212
|
const objective = typeof t.objective === 'string' ? t.objective : '(no objective)';
|
|
204
|
-
|
|
213
|
+
const criteria = taskAcceptanceCriteria(t);
|
|
214
|
+
const clauses = taskNonfunctionalClauses(t);
|
|
215
|
+
return [
|
|
216
|
+
` ${t.task_id} [${caseValue}, ${taskTokens(t)} tokens]: ${objective}`,
|
|
217
|
+
' acceptance criteria:',
|
|
218
|
+
...(criteria.length > 0
|
|
219
|
+
? criteria.map((c) => ` - ${c}`)
|
|
220
|
+
: [' (no acceptance criteria)']),
|
|
221
|
+
...(clauses.length > 0
|
|
222
|
+
? [' nonfunctional clauses:', ...clauses.map((c) => ` - ${c}`)]
|
|
223
|
+
: []),
|
|
224
|
+
].join('\n');
|
|
205
225
|
})
|
|
206
226
|
.join('\n')
|
|
207
227
|
: ' (no tasks)';
|
|
@@ -299,7 +319,15 @@ async function emitPlanQuorumDecision(args, ctx, opts) {
|
|
|
299
319
|
* operator must look before approving the plan.
|
|
300
320
|
*/
|
|
301
321
|
export async function runPlanQuorum(input, ctx, opts = {}) {
|
|
302
|
-
const plan = loadPlan(input.epicId, input.version, input.planOpts);
|
|
322
|
+
const plan = input.plan ?? loadPlan(input.epicId, input.version, input.planOpts);
|
|
323
|
+
if (plan.epic_id !== input.epicId || plan.version !== input.version) {
|
|
324
|
+
throw new PlanError('plan.identity-mismatch', `The plan handed to the quorum is ${plan.epic_id} version ${plan.version}, but the command names --epic ${input.epicId} --plan-version ${input.version}. One quorum record describes one plan; say which.`, {
|
|
325
|
+
epic_id: plan.epic_id,
|
|
326
|
+
version: plan.version,
|
|
327
|
+
expected_epic_id: input.epicId,
|
|
328
|
+
expected_version: input.version,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
303
331
|
const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
|
|
304
332
|
const epicCapTokens = input.epicCapTokens ?? loadBudgetPolicy().epic.capTokens;
|
|
305
333
|
const triggers = evaluatePlanQuorumTriggers(plan, policy.planQuorum, epicCapTokens, {
|
|
@@ -7,6 +7,12 @@ import { extractAndValidate } from './schema-validate.js';
|
|
|
7
7
|
import { ProviderError } from './types.js';
|
|
8
8
|
const NUDGE = '\n\nReturn only valid JSON per schema.';
|
|
9
9
|
const RESPONSE_BODY_ERROR_SNIPPET_LIMIT = 500;
|
|
10
|
+
// Raw answer kept on a rejected verdict: enough head to see what the model
|
|
11
|
+
// started writing, enough tail to see where it stopped. A truncated JSON
|
|
12
|
+
// answer and a prose answer look alike in a bare `no-json-found`.
|
|
13
|
+
const CONTENT_SNIPPET_HEAD = 300;
|
|
14
|
+
const CONTENT_SNIPPET_TAIL = 200;
|
|
15
|
+
const CONTENT_SNIPPET_JOINER = ' … ';
|
|
10
16
|
function resolveApiKey(config, provider) {
|
|
11
17
|
const key = process.env[config.apiKeyEnv];
|
|
12
18
|
if (!key) {
|
|
@@ -14,9 +20,31 @@ function resolveApiKey(config, provider) {
|
|
|
14
20
|
}
|
|
15
21
|
return key;
|
|
16
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The raw answer as it may appear in an error: the API key scrubbed the same
|
|
25
|
+
* way a non-OK body is, and bounded to head + tail so a 64K truncation shows
|
|
26
|
+
* its last bytes instead of nothing.
|
|
27
|
+
*/
|
|
28
|
+
function contentSnippet(content, apiKey) {
|
|
29
|
+
const scrubbed = content.split(apiKey).join('[REDACTED]');
|
|
30
|
+
if (scrubbed.length <= CONTENT_SNIPPET_HEAD + CONTENT_SNIPPET_TAIL)
|
|
31
|
+
return scrubbed;
|
|
32
|
+
return `${scrubbed.slice(0, CONTENT_SNIPPET_HEAD)}${CONTENT_SNIPPET_JOINER}${scrubbed.slice(-CONTENT_SNIPPET_TAIL)}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a second call with the nudge appended can change anything. It can
|
|
36
|
+
* when the model finished on its own (`stop`) — the prompt is the suspect. It
|
|
37
|
+
* cannot when the model was cut off at `max_tokens`: the same prompt runs to
|
|
38
|
+
* the same cap and bills the same tokens. An absent finish_reason is read as
|
|
39
|
+
* `stop` so a provider that omits the field keeps the retry it always had.
|
|
40
|
+
*/
|
|
41
|
+
function nudgeCanHelp(finishReason) {
|
|
42
|
+
return finishReason === undefined || finishReason === 'stop';
|
|
43
|
+
}
|
|
17
44
|
async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
18
45
|
const controller = new AbortController();
|
|
19
46
|
const timer = setTimeout(() => controller.abort(), budget.timeout_ms);
|
|
47
|
+
const maxTokens = budget.max_output_tokens ?? config.maxTokens;
|
|
20
48
|
let response;
|
|
21
49
|
try {
|
|
22
50
|
response = await fetchImpl(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
@@ -27,6 +55,7 @@ async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
|
27
55
|
messages: [{ role: 'user', content: prompt }],
|
|
28
56
|
stream: false,
|
|
29
57
|
...(config.responseFormatJsonObject ? { response_format: { type: 'json_object' } } : {}),
|
|
58
|
+
...(maxTokens === undefined ? {} : { max_tokens: maxTokens }),
|
|
30
59
|
}),
|
|
31
60
|
signal: controller.signal,
|
|
32
61
|
});
|
|
@@ -65,13 +94,39 @@ async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
|
65
94
|
catch {
|
|
66
95
|
throw new ProviderError('provider.malformed-response', `Provider "${provider}" API judge response was not valid JSON.`, { provider });
|
|
67
96
|
}
|
|
68
|
-
const
|
|
97
|
+
const choice = parsed.choices?.[0];
|
|
98
|
+
const content = choice?.message?.content ?? '';
|
|
69
99
|
return {
|
|
70
100
|
content,
|
|
71
101
|
usage: parsed.usage
|
|
72
102
|
? { input_tokens: parsed.usage.prompt_tokens, output_tokens: parsed.usage.completion_tokens }
|
|
73
103
|
: undefined,
|
|
104
|
+
finishReason: typeof choice?.finish_reason === 'string' ? choice.finish_reason : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The error for an answer that never validated. Two codes, because the two
|
|
109
|
+
* situations are fixed in different places: `output-truncated` (the model
|
|
110
|
+
* stopped at the cap mid-answer) is fixed by a cap or a shorter prompt;
|
|
111
|
+
* `invalid-output` (the model finished and still did not answer the schema)
|
|
112
|
+
* is fixed at the prompt. Both carry the raw answer, scrubbed and bounded, so
|
|
113
|
+
* the operator can tell the two apart in the log instead of guessing from a
|
|
114
|
+
* reason string.
|
|
115
|
+
*/
|
|
116
|
+
function rejectedAnswerError(provider, apiKey, call, result, retried) {
|
|
117
|
+
const details = {
|
|
118
|
+
provider,
|
|
119
|
+
reason: result.reason,
|
|
120
|
+
errors: result.errors,
|
|
121
|
+
finish_reason: call.finishReason,
|
|
122
|
+
content_length: call.content.length,
|
|
123
|
+
content_snippet: contentSnippet(call.content, apiKey),
|
|
124
|
+
retried,
|
|
74
125
|
};
|
|
126
|
+
if (call.finishReason === 'length') {
|
|
127
|
+
return new ProviderError('provider.output-truncated', `Provider "${provider}" API judge stopped at its output cap (finish_reason "length") after ${call.content.length} characters, before the answer closed; not retried. Raise max_tokens for the provider (crosscheck.yml) or --max-output-tokens, or shorten the prompt.`, details);
|
|
128
|
+
}
|
|
129
|
+
return new ProviderError('provider.invalid-output', `Provider "${provider}" API judge returned invalid output${retried ? ' after one retry' : ''}: ${result.reason}.`, details);
|
|
75
130
|
}
|
|
76
131
|
/**
|
|
77
132
|
* Both attempts, added together — the provider billed both, and this is the
|
|
@@ -99,13 +154,15 @@ export async function runApiJudge(provider, config, request, fetchImpl = fetch)
|
|
|
99
154
|
let call = await callOnce(provider, config, apiKey, request.prompt, request.budget, fetchImpl);
|
|
100
155
|
let usage = call.usage;
|
|
101
156
|
let result = extractAndValidate(call.content, request.schemaName);
|
|
102
|
-
|
|
157
|
+
let retried = false;
|
|
158
|
+
if (!result.valid && nudgeCanHelp(call.finishReason)) {
|
|
103
159
|
call = await callOnce(provider, config, apiKey, `${request.prompt}${NUDGE}`, request.budget, fetchImpl);
|
|
104
160
|
usage = addUsage(usage, call.usage);
|
|
105
161
|
result = extractAndValidate(call.content, request.schemaName);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
162
|
+
retried = true;
|
|
163
|
+
}
|
|
164
|
+
if (!result.valid) {
|
|
165
|
+
throw rejectedAnswerError(provider, apiKey, call, { reason: result.reason, errors: 'errors' in result ? result.errors : undefined }, retried);
|
|
109
166
|
}
|
|
110
167
|
return {
|
|
111
168
|
provider,
|
|
@@ -48,6 +48,7 @@ export async function runJudge(providerName, request, opts = {}) {
|
|
|
48
48
|
model: config.model,
|
|
49
49
|
apiKeyEnv: config.apiKeyEnv,
|
|
50
50
|
responseFormatJsonObject: config.responseFormatJsonObject,
|
|
51
|
+
...(config.maxTokens === undefined ? {} : { maxTokens: config.maxTokens }),
|
|
51
52
|
}, request, opts.fetchImpl);
|
|
52
53
|
}
|
|
53
54
|
export { ProviderError } from './types.js';
|