@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.
Files changed (61) hide show
  1. package/.claude/agents/coder.md +9 -3
  2. package/.claude/agents/grader.md +13 -5
  3. package/.claude/agents/merger.md +2 -0
  4. package/.claude/agents/planner.md +1 -1
  5. package/.claude/agents/researcher.md +3 -1
  6. package/.claude/agents/reviewer.md +9 -2
  7. package/.claude/agents/security-reviewer.md +15 -7
  8. package/.claude/agents/spec-reviewer.md +12 -3
  9. package/.claude/agents/tester.md +5 -2
  10. package/.claude/agents/uiux.md +10 -3
  11. package/.claude/agents/verifier.md +8 -3
  12. package/.claude/skills/bs/SKILL.md +103 -0
  13. package/.claude/skills/bs/audit.md +250 -0
  14. package/.claude/skills/bs/dispatch.md +394 -0
  15. package/.claude/skills/bs/lessons.md +73 -0
  16. package/.claude/skills/bs/mcp.md +29 -0
  17. package/.claude/skills/bs/new.md +33 -0
  18. package/.claude/skills/bs/plan.md +155 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +325 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +12 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +261 -0
  25. package/README.md +119 -65
  26. package/factory/orchestrator/dist/actors.js +4 -3
  27. package/factory/orchestrator/dist/agents-registry.js +96 -19
  28. package/factory/orchestrator/dist/artifacts.js +36 -4
  29. package/factory/orchestrator/dist/budgets.js +7 -5
  30. package/factory/orchestrator/dist/cli.js +107 -7
  31. package/factory/orchestrator/dist/crosscheck.js +11 -0
  32. package/factory/orchestrator/dist/daemon.js +14 -0
  33. package/factory/orchestrator/dist/db/projector.js +272 -90
  34. package/factory/orchestrator/dist/db/queries.js +83 -14
  35. package/factory/orchestrator/dist/db/schema.js +7 -0
  36. package/factory/orchestrator/dist/events.js +38 -4
  37. package/factory/orchestrator/dist/harness.js +367 -0
  38. package/factory/orchestrator/dist/judges.js +49 -8
  39. package/factory/orchestrator/dist/lessons.js +4 -4
  40. package/factory/orchestrator/dist/mcp.js +4 -3
  41. package/factory/orchestrator/dist/paths.js +167 -15
  42. package/factory/orchestrator/dist/plan.js +1 -1
  43. package/factory/orchestrator/dist/planQuorum.js +36 -8
  44. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  45. package/factory/orchestrator/dist/providers/index.js +1 -0
  46. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  47. package/factory/orchestrator/dist/providers/types.js +5 -0
  48. package/factory/orchestrator/dist/roadmap.js +3 -3
  49. package/factory/orchestrator/dist/scaffold.js +3 -2
  50. package/factory/orchestrator/dist/stack.js +10 -2
  51. package/factory/orchestrator/dist/templateFragments.js +178 -0
  52. package/factory/orchestrator/dist/usage.js +24 -5
  53. package/factory/orchestrator/dist/workroot.js +113 -0
  54. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  55. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  56. package/factory/policies/budgets.yml +6 -3
  57. package/factory/policies/crosscheck.yml +10 -8
  58. package/factory/policies/lessons.md +122 -0
  59. package/factory/policies/taxonomy.yml +6 -2
  60. package/factory/specs/roadmap.md +154 -0
  61. 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 — a grader verdict, say, which is P9-14's business and needs its own
243
- * schema before anything here can accept it).
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 (!Array.isArray(parsed)) {
261
- throw new JudgeError('judges.artifact-not-a-list', `Judge artifact "${artifactPath}" parsed to ${parsed === null ? 'null' : typeof parsed}, not a findings-evidence array. An empty review is "[]", written out.`, { artifact_path: artifactPath });
262
- }
263
- return parsed;
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).length;
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, LESSONS_MD_PATH } from './paths.js';
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 ?? LESSONS_MD_PATH;
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, ROADMAP_PATH, SCAFFOLD_DIR, WORKSPACES_DIR } from './paths.js';
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 = ROADMAP_PATH) {
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 ?? ROADMAP_PATH;
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
- * The repo's own `.env`, read at CLI start by `loadDotEnv`. Gitignored, and
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 this module, not on cwd: an agent runs `smith`
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(REPO_ROOT, '.env');
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(REPO_ROOT, 'factory', 'specs', 'active');
16
- export const STATE_EVENTS_DIR = path.join(REPO_ROOT, 'state', 'events');
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(REPO_ROOT, 'state', 'artifacts');
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(REPO_ROOT, 'state', 'smith.db');
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(REPO_ROOT, 'state', 'daemon');
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
- export const ROADMAP_PATH = path.join(REPO_ROOT, 'factory', 'specs', 'roadmap.md');
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(REPO_ROOT, 'workspaces');
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 = path.dirname(REPO_ROOT);
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
- export const LESSONS_MD_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'lessons.md');
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 STACK_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'stack.yml');
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(REPO_ROOT, 'state', 'sandboxes');
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. Carry the number in the dispatch prompt instead, where it reads as the request it is (agent-interviews.md M-4).`,
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, edge count, fired triggers with their evidence) —
189
- * never file contents, never a diff. The tasks it lists are `livePlanTasks`,
190
- * not `plan.tasks`: this prompt is the whole of what the critic sees, so a
191
- * superseded record listed here is an objective the plan withdrew, offered to
192
- * a judge as the plan's current ask (D-185). Carries
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
- return ` ${t.task_id} [${caseValue}, ${taskTokens(t)} tokens]: ${objective}`;
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 content = parsed.choices?.[0]?.message?.content ?? '';
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
- if (!result.valid) {
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
- if (!result.valid) {
107
- throw new ProviderError('provider.invalid-output', `Provider "${provider}" API judge returned invalid output after one retry: ${result.reason}.`, { provider, reason: result.reason, errors: 'errors' in result ? result.errors : undefined });
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';