@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
@@ -100,20 +100,71 @@ export function extractBalancedJson(text) {
100
100
  * array of evidence as a single malformed object.
101
101
  */
102
102
  const ARRAY_VALUED_SCHEMAS = new Set(['finding', 'finding-evidence']);
103
+ /**
104
+ * The list a judge answered with for an array-valued schema, or undefined when
105
+ * it answered with some other shape. A bare array is the list. An object with
106
+ * exactly one key whose value is an array is the list wrapped — the shape an
107
+ * API judge under `response_format: json_object` has to produce, because that
108
+ * mode refuses a top-level array (FD-41; deepseek-chat answered
109
+ * `{"findings":[...]}` and lost the whole review to `schema-invalid`).
110
+ * Anything else — a bare record, a wrapper with a second key, a scalar — is
111
+ * refused: `balancedJsonCandidates` never descends into a value it has
112
+ * yielded, so this is the only place a wrapped list can be recognised, and it
113
+ * recognises exactly that one shape rather than searching for an array
114
+ * somewhere inside the answer.
115
+ */
116
+ function unwrapArray(parsed) {
117
+ if (Array.isArray(parsed))
118
+ return parsed;
119
+ if (typeof parsed !== 'object' || parsed === null)
120
+ return undefined;
121
+ const keys = Object.keys(parsed);
122
+ if (keys.length !== 1)
123
+ return undefined;
124
+ const inner = parsed[keys[0]];
125
+ return Array.isArray(inner) ? inner : undefined;
126
+ }
127
+ function describeShape(parsed) {
128
+ if (Array.isArray(parsed))
129
+ return 'an array';
130
+ if (parsed === null)
131
+ return 'null';
132
+ if (typeof parsed === 'object')
133
+ return `an object with ${Object.keys(parsed).length} key(s)`;
134
+ return `a ${typeof parsed}`;
135
+ }
103
136
  /**
104
137
  * Extract + schema-validate a judge's raw text response. `finding.schema.json`
105
138
  * describes ONE finding, but a `kind: review` judge's contract
106
- * (.claude/agents/reviewer.md) returns an ARRAY of findings — so an array
107
- * top-level value against an ARRAY_VALUED_SCHEMAS name validates element-wise;
108
- * every other schema (e.g. "judge-verdict") validates the parsed value
109
- * directly against its own object shape.
139
+ * (.claude/agents/reviewer.md) returns an ARRAY of findings — so against an
140
+ * ARRAY_VALUED_SCHEMAS name the top-level value must be that array, bare or
141
+ * wrapped in one single-key object (see `unwrapArray`), and validates
142
+ * element-wise; the value returned is the array itself, unwrapped. A bare
143
+ * record is refused there even when it would validate on its own: one finding
144
+ * where a list was asked for is a judge that misread its contract, and
145
+ * accepting it would file the answer as a review of exactly one issue. Every
146
+ * other schema (e.g. "judge-verdict") validates the parsed value directly
147
+ * against its own object shape.
110
148
  */
111
149
  export function extractAndValidate(rawText, schemaName, opts = {}) {
112
150
  const { taxonomy, schemas } = resolve(opts);
113
151
  const validateOne = (parsed) => {
114
- if (ARRAY_VALUED_SCHEMAS.has(schemaName) && Array.isArray(parsed)) {
152
+ if (ARRAY_VALUED_SCHEMAS.has(schemaName)) {
153
+ const items = unwrapArray(parsed);
154
+ if (items === undefined) {
155
+ return {
156
+ valid: false,
157
+ reason: 'schema-invalid',
158
+ errors: [
159
+ {
160
+ path: '',
161
+ message: `expected an array of ${schemaName} records (or one object whose single key holds that array); got ${describeShape(parsed)}`,
162
+ },
163
+ ],
164
+ };
165
+ }
115
166
  const errors = [];
116
- parsed.forEach((item, index) => {
167
+ items.forEach((item, index) => {
117
168
  const result = validateRecord(schemas, taxonomy, schemaName, item);
118
169
  if (!result.valid) {
119
170
  errors.push(...result.errors.map((e) => ({ path: `/${index}${e.path}`, message: e.message })));
@@ -121,7 +172,7 @@ export function extractAndValidate(rawText, schemaName, opts = {}) {
121
172
  });
122
173
  if (errors.length > 0)
123
174
  return { valid: false, reason: 'schema-invalid', errors };
124
- return { valid: true, value: parsed };
175
+ return { valid: true, value: items };
125
176
  }
126
177
  const result = validateRecord(schemas, taxonomy, schemaName, parsed);
127
178
  if (!result.valid)
@@ -15,6 +15,11 @@ export const JUDGE_SCHEMA_FAILURE_CODES = new Set([
15
15
  'provider.invalid-output',
16
16
  'provider.malformed-response',
17
17
  'provider.output-too-large',
18
+ // The provider stopped writing at its output cap (finish_reason `length`)
19
+ // before the answer closed. The answer is unusable for the same reason a
20
+ // malformed one is, and the fix is the same side of the wire: a cap that
21
+ // fits the answer, or a prompt that asks for a shorter one.
22
+ 'provider.output-truncated',
18
23
  ]);
19
24
  /** Codes raised when no usable answer arrived -- including when no request was sent. */
20
25
  export const JUDGE_TRANSPORT_FAILURE_CODES = new Set([
@@ -11,7 +11,7 @@
11
11
  // entry (documented as a deviation in ui/docs/DESIGN.md).
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { SmithError } from './errors.js';
14
- import { ROADMAP_PATH } from './paths.js';
14
+ import { roadmapReadPath } from './paths.js';
15
15
  export class RoadmapError extends SmithError {
16
16
  }
17
17
  /**
@@ -347,7 +347,7 @@ export function parseRoadmap(markdown) {
347
347
  * guard is around the read rather than an existsSync in front of it so a
348
348
  * directory, a permission error and a missing file all answer the same way.
349
349
  */
350
- export function readRoadmapText(roadmapPath = ROADMAP_PATH) {
350
+ export function readRoadmapText(roadmapPath = roadmapReadPath()) {
351
351
  try {
352
352
  return readFileSync(roadmapPath, 'utf8');
353
353
  }
@@ -392,7 +392,7 @@ export function roadmapDeclaresId(markdown, id) {
392
392
  return false;
393
393
  }
394
394
  /** Read and parse roadmap.md from disk (defaults to the real repo path). */
395
- export function loadRoadmap(roadmapPath = ROADMAP_PATH) {
395
+ export function loadRoadmap(roadmapPath = roadmapReadPath()) {
396
396
  return parseRoadmap(readRoadmapText(roadmapPath));
397
397
  }
398
398
  /**
@@ -16,9 +16,10 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
16
16
  import path from 'node:path';
17
17
  import { SmithError } from './errors.js';
18
18
  import { runGit as git } from './git.js';
19
- import { PROJECTS_DIR, REPO_ROOT, ROADMAP_PATH, SCAFFOLD_DIR } from './paths.js';
19
+ import { PROJECTS_DIR, REPO_ROOT, SCAFFOLD_DIR } from './paths.js';
20
20
  import { readRoadmapText, roadmapDeclaresId } from './roadmap.js';
21
21
  import { loadStackAnswers, requireScaffoldable } from './stack.js';
22
+ import { ensureWritableRoadmap } from './workroot.js';
22
23
  export class ScaffoldError extends SmithError {
23
24
  }
24
25
  const PROJECT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -355,7 +356,7 @@ export function scaffoldProject(opts) {
355
356
  * gets a placeholder milestone so the Projects/Roadmap pages have something
356
357
  * to show the moment it exists, edited further as real epics get scoped).
357
358
  */
358
- export function registerProjectInRoadmap(projectName, roadmapPath = ROADMAP_PATH) {
359
+ export function registerProjectInRoadmap(projectName, roadmapPath = ensureWritableRoadmap()) {
359
360
  const text = readRoadmapText(roadmapPath);
360
361
  const id = `${projectName}-bootstrap`;
361
362
  // See registerMcpMilestone: the substring `- id: <id>` is not what
@@ -25,7 +25,7 @@ import { existsSync, readFileSync } from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { parse as parseYaml } from 'yaml';
27
27
  import { SmithError } from './errors.js';
28
- import { REPO_ROOT, STACK_POLICY_PATH } from './paths.js';
28
+ import { REPO_ROOT, stackPolicyReadPath } from './paths.js';
29
29
  export class StackError extends SmithError {
30
30
  }
31
31
  /**
@@ -124,7 +124,7 @@ export function parseStackAnswers(yamlText) {
124
124
  }
125
125
  return answers;
126
126
  }
127
- export function loadStackAnswers(filePath = STACK_POLICY_PATH) {
127
+ export function loadStackAnswers(filePath = stackPolicyReadPath()) {
128
128
  return parseStackAnswers(readFileSync(filePath, 'utf8'));
129
129
  }
130
130
  /** Values `factory/scaffold/` can build. Everything else is a refusal, not a substitution. */
@@ -222,6 +222,14 @@ function reportFor(field, value, source) {
222
222
  note: `The uiux agent is told to ground its specs in ${value}; with design_system_source empty, no kit is copied into scaffolded projects.`,
223
223
  };
224
224
  }
225
+ // A relative `design_system_source` is relative to the *package*, not to
226
+ // the operator's repository -- which is what it has always meant and what
227
+ // scaffold.ts resolves it against too. Stated here because this diff moved
228
+ // the file the value is typed into (stack.yml is now read from the work
229
+ // root under an install) without moving the base it resolves against, so a
230
+ // reader could reasonably assume the two travel together. They do not:
231
+ // changing the base is a behaviour change for every existing clone and
232
+ // needs a matching sentence in stack.yml, which is a policy file.
225
233
  const resolved = path.isAbsolute(source) ? source : path.join(REPO_ROOT, source);
226
234
  return existsSync(resolved)
227
235
  ? {
@@ -0,0 +1,178 @@
1
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { SmithError } from './errors.js';
4
+ import { AGENTS_DIR, REPO_ROOT } from './paths.js';
5
+ /**
6
+ * Shared fragments: one paragraph, stamped into several role templates.
7
+ *
8
+ * Six judges carry the same read-only rule, six workers the same
9
+ * `token_usage` paragraph, three judges the same finding-field rule. Before
10
+ * this module each copy was hand-maintained, and the copies had drifted —
11
+ * the same rule in three wordings, one of them a sentence short. The fix is
12
+ * not a template include: an agent template is what Claude Code reads
13
+ * *verbatim* as a system prompt, so the text has to be *in* the file. So the
14
+ * fragment is the source, the template carries a stamped copy inside a fence,
15
+ * and a test holds every fence byte-equal to its fragment. Editing the
16
+ * paragraph means editing the fragment and re-running the sync — editing one
17
+ * copy fails the gate by name.
18
+ */
19
+ export class TemplateFragmentsError extends SmithError {
20
+ }
21
+ /**
22
+ * Where the fragments live. Deliberately not a `paths.ts` export: everything
23
+ * anchored on `REPO_ROOT` there must ship in the npm package (packaging
24
+ * test), and the fragments are dev-time source — the templates carry the
25
+ * stamped text, and nothing the CLI runs at an operator's desk reads this
26
+ * directory.
27
+ *
28
+ * And deliberately not `.claude/agents/_shared/`: Claude Code's agent loader
29
+ * recurses into `.claude/agents/`, so a fragment there with front matter
30
+ * registers as an agent and one without is skipped silently — neither is a
31
+ * fragment. `.claude/fragments/` is a directory the loader does not watch.
32
+ */
33
+ export const FRAGMENTS_DIR = path.join(REPO_ROOT, '.claude', 'fragments');
34
+ /** A fragment name is its file's basename; the fence names it the same way. */
35
+ export const FRAGMENT_NAME = /^[a-z][a-z0-9-]*$/;
36
+ const BEGIN_FENCE = /^<!-- BEGIN SHARED:(\S+) -->$/;
37
+ const END_FENCE = /^<!-- END SHARED:(\S+) -->$/;
38
+ export function beginFence(name) {
39
+ return `<!-- BEGIN SHARED:${name} -->`;
40
+ }
41
+ export function endFence(name) {
42
+ return `<!-- END SHARED:${name} -->`;
43
+ }
44
+ /**
45
+ * Every `<!-- BEGIN SHARED:x -->` … `<!-- END SHARED:x -->` region in a
46
+ * template, in file order. Fences are whole lines, so a fence quoted inside
47
+ * a sentence is prose, not a region. Regions do not nest: a BEGIN inside a
48
+ * region and an END with no open region are both errors, as is an END that
49
+ * names a different fragment than the BEGIN it closes — the fence pair is the
50
+ * unit the sync rewrites, and a mismatched pair has no single body to rewrite.
51
+ */
52
+ export function parseSharedRegions(text, file = '<template>') {
53
+ const lines = text.split('\n');
54
+ const regions = [];
55
+ let open = null;
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i];
58
+ const begin = BEGIN_FENCE.exec(line);
59
+ if (begin) {
60
+ const name = begin[1];
61
+ if (open) {
62
+ throw new TemplateFragmentsError('fragments.nested-fence', `${file}:${i + 1}: BEGIN SHARED:${name} opens inside SHARED:${open.name} (opened at line ${open.start + 1}); regions do not nest.`, { file, line: i + 1, name, open: open.name });
63
+ }
64
+ assertFragmentName(name, `${file}:${i + 1}`);
65
+ open = { name, start: i };
66
+ continue;
67
+ }
68
+ const end = END_FENCE.exec(line);
69
+ if (end) {
70
+ const name = end[1];
71
+ if (!open || open.name !== name) {
72
+ throw new TemplateFragmentsError('fragments.unbalanced-fence', open
73
+ ? `${file}:${i + 1}: END SHARED:${name} closes SHARED:${open.name} (opened at line ${open.start + 1}).`
74
+ : `${file}:${i + 1}: END SHARED:${name} has no open BEGIN.`, { file, line: i + 1, name, open: open?.name ?? null });
75
+ }
76
+ const between = lines.slice(open.start + 1, i);
77
+ regions.push({
78
+ name,
79
+ start: open.start,
80
+ end: i,
81
+ body: between.length === 0 ? '' : `${between.join('\n')}\n`,
82
+ });
83
+ open = null;
84
+ }
85
+ }
86
+ if (open) {
87
+ throw new TemplateFragmentsError('fragments.unbalanced-fence', `${file}:${open.start + 1}: BEGIN SHARED:${open.name} is never closed.`, { file, line: open.start + 1, name: open.name, open: open.name });
88
+ }
89
+ return regions;
90
+ }
91
+ function assertFragmentName(name, where) {
92
+ if (!FRAGMENT_NAME.test(name)) {
93
+ throw new TemplateFragmentsError('fragments.invalid-fragment-name', `${where}: ${JSON.stringify(name)} is not a fragment name (expected ${FRAGMENT_NAME}).`, { name, where });
94
+ }
95
+ }
96
+ /**
97
+ * The fragments in a directory, by name. Every `.md` file there is a
98
+ * fragment and its basename is the name — the directory holds nothing else,
99
+ * so a README or a draft dropped in it fails by name rather than stamping
100
+ * itself into a template that asks for it.
101
+ */
102
+ export function listFragments(dir = FRAGMENTS_DIR) {
103
+ const fragments = new Map();
104
+ const files = readdirSync(dir)
105
+ .filter((f) => f.endsWith('.md'))
106
+ .sort();
107
+ for (const file of files) {
108
+ const name = path.basename(file, '.md');
109
+ assertFragmentName(name, path.join(dir, file));
110
+ fragments.set(name, readFileSync(path.join(dir, file), 'utf8'));
111
+ }
112
+ return fragments;
113
+ }
114
+ /**
115
+ * Rewrite every fenced region so its body equals the named fragment. The
116
+ * fences stay; only the lines between them change. Stamping a template that
117
+ * is already in sync returns it byte-for-byte, which is what lets the sync
118
+ * script run on every template without a diff to explain.
119
+ */
120
+ export function stampFragments(text, fragments, file = '<template>') {
121
+ const regions = parseSharedRegions(text, file);
122
+ const lines = text.split('\n');
123
+ const out = [];
124
+ const stamped = [];
125
+ let cursor = 0;
126
+ for (const region of regions) {
127
+ const fragment = fragments.get(region.name);
128
+ if (fragment === undefined) {
129
+ throw new TemplateFragmentsError('fragments.unknown-fragment', `${file}:${region.start + 1}: SHARED:${region.name} names no file under the fragments directory (have: ${[...fragments.keys()].join(', ') || 'none'}).`, { file, line: region.start + 1, name: region.name, known: [...fragments.keys()] });
130
+ }
131
+ out.push(...lines.slice(cursor, region.start + 1));
132
+ if (fragment !== '')
133
+ out.push(...fragment.replace(/\n$/, '').split('\n'));
134
+ out.push(lines[region.end]);
135
+ cursor = region.end + 1;
136
+ stamped.push({ name: region.name, drifted: region.body !== fragment });
137
+ }
138
+ out.push(...lines.slice(cursor));
139
+ return { text: out.join('\n'), regions: stamped };
140
+ }
141
+ /**
142
+ * Stamp every fragment into every role template. Reads the templates
143
+ * non-recursively, the same set `scripts/check.sh` validates, so a file in a
144
+ * subdirectory is neither a template here nor an agent there.
145
+ */
146
+ export function syncTemplates(opts = {}) {
147
+ const agentsDir = opts.agentsDir ?? AGENTS_DIR;
148
+ const fragments = listFragments(opts.fragmentsDir ?? FRAGMENTS_DIR);
149
+ const used = new Set();
150
+ const report = {
151
+ fragments: [...fragments.keys()],
152
+ templates: [],
153
+ drifted: [],
154
+ unused: [],
155
+ written: [],
156
+ };
157
+ const files = readdirSync(agentsDir, { withFileTypes: true })
158
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
159
+ .map((entry) => entry.name)
160
+ .sort();
161
+ for (const file of files) {
162
+ const templatePath = path.join(agentsDir, file);
163
+ const before = readFileSync(templatePath, 'utf8');
164
+ const { text, regions } = stampFragments(before, fragments, templatePath);
165
+ report.templates.push({ file, regions });
166
+ for (const region of regions) {
167
+ used.add(region.name);
168
+ if (region.drifted)
169
+ report.drifted.push({ file, name: region.name });
170
+ }
171
+ if (opts.write && text !== before) {
172
+ writeFileSync(templatePath, text, 'utf8');
173
+ report.written.push(file);
174
+ }
175
+ }
176
+ report.unused = report.fragments.filter((name) => !used.has(name));
177
+ return report;
178
+ }
@@ -88,8 +88,8 @@ export const COMMANDS = [
88
88
  {
89
89
  command: 'plan quorum',
90
90
  positionals: '',
91
- flags: '--epic <id> --plan-version <n> --session <id> --causal-parent <event-id> [--confidence <0-1>] [--actor <name>] [--state-dir <dir>]',
92
- summary: 'Critique-only review of a drafted plan. Exit 1 means the operator must look first.',
91
+ flags: '--plan-version <n> --session <id> --causal-parent <event-id> [--epic <id>] [--plan <draft.json>] [--confidence <0-1>] [--out <file>] [--actor <name>] [--specs-dir <dir>] [--state-dir <dir>]',
92
+ summary: 'Critique-only review of a drafted plan: --plan <draft.json> before it is filed, or --epic for the filed version. Exit 1 means the operator must look first.',
93
93
  },
94
94
  {
95
95
  command: 'plan amend',
@@ -162,6 +162,13 @@ export const COMMANDS = [
162
162
  'wide, or were dispatched one task at a time. Reads, never writes. Exit 1 on a wave that ' +
163
163
  'ran strictly serially, 2 when a wave was admitted and the log shows no work for it.',
164
164
  },
165
+ {
166
+ command: 'init',
167
+ positionals: '',
168
+ flags: '[--work-root <dir>]',
169
+ summary: 'Prepare a work root in the current directory: the state directories, and a copy of every ' +
170
+ 'file the factory ships a default of for you to edit. Idempotent; a clone needs none of it.',
171
+ },
165
172
  {
166
173
  command: 'new',
167
174
  positionals: '<project>',
@@ -378,6 +385,18 @@ export const COMMANDS = [
378
385
  flags: '--task <task.json> [--policy <file>] [--case <case>] [--epic-tag <tag>...] [--recheck]',
379
386
  summary: 'Compute whether the security reviewer must be dispatched. A fired trigger is not a red.',
380
387
  },
388
+ {
389
+ command: 'harness list',
390
+ positionals: '',
391
+ flags: '[--policy <file>]',
392
+ summary: 'The harnesses that may run a worker turn, and which roles each one serves. Built-in unless --policy names a file.',
393
+ },
394
+ {
395
+ command: 'harness plan',
396
+ positionals: '',
397
+ flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>]',
398
+ summary: 'Render the invocation that would run one worker turn, as JSON. Prints it; never starts it.',
399
+ },
381
400
  {
382
401
  command: 'sandbox open',
383
402
  positionals: '<worktree-dir>',
@@ -637,7 +656,7 @@ export const COMMANDS = [
637
656
  {
638
657
  command: 'crossfind request',
639
658
  positionals: '',
640
- flags: '--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--timeout-ms <n>] [--max-output-bytes <n>] [--policy <file>]',
659
+ flags: '--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>]',
641
660
  summary: 'Print the finder request without sending it — exactly what would leave the machine. Refuses when send_diff is false.',
642
661
  },
643
662
  {
@@ -649,13 +668,13 @@ export const COMMANDS = [
649
668
  {
650
669
  command: 'crossfind run',
651
670
  positionals: '',
652
- flags: `--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--status <status>] [--timeout-ms <n>] [--max-output-bytes <n>] [--policy <file>] ${EVENTS_DIR}`,
671
+ flags: `--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--status <status>] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>] ${EVENTS_DIR}`,
653
672
  summary: 'Run the independent finder over a diff and reconcile it against the native findings. Exit 1 when the result would change a gate.',
654
673
  },
655
674
  {
656
675
  command: 'ui serve',
657
676
  positionals: '',
658
- flags: '[--port <n>] [--db <file>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>]',
677
+ flags: '[--port <n>] [--db <file>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>] [--now-iso <iso>]',
659
678
  summary: 'Serve the dashboard from the built ui/server.',
660
679
  },
661
680
  {
@@ -0,0 +1,113 @@
1
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { REPO_ROOT, ROADMAP_DEFAULT_PATH, ROADMAP_PATH, SPECS_ACTIVE_DIR, STACK_POLICY_DEFAULT_PATH, STATE_ARTIFACTS_DIR, STATE_EVENTS_DIR, WORK_ROOT, } from './paths.js';
4
+ /**
5
+ * The files `paths.ts` declares twice: a default that ships and a copy the
6
+ * operator then edits. Spelled relative to their own root, because the pairing
7
+ * rule -- enforced in packaging.test.ts -- is that both halves are the same
8
+ * relative path under two roots, which is what lets seeding be a plain copy.
9
+ *
10
+ * Derived from the constants rather than retyped, so a third overlay file
11
+ * added to `paths.ts` is seeded without anyone remembering to edit this list.
12
+ */
13
+ export const OVERLAY_FILES = [
14
+ path.relative(REPO_ROOT, ROADMAP_DEFAULT_PATH),
15
+ path.relative(REPO_ROOT, STACK_POLICY_DEFAULT_PATH),
16
+ ];
17
+ // `factory/policies/lessons.md` is the third overlay in `paths.ts` and is
18
+ // deliberately not in that list. The two above are seeded because INSTALL.md
19
+ // has the operator open them and edit them in place -- they must exist before
20
+ // anyone can answer. Nobody hand-edits lessons.md: it appears the first time
21
+ // `smith lessons compile` runs, and until then reading the shipped set is the
22
+ // right answer. Seeding a copy would freeze the lessons at install time and
23
+ // stop every later upgrade from delivering new ones.
24
+ /**
25
+ * The directories the CLI writes into. Same derivation, same reason: the
26
+ * relative spelling is `paths.ts`'s to decide, and this module should follow
27
+ * it rather than hold a second opinion about where `state/events` is.
28
+ */
29
+ const WORK_DIRS = [
30
+ path.relative(WORK_ROOT, STATE_EVENTS_DIR),
31
+ path.relative(WORK_ROOT, STATE_ARTIFACTS_DIR),
32
+ path.relative(WORK_ROOT, SPECS_ACTIVE_DIR),
33
+ ];
34
+ /**
35
+ * `state/` is a cache of the event log and `workspaces/` holds worktrees; both
36
+ * are this machine's, not the project's. `factory/` is deliberately absent:
37
+ * the roadmap and the stack answers are declarations the operator made, and a
38
+ * team sharing the repository should share them.
39
+ */
40
+ const WORK_ROOT_GITIGNORE = `# Written by \`smith init\`. Blacksmith's state is a cache of its event log
41
+ # and belongs to this machine; the answers under factory/ are declarations
42
+ # this project made, so they are tracked.
43
+ state/
44
+ workspaces/
45
+ .env
46
+ `;
47
+ /**
48
+ * Give the operator their own copy of a file the factory ships a default of.
49
+ *
50
+ * Never overwrites: an operator who answers the stack questionnaire, upgrades,
51
+ * and runs init again out of habit must not find the defaults back. And never
52
+ * copies a file onto itself, which is the clone case -- there the personal
53
+ * copy *is* the shipped default, and the honest report is that nothing needed
54
+ * doing.
55
+ */
56
+ export function seedFromShipped(personal, shipped) {
57
+ if (personal === shipped || existsSync(personal)) {
58
+ return { personal, shipped, status: 'kept' };
59
+ }
60
+ if (!existsSync(shipped)) {
61
+ // Not an throw: a missing default is a packaging defect, and the caller --
62
+ // `smith init`, which is reporting a list -- can say which file is missing
63
+ // more usefully than an exception thrown halfway through the list can.
64
+ return { personal, shipped, status: 'missing-default' };
65
+ }
66
+ mkdirSync(path.dirname(personal), { recursive: true });
67
+ copyFileSync(shipped, personal);
68
+ return { personal, shipped, status: 'seeded' };
69
+ }
70
+ /**
71
+ * The roadmap, guaranteed writable, returned.
72
+ *
73
+ * Used as the *default argument* of the functions that append to the roadmap,
74
+ * so that an explicitly-passed path is never seeded -- a caller naming a file
75
+ * is naming the file it means, and a missing one is still an error there.
76
+ */
77
+ export function ensureWritableRoadmap() {
78
+ seedFromShipped(ROADMAP_PATH, ROADMAP_DEFAULT_PATH);
79
+ return ROADMAP_PATH;
80
+ }
81
+ /**
82
+ * Make a work root that the CLI can run against: the directories it writes
83
+ * into, a copy of each file the operator is expected to edit, and -- only when
84
+ * the work root is a directory of our own inside somebody else's repository --
85
+ * a `.gitignore` saying which half of it is state.
86
+ *
87
+ * Idempotent by construction: every step asks whether it has already been
88
+ * done. Running it twice is not an undo.
89
+ */
90
+ export function initWorkRoot(options = {}) {
91
+ const workRoot = options.workRoot ?? WORK_ROOT;
92
+ const repoRoot = options.repoRoot ?? REPO_ROOT;
93
+ const inPlace = path.resolve(workRoot) === path.resolve(repoRoot);
94
+ for (const rel of WORK_DIRS) {
95
+ mkdirSync(path.join(workRoot, rel), { recursive: true });
96
+ }
97
+ const files = OVERLAY_FILES.map((rel) => seedFromShipped(path.join(workRoot, rel), path.join(repoRoot, rel)));
98
+ // In a clone this is the repository's own .gitignore, which means something
99
+ // already. Appending to it would be a surprise and replacing it would be
100
+ // data loss, so the only correct move is to leave it entirely alone.
101
+ let gitignore = 'not-applicable';
102
+ if (!inPlace) {
103
+ const ignorePath = path.join(workRoot, '.gitignore');
104
+ if (existsSync(ignorePath)) {
105
+ gitignore = 'kept';
106
+ }
107
+ else {
108
+ writeFileSync(ignorePath, WORK_ROOT_GITIGNORE, 'utf8');
109
+ gitignore = 'written';
110
+ }
111
+ }
112
+ return { workRoot, repoRoot, inPlace, directories: WORK_DIRS, files, gitignore };
113
+ }
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `findings` ADD `finding_scope` text DEFAULT 'diff' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE `findings` ADD `spec_plan_version` integer;--> statement-breakpoint
3
+ ALTER TABLE `findings` ADD `criterion_ref` text;
@@ -78,6 +78,13 @@
78
78
  "when": 1788429177107,
79
79
  "tag": "0010_milestone_kind",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1789391823211,
86
+ "tag": "0011_findings_spec_ref",
87
+ "breakpoints": true
81
88
  }
82
89
  ]
83
90
  }
@@ -62,9 +62,12 @@
62
62
  # Every other number below is prompt-level: read by agents from their own
63
63
  # template and by the operator from the /bs playbook. They are requests, not
64
64
  # limits, and the sections that follow say which is which. `max_turns` is no
65
- # longer among them at all — no dispatch harness can enforce a turn limit
66
- # (agent-interviews.md M-4), so plan validation now rejects it as a task budget
67
- # field and the number belongs in the dispatch prompt.
65
+ # longer among them at all: the turn ceiling is the role template's
66
+ # `maxTurns` (`.claude/agents/<role>.md`), which Claude Code enforces per
67
+ # dispatch (measured 2026-09-07/-11, dogfood-csb-audit-1 FD-7 and FD-14 —
68
+ # agent-interviews.md M-4 had called it inert), and a plan cannot raise or
69
+ # lower a role's ceiling. So plan validation rejects it as a task budget
70
+ # field, and the dispatch prompt restates the template's number verbatim.
68
71
  #
69
72
  # No concurrency cap on cost grounds, by design (removed 2026-08-05). Workers
70
73
  # run in isolated worktrees on disjoint path claims, so parallelism is bounded
@@ -113,14 +113,16 @@ providers:
113
113
  #
114
114
  # Funded for THIS box, 2026-09-04, by the operator who owns the key.
115
115
  enabled: auto
116
- # `shadow`, deliberately, and the difference from codex above is the point:
117
- # this box declined shadow for codex and chose it here. A shadow provider
118
- # is still called and still recorded as a participant -- it forfeits only
119
- # gating power -- so what the mode buys is a run of recorded disagreement
120
- # to read before a second vendor gets a vote (docs/runbooks/providers.md
121
- # §3). Promote by editing this to `active`; that is the calibration pass,
122
- # and it is an operator decision, not a default.
123
- mode: shadow
116
+ # Promoted shadow -> active by the operator on 2026-09-14, after the
117
+ # csb-signing-policy-1 epic-final verdict held on `insufficient-providers`
118
+ # with all three participants at `confirm`: with claude excluded as the
119
+ # finder and deepseek in shadow, the gating pool was codex alone (1 <
120
+ # `quorum_rule.min_providers` 2), so no mechanically-ready epic could ever
121
+ # reach quorum on this box (epic.ts, "exactly 1 active external"). The
122
+ # calibration run it replaced is the recorded shadow verdicts from
123
+ # csb-audit-1 and csb-signing-policy-1 (docs/runbooks/providers.md §3).
124
+ # Rollback is `mode: shadow` here; nothing else remembers the promotion.
125
+ mode: active
124
126
 
125
127
  quorum_triggers:
126
128
  # architecture §6, agent-constraints.md "cross-check (phase 8)" — all four,