@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
|
@@ -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
|
|
107
|
-
* top-level value
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 =
|
|
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,
|
|
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 =
|
|
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: '--
|
|
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
|
+
}
|
|
@@ -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
|
|
66
|
-
# (
|
|
67
|
-
#
|
|
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
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
|
|
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,
|