@juzser/blacksmith 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The factory's own project name, and the one place its old spelling
|
|
2
|
+
// becomes the current one.
|
|
3
|
+
//
|
|
4
|
+
// Renamed from `black-smith` to `blacksmith` (operator decision, CHANGELOG
|
|
5
|
+
// "rename the factory's own project"). History is immutable: already-logged
|
|
6
|
+
// events, committed plan files and old roadmap.md bullets may still carry
|
|
7
|
+
// the old name verbatim, so every reader of a project value normalizes it
|
|
8
|
+
// through `normalizeProjectName` rather than comparing against two literals.
|
|
9
|
+
//
|
|
10
|
+
// A tiny module with no dependency of its own, so both `db/queries.ts`
|
|
11
|
+
// (which reaches drizzle) and `roadmap.ts` (which `test/cliBoot.test.ts`
|
|
12
|
+
// pins never does) can import the same constant and the same mapping.
|
|
13
|
+
/** The factory's own project name, everywhere a reader needs the current spelling. */
|
|
14
|
+
export const FACTORY_PROJECT_NAME = 'blacksmith';
|
|
15
|
+
/** The name `blacksmith` replaced — still present verbatim in old events, plans and roadmap bullets. */
|
|
16
|
+
export const LEGACY_FACTORY_PROJECT = 'black-smith';
|
|
17
|
+
/**
|
|
18
|
+
* Maps the legacy project name to the current one; every other value passes
|
|
19
|
+
* through unchanged. The one place old data's project value becomes the
|
|
20
|
+
* current one, so a reader never has to compare against `'black-smith'`
|
|
21
|
+
* itself.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeProjectName(value) {
|
|
24
|
+
return value === LEGACY_FACTORY_PROJECT ? FACTORY_PROJECT_NAME : value;
|
|
25
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an operator prompt is recorded, decided from the working directory.
|
|
3
|
+
*
|
|
4
|
+
* The UserPromptSubmit hook reaches this through two entries (`promptHook.ts`
|
|
5
|
+
* and `bs prompt capture`), and bs-mod asks the same resolver through
|
|
6
|
+
* `bs-prompt-hook --resolve`, so "is this cwd a store" has one answer. The
|
|
7
|
+
* import graph is kept small on purpose: no db layer, no `cli.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { readEnv } from './env.js';
|
|
12
|
+
import { appendWithin, listSessionIds, ROOT_EVENT_TYPE, readEvents, } from './events.js';
|
|
13
|
+
import { IS_CLONE, REPO_ROOT, resolveProjectsDir, resolveWorkRoot, roadmapReadPath, } from './paths.js';
|
|
14
|
+
import { factoryProjects } from './projects.js';
|
|
15
|
+
import { isHarnessText } from './prompts.js';
|
|
16
|
+
import { gitTop, hasEvents, storeRootAt } from './storeRoot.js';
|
|
17
|
+
const realOr = (p) => {
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync(p);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** The checkout's own roadmap, read only after M1 and M2 miss (Q5b). */
|
|
26
|
+
const overlayRoadmapAt = (top) => path.join(top, '.blacksmith', 'factory', 'specs', 'roadmap.md');
|
|
27
|
+
/** The name a roadmap gives the checkout at `top`, or undefined. Never throws. */
|
|
28
|
+
function declaredAs(top, roadmapPath, roots) {
|
|
29
|
+
const want = realOr(top);
|
|
30
|
+
const ref = factoryProjects({ roadmapPath, roots }).find((r) => !r.self && realOr(r.dir) === want);
|
|
31
|
+
return ref?.name;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* M1, M2, M4, M3 in that order; null when the cwd is not managed. Reads the
|
|
35
|
+
* environment, the file system and the roadmaps and writes nothing.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveCaptureStore(input) {
|
|
38
|
+
const { cwd, env, repoRoot, isClone } = input;
|
|
39
|
+
const declaredHome = readEnv(env, 'BS_HOME')?.trim();
|
|
40
|
+
const workRoot = resolveWorkRoot(repoRoot, cwd, env, isClone);
|
|
41
|
+
// M1: BS_HOME names a store that exists.
|
|
42
|
+
if (declaredHome && hasEvents(workRoot)) {
|
|
43
|
+
return { eventsDir: path.join(workRoot, 'state', 'events'), rule: 'M1' };
|
|
44
|
+
}
|
|
45
|
+
// M2: the checkout (or the main clone of a linked worktree) has a store.
|
|
46
|
+
const top = gitTop(cwd);
|
|
47
|
+
const own = top === null ? null : storeRootAt(top);
|
|
48
|
+
if (own !== null)
|
|
49
|
+
return { eventsDir: path.join(own.root, 'state', 'events'), rule: 'M2' };
|
|
50
|
+
// M4: the factory's roadmap declares this checkout. A BS_HOME with no store
|
|
51
|
+
// means `bs` itself would write elsewhere, so it is a no-op rather than a
|
|
52
|
+
// fallback into the clone.
|
|
53
|
+
if (top !== null && isClone && !declaredHome && hasEvents(repoRoot)) {
|
|
54
|
+
const roots = [
|
|
55
|
+
resolveProjectsDir(repoRoot, cwd, isClone),
|
|
56
|
+
path.join(resolveWorkRoot(repoRoot, cwd, env, isClone), 'workspaces'),
|
|
57
|
+
];
|
|
58
|
+
let project = declaredAs(top, input.roadmapPath, roots);
|
|
59
|
+
const overlay = overlayRoadmapAt(top);
|
|
60
|
+
if (project === undefined && existsSync(overlay))
|
|
61
|
+
project = declaredAs(top, overlay, roots);
|
|
62
|
+
if (project !== undefined) {
|
|
63
|
+
return { eventsDir: path.join(repoRoot, 'state', 'events'), project, rule: 'M4' };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// M3: a bare `/bs` where the work root already has a store.
|
|
67
|
+
if (input.command === 'bs' && hasEvents(workRoot)) {
|
|
68
|
+
return { eventsDir: path.join(workRoot, 'state', 'events'), rule: 'M3' };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** The context both entries build from the running bin's own location. */
|
|
73
|
+
export const captureContext = (cwd, env) => ({
|
|
74
|
+
cwd,
|
|
75
|
+
env,
|
|
76
|
+
repoRoot: REPO_ROOT,
|
|
77
|
+
isClone: IS_CLONE,
|
|
78
|
+
roadmapPath: roadmapReadPath(),
|
|
79
|
+
});
|
|
80
|
+
/** The `--resolve <cwd>` answer: one JSON line, or null when the cwd is not managed. */
|
|
81
|
+
export function resolveLine(cwd, ctx) {
|
|
82
|
+
const store = resolveCaptureStore({ ...ctx, cwd });
|
|
83
|
+
if (store === null || store.rule === 'M3')
|
|
84
|
+
return null;
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
events_dir: store.eventsDir,
|
|
87
|
+
...(store.project === undefined ? {} : { project: store.project }),
|
|
88
|
+
rule: store.rule,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export const CLI_ID = /^[0-9a-f-]{8,64}$/i;
|
|
92
|
+
const COMMAND_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
93
|
+
const MAIN_THREAD = new Set(['user', 'operator', 'orchestrator']);
|
|
94
|
+
/** Wave logs belong to their wave-runner node (architecture §18 rule 4). */
|
|
95
|
+
const WAVE_RUNNER = 'wave-runner';
|
|
96
|
+
const FALLBACK_WINDOW_MS = 72 * 3600 * 1000;
|
|
97
|
+
const DEDUPE_MS = 10_000;
|
|
98
|
+
const SCAN_LIMIT = 20;
|
|
99
|
+
export const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
100
|
+
export const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
101
|
+
/** The text an AskUserQuestion result puts on the timeline; null when no one answered. */
|
|
102
|
+
export function answerOf(result) {
|
|
103
|
+
const answers = isObj(result.answers) ? result.answers : {};
|
|
104
|
+
const annotations = isObj(result.annotations) ? result.annotations : {};
|
|
105
|
+
const questions = Array.isArray(result.questions) ? result.questions.filter(isObj) : [];
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (const q of questions) {
|
|
108
|
+
const question = str(q.question);
|
|
109
|
+
const answer = question === undefined ? undefined : str(answers[question]);
|
|
110
|
+
if (question === undefined || answer === undefined || answer === '')
|
|
111
|
+
continue;
|
|
112
|
+
const notes = isObj(annotations[question])
|
|
113
|
+
? str(annotations[question].notes)
|
|
114
|
+
: undefined;
|
|
115
|
+
rows.push({
|
|
116
|
+
question,
|
|
117
|
+
header: str(q.header) ?? question,
|
|
118
|
+
answer,
|
|
119
|
+
...(notes === undefined || notes === '' ? {} : { notes }),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const response = str(result.response);
|
|
123
|
+
const free = response === undefined || response === '' ? undefined : response;
|
|
124
|
+
if (rows.length === 0 && free === undefined)
|
|
125
|
+
return null;
|
|
126
|
+
const prompt = rows.length > 0 ? rows.map((r) => `${r.header}: ${r.answer}`).join('\n') : free;
|
|
127
|
+
return { prompt, answers: rows, ...(free === undefined ? {} : { response: free }) };
|
|
128
|
+
}
|
|
129
|
+
/** `/bs-mod off` -> `bs-mod`; no `command` for text that is not a command name. */
|
|
130
|
+
export function commandOf(text) {
|
|
131
|
+
if (!text.startsWith('/'))
|
|
132
|
+
return undefined;
|
|
133
|
+
const token = /^\/(\S*)/.exec(text)?.[1] ?? '';
|
|
134
|
+
return COMMAND_NAME.test(token) ? token : undefined;
|
|
135
|
+
}
|
|
136
|
+
export const newestMain = (events, cli) => [...events]
|
|
137
|
+
.reverse()
|
|
138
|
+
.find((e) => e.record.cli_session_id === cli && MAIN_THREAD.has(e.record.actor));
|
|
139
|
+
/** The epic main log this CLI session is driving, or null (design §2.1, target log). */
|
|
140
|
+
async function findMainLog(eventsDir, cli, floorMs) {
|
|
141
|
+
const logs = listSessionIds(eventsDir)
|
|
142
|
+
.map((id) => {
|
|
143
|
+
try {
|
|
144
|
+
return {
|
|
145
|
+
id,
|
|
146
|
+
file: path.join(eventsDir, `${id}.jsonl`),
|
|
147
|
+
mtime: statSync(path.join(eventsDir, `${id}.jsonl`)).mtimeMs,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.filter((l) => l !== null && l.mtime >= floorMs)
|
|
155
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
156
|
+
let best = null;
|
|
157
|
+
let stamped = 0;
|
|
158
|
+
for (const log of logs) {
|
|
159
|
+
if (stamped >= SCAN_LIMIT)
|
|
160
|
+
break;
|
|
161
|
+
try {
|
|
162
|
+
if (!readFileSync(log.file, 'utf8').includes(cli))
|
|
163
|
+
continue;
|
|
164
|
+
stamped++;
|
|
165
|
+
const events = await readEvents(log.id, { stateDir: eventsDir });
|
|
166
|
+
if (events[0]?.record.actor === WAVE_RUNNER)
|
|
167
|
+
continue;
|
|
168
|
+
if (events.some((e) => e.record.event_type === 'epic-closed'))
|
|
169
|
+
continue;
|
|
170
|
+
const hit = newestMain(events, cli);
|
|
171
|
+
if (hit !== undefined && (best === null || hit.record.ts > best.ts)) {
|
|
172
|
+
best = { id: log.id, ts: hit.record.ts };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// An unreadable log is not a candidate.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return best?.id ?? null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Record one UserPromptSubmit hook payload. Returns the single stdout line on
|
|
183
|
+
* a write, null when nothing was written (not managed, skipped, duplicate,
|
|
184
|
+
* malformed). Throws only on a store failure; the entries swallow it.
|
|
185
|
+
* `answer` mode takes a PostToolUse AskUserQuestion payload instead and files
|
|
186
|
+
* the operator's answers as a `user_prompt` (design §2.8).
|
|
187
|
+
*/
|
|
188
|
+
export async function capturePrompt(raw, ctx, mode = 'prompt') {
|
|
189
|
+
let input;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(raw);
|
|
192
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
193
|
+
return null;
|
|
194
|
+
input = parsed;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const { session_id: cli } = input;
|
|
200
|
+
if (typeof cli !== 'string' || !CLI_ID.test(cli))
|
|
201
|
+
return null;
|
|
202
|
+
if (input.agent_id !== undefined)
|
|
203
|
+
return null;
|
|
204
|
+
let prompt;
|
|
205
|
+
let promptId = typeof input.prompt_id === 'string' ? input.prompt_id : undefined;
|
|
206
|
+
let extra = {};
|
|
207
|
+
if (mode === 'answer') {
|
|
208
|
+
const result = input.tool_response;
|
|
209
|
+
if (input.tool_name !== 'AskUserQuestion' || !isObj(result))
|
|
210
|
+
return null;
|
|
211
|
+
if (typeof input.tool_use_id !== 'string' || input.tool_use_id === '')
|
|
212
|
+
return null;
|
|
213
|
+
// A timeout answered nothing: no human chose.
|
|
214
|
+
if (result.afkTimeoutMs !== undefined && result.afkTimeoutMs !== null)
|
|
215
|
+
return null;
|
|
216
|
+
const got = answerOf(result);
|
|
217
|
+
if (got === null || isHarnessText(got.prompt))
|
|
218
|
+
return null;
|
|
219
|
+
prompt = got.prompt;
|
|
220
|
+
promptId = input.tool_use_id;
|
|
221
|
+
extra = {
|
|
222
|
+
kind: 'answer',
|
|
223
|
+
answers: got.answers,
|
|
224
|
+
...(got.response === undefined ? {} : { response: got.response }),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
if (typeof input.prompt !== 'string' || isHarnessText(input.prompt))
|
|
229
|
+
return null;
|
|
230
|
+
prompt = input.prompt;
|
|
231
|
+
}
|
|
232
|
+
const command = mode === 'answer' ? undefined : commandOf(prompt);
|
|
233
|
+
const cwd = typeof input.cwd === 'string' ? input.cwd : ctx.cwd;
|
|
234
|
+
const store = resolveCaptureStore({ ...ctx, cwd, command });
|
|
235
|
+
if (store === null)
|
|
236
|
+
return null;
|
|
237
|
+
const { eventsDir, project: declared } = store;
|
|
238
|
+
let floorMs = Date.now() - FALLBACK_WINDOW_MS;
|
|
239
|
+
if (typeof input.transcript_path === 'string') {
|
|
240
|
+
try {
|
|
241
|
+
floorMs = statSync(input.transcript_path).birthtimeMs;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Keep the 72 h fallback.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const home = `prompts-${cli}`;
|
|
248
|
+
const found = await findMainLog(eventsDir, cli, floorMs);
|
|
249
|
+
const write = (target) => appendWithin(target, (existing) => {
|
|
250
|
+
const nowMs = Date.now();
|
|
251
|
+
const duplicate = existing.some((e) => e.record.event_type === 'user_prompt' &&
|
|
252
|
+
((promptId !== undefined && e.record.payload.prompt_id === promptId) ||
|
|
253
|
+
(mode === 'prompt' &&
|
|
254
|
+
e.record.cli_session_id === cli &&
|
|
255
|
+
e.record.payload.prompt === prompt &&
|
|
256
|
+
nowMs - Date.parse(e.record.ts) <= DEDUPE_MS)));
|
|
257
|
+
if (duplicate)
|
|
258
|
+
return [];
|
|
259
|
+
const out = [];
|
|
260
|
+
if (existing.length === 0) {
|
|
261
|
+
out.push({
|
|
262
|
+
session_id: target,
|
|
263
|
+
actor: 'system',
|
|
264
|
+
event_type: ROOT_EVENT_TYPE,
|
|
265
|
+
plan_version: 1,
|
|
266
|
+
causal_parent: null,
|
|
267
|
+
payload: { kind: 'prompt-log' },
|
|
268
|
+
...(declared === undefined ? {} : { project: declared }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const parent = newestMain(existing, cli) ?? existing[0];
|
|
272
|
+
const project = parent?.record.project ?? declared ?? out[0]?.project;
|
|
273
|
+
out.push({
|
|
274
|
+
session_id: target,
|
|
275
|
+
actor: 'user',
|
|
276
|
+
event_type: 'user_prompt',
|
|
277
|
+
plan_version: parent?.record.plan_version ?? 1,
|
|
278
|
+
causal_parent: parent?.event_id ?? `${target}#0`,
|
|
279
|
+
payload: {
|
|
280
|
+
prompt,
|
|
281
|
+
source: 'hook',
|
|
282
|
+
...extra,
|
|
283
|
+
...(promptId === undefined ? {} : { prompt_id: promptId }),
|
|
284
|
+
...(command === undefined ? {} : { command }),
|
|
285
|
+
},
|
|
286
|
+
...(project === undefined ? {} : { project }),
|
|
287
|
+
});
|
|
288
|
+
return out;
|
|
289
|
+
}, { stateDir: eventsDir, cliSessionId: cli });
|
|
290
|
+
let target = found ?? home;
|
|
291
|
+
let stored;
|
|
292
|
+
try {
|
|
293
|
+
stored = await write(target);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
if (target === home)
|
|
297
|
+
throw err;
|
|
298
|
+
// The epic log the scan picked can no longer take the prompt: keep it in the home log.
|
|
299
|
+
target = home;
|
|
300
|
+
stored = await write(target);
|
|
301
|
+
}
|
|
302
|
+
const written = stored[stored.length - 1];
|
|
303
|
+
if (written === undefined)
|
|
304
|
+
return null;
|
|
305
|
+
return `bs prompt capture: ${JSON.stringify({ event_id: written.event_id, session_id: target })}`;
|
|
306
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The prompt-capture hook, as an entry point and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// The plugin's UserPromptSubmit hook execs this file on every prompt, so it
|
|
5
|
+
// stays off `cli.ts` and the db layer, as `policyHook.ts` does for the guard.
|
|
6
|
+
// The logic is `promptCapture.ts`; `bs prompt capture` reaches the same
|
|
7
|
+
// function. Deliberately unconditional (no `import.meta.url` guard) for the
|
|
8
|
+
// reason `policyHook.ts` gives.
|
|
9
|
+
//
|
|
10
|
+
// Never blocks a prompt: every failure is a silent exit 0.
|
|
11
|
+
// promptHook.js stdin = hook JSON; prints one line on a write
|
|
12
|
+
// promptHook.js answer stdin = PostToolUse AskUserQuestion JSON; same line
|
|
13
|
+
// promptHook.js --resolve <cwd> prints the store this cwd records into, if any
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { captureContext, capturePrompt, resolveLine } from './promptCapture.js';
|
|
16
|
+
try {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args[0] === '--resolve') {
|
|
19
|
+
const cwd = args[1];
|
|
20
|
+
const line = cwd === undefined ? null : resolveLine(cwd, captureContext(cwd, process.env));
|
|
21
|
+
if (line !== null)
|
|
22
|
+
process.stdout.write(`${line}\n`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const line = await capturePrompt(readFileSync(0, 'utf8'), captureContext(process.cwd(), process.env), args[0] === 'answer' ? 'answer' : 'prompt');
|
|
26
|
+
if (line !== null)
|
|
27
|
+
process.stdout.write(`${line}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// A capture that fails loses one record; the prompt itself goes through.
|
|
32
|
+
}
|
|
33
|
+
process.exitCode = 0;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bs prompt import`: replay a Claude Code transcript into the home prompt
|
|
3
|
+
* log, writing what the UserPromptSubmit and AskUserQuestion hooks would have
|
|
4
|
+
* written had they been installed (design/prompt-capture.md §2.7, §2.8).
|
|
5
|
+
*/
|
|
6
|
+
import { createReadStream } from 'node:fs';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
import { appendWithin, ROOT_EVENT_TYPE, readEvents, } from './events.js';
|
|
9
|
+
import { answerOf, CLI_ID, commandOf, isObj, newestMain, resolveCaptureStore, str, } from './promptCapture.js';
|
|
10
|
+
import { isHarnessText, PromptError } from './prompts.js';
|
|
11
|
+
/** `/name args` (or just `/name`) from a command echo; null without a command name. */
|
|
12
|
+
function commandText(content) {
|
|
13
|
+
const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(content)?.[1]?.trim();
|
|
14
|
+
if (name === undefined || name === '')
|
|
15
|
+
return null;
|
|
16
|
+
const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(content)?.[1]?.trim() ?? '';
|
|
17
|
+
return args === '' ? name : `${name} ${args}`;
|
|
18
|
+
}
|
|
19
|
+
function classify(l) {
|
|
20
|
+
const ts = str(l.timestamp);
|
|
21
|
+
const ms = Date.parse(ts ?? '');
|
|
22
|
+
if (ts === undefined || Number.isNaN(ms))
|
|
23
|
+
return null;
|
|
24
|
+
const uuid = str(l.uuid);
|
|
25
|
+
const cwd = str(l.cwd);
|
|
26
|
+
const base = { ts, ms, cwd };
|
|
27
|
+
const origin = isObj(l.origin) ? str(l.origin.kind) : undefined;
|
|
28
|
+
const flagged = l.isMeta === true || l.isCompactSummary === true || l.isSidechain === true;
|
|
29
|
+
const result = isObj(l.toolUseResult) ? l.toolUseResult : undefined;
|
|
30
|
+
if (l.type === 'user' && result !== undefined && 'questions' in result && 'answers' in result) {
|
|
31
|
+
const message = isObj(l.message) ? l.message : {};
|
|
32
|
+
const first = Array.isArray(message.content) ? message.content[0] : undefined;
|
|
33
|
+
const toolUseId = isObj(first) ? str(first.tool_use_id) : undefined;
|
|
34
|
+
if (toolUseId === undefined || l.isSidechain === true)
|
|
35
|
+
return null;
|
|
36
|
+
if (result.afkTimeoutMs !== undefined && result.afkTimeoutMs !== null)
|
|
37
|
+
return 'afk';
|
|
38
|
+
const got = answerOf(result);
|
|
39
|
+
if (got === null)
|
|
40
|
+
return null;
|
|
41
|
+
if (isHarnessText(got.prompt))
|
|
42
|
+
return 'harness';
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
key: 'prompt_id',
|
|
46
|
+
keyValue: toolUseId,
|
|
47
|
+
command: undefined,
|
|
48
|
+
payload: {
|
|
49
|
+
prompt: got.prompt,
|
|
50
|
+
source: 'backfill',
|
|
51
|
+
kind: 'answer',
|
|
52
|
+
prompt_id: toolUseId,
|
|
53
|
+
answers: got.answers,
|
|
54
|
+
...(got.response === undefined ? {} : { response: got.response }),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const text = (key, keyValue, t) => {
|
|
59
|
+
if (keyValue === undefined)
|
|
60
|
+
return null;
|
|
61
|
+
if (isHarnessText(t))
|
|
62
|
+
return 'harness';
|
|
63
|
+
const command = commandOf(t);
|
|
64
|
+
return {
|
|
65
|
+
...base,
|
|
66
|
+
key,
|
|
67
|
+
keyValue,
|
|
68
|
+
command,
|
|
69
|
+
payload: {
|
|
70
|
+
prompt: t,
|
|
71
|
+
source: 'backfill',
|
|
72
|
+
[key]: keyValue,
|
|
73
|
+
...(command === undefined ? {} : { command }),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
if (l.type === 'user') {
|
|
78
|
+
const message = isObj(l.message) ? l.message : {};
|
|
79
|
+
if (origin !== 'human' || flagged || typeof message.content !== 'string')
|
|
80
|
+
return null;
|
|
81
|
+
const opening = message.content.trimStart();
|
|
82
|
+
const typedCommand = opening.startsWith('<command-message') || opening.startsWith('<command-name')
|
|
83
|
+
? commandText(message.content)
|
|
84
|
+
: null;
|
|
85
|
+
return text('prompt_id', str(l.promptId) ?? uuid, typedCommand ?? message.content);
|
|
86
|
+
}
|
|
87
|
+
if (l.type === 'attachment') {
|
|
88
|
+
const a = isObj(l.attachment) ? l.attachment : {};
|
|
89
|
+
const aOrigin = isObj(a.origin) ? str(a.origin.kind) : undefined;
|
|
90
|
+
if (a.type !== 'queued_command' || a.commandMode !== 'prompt' || aOrigin !== 'human')
|
|
91
|
+
return null;
|
|
92
|
+
if (flagged || typeof a.prompt !== 'string')
|
|
93
|
+
return null;
|
|
94
|
+
return text('transcript_uuid', uuid, a.prompt);
|
|
95
|
+
}
|
|
96
|
+
if (l.type === 'system' && l.subtype === 'local_command') {
|
|
97
|
+
const content = str(l.content) ?? '';
|
|
98
|
+
const t = commandText(content);
|
|
99
|
+
if (t === null || uuid === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
const command = commandOf(t);
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
key: 'transcript_uuid',
|
|
105
|
+
keyValue: uuid,
|
|
106
|
+
command,
|
|
107
|
+
payload: {
|
|
108
|
+
prompt: t,
|
|
109
|
+
source: 'backfill',
|
|
110
|
+
transcript_uuid: uuid,
|
|
111
|
+
...(command === undefined ? {} : { command }),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** Every line of a file, decoded as UTF-8, without ever holding the whole file. */
|
|
118
|
+
async function* linesOf(file) {
|
|
119
|
+
yield* linesOfChunks(createReadStream(file));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Split byte chunks into `\n`-terminated lines (the terminator is dropped; a
|
|
123
|
+
* `\r` before it is kept). A final unterminated line is yielded, empty input
|
|
124
|
+
* yields nothing. UTF-8 is decoded across chunk boundaries, and a sequence cut
|
|
125
|
+
* off at EOF decodes to U+FFFD.
|
|
126
|
+
*/
|
|
127
|
+
export async function* linesOfChunks(chunks) {
|
|
128
|
+
const decoder = new StringDecoder('utf8');
|
|
129
|
+
// Pieces of the unfinished line; each chunk is searched once, from its start.
|
|
130
|
+
let pending = [];
|
|
131
|
+
for await (const chunk of chunks) {
|
|
132
|
+
const text = decoder.write(chunk);
|
|
133
|
+
let from = 0;
|
|
134
|
+
let nl = text.indexOf('\n');
|
|
135
|
+
while (nl !== -1) {
|
|
136
|
+
pending.push(text.slice(from, nl));
|
|
137
|
+
yield pending.length === 1 ? pending[0] : pending.join('');
|
|
138
|
+
pending = [];
|
|
139
|
+
from = nl + 1;
|
|
140
|
+
nl = text.indexOf('\n', from);
|
|
141
|
+
}
|
|
142
|
+
if (from < text.length)
|
|
143
|
+
pending.push(from === 0 ? text : text.slice(from));
|
|
144
|
+
}
|
|
145
|
+
pending.push(decoder.end());
|
|
146
|
+
const last = pending.join('');
|
|
147
|
+
if (last !== '')
|
|
148
|
+
yield last;
|
|
149
|
+
}
|
|
150
|
+
async function parseTranscript(file) {
|
|
151
|
+
const ids = new Set();
|
|
152
|
+
const candidates = [];
|
|
153
|
+
let harness = 0;
|
|
154
|
+
let afk = 0;
|
|
155
|
+
for await (const raw of linesOf(file)) {
|
|
156
|
+
if (raw.trim() === '')
|
|
157
|
+
continue;
|
|
158
|
+
let l;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
if (!isObj(parsed))
|
|
162
|
+
continue;
|
|
163
|
+
l = parsed;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A torn line (a transcript still being written) is not a prompt.
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const id = str(l.sessionId);
|
|
170
|
+
if (id !== undefined)
|
|
171
|
+
ids.add(id);
|
|
172
|
+
const c = classify(l);
|
|
173
|
+
if (c === 'harness')
|
|
174
|
+
harness++;
|
|
175
|
+
else if (c === 'afk')
|
|
176
|
+
afk++;
|
|
177
|
+
else if (c !== null)
|
|
178
|
+
candidates.push(c);
|
|
179
|
+
}
|
|
180
|
+
const all = [...ids];
|
|
181
|
+
const cli = all[0];
|
|
182
|
+
if (all.length !== 1 || cli === undefined || !CLI_ID.test(cli)) {
|
|
183
|
+
throw new PromptError('prompts.import-session-id', `A transcript must carry exactly one valid sessionId; "${file}" has ${all.length === 0 ? 'none' : `${all.length} (${all.join(', ')})`}. Nothing was written.`, { transcript: file, session_ids: all });
|
|
184
|
+
}
|
|
185
|
+
candidates.sort((a, b) => a.ms - b.ms);
|
|
186
|
+
return { cli, candidates, harness, afk };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Import one transcript. With `dryRun` nothing is written; either way `events`
|
|
190
|
+
* is what is (or would be) appended, root included.
|
|
191
|
+
*/
|
|
192
|
+
export async function importTranscript(file, ctx, opts = {}) {
|
|
193
|
+
const { cli, candidates, harness, afk } = await parseTranscript(file);
|
|
194
|
+
const home = `prompts-${cli}`;
|
|
195
|
+
const summary = {
|
|
196
|
+
session_id: home,
|
|
197
|
+
imported: 0,
|
|
198
|
+
skipped: { duplicate: 0, older: 0, harness, afk },
|
|
199
|
+
};
|
|
200
|
+
const first = candidates[0];
|
|
201
|
+
if (first === undefined)
|
|
202
|
+
return { events: [], summary };
|
|
203
|
+
const store = resolveCaptureStore({
|
|
204
|
+
...ctx,
|
|
205
|
+
cwd: first.cwd ?? ctx.cwd,
|
|
206
|
+
command: candidates.some((c) => c.command === 'bs') ? 'bs' : undefined,
|
|
207
|
+
});
|
|
208
|
+
if (store === null) {
|
|
209
|
+
throw new PromptError('prompts.import-unmanaged', `The first prompt of "${file}" ran in ${first.cwd ?? ctx.cwd}, which belongs to no Blacksmith store. Nothing was written.`, { transcript: file, cwd: first.cwd ?? ctx.cwd });
|
|
210
|
+
}
|
|
211
|
+
const plan = (existing) => {
|
|
212
|
+
summary.imported = 0;
|
|
213
|
+
summary.skipped.duplicate = 0;
|
|
214
|
+
summary.skipped.older = 0;
|
|
215
|
+
const seen = new Set();
|
|
216
|
+
for (const e of existing) {
|
|
217
|
+
for (const k of ['prompt_id', 'transcript_uuid']) {
|
|
218
|
+
const v = e.record.payload[k];
|
|
219
|
+
if (typeof v === 'string')
|
|
220
|
+
seen.add(v);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const newest = existing.reduce((m, e) => Math.max(m, Date.parse(e.record.ts)), -Infinity);
|
|
224
|
+
const fresh = [];
|
|
225
|
+
for (const c of candidates) {
|
|
226
|
+
if (seen.has(c.keyValue))
|
|
227
|
+
summary.skipped.duplicate++;
|
|
228
|
+
else if (c.ms < newest)
|
|
229
|
+
summary.skipped.older++;
|
|
230
|
+
else {
|
|
231
|
+
seen.add(c.keyValue);
|
|
232
|
+
fresh.push(c);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
summary.imported = fresh.length;
|
|
236
|
+
if (fresh.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const out = [];
|
|
239
|
+
const head = fresh[0];
|
|
240
|
+
if (existing.length === 0) {
|
|
241
|
+
out.push({
|
|
242
|
+
session_id: home,
|
|
243
|
+
actor: 'system',
|
|
244
|
+
event_type: ROOT_EVENT_TYPE,
|
|
245
|
+
plan_version: 1,
|
|
246
|
+
causal_parent: null,
|
|
247
|
+
payload: { kind: 'prompt-log' },
|
|
248
|
+
cli_session_id: cli,
|
|
249
|
+
ts: head.ts,
|
|
250
|
+
...(store.project === undefined ? {} : { project: store.project }),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const anchor = newestMain(existing, cli) ?? existing[0];
|
|
254
|
+
const project = anchor?.record.project ?? store.project;
|
|
255
|
+
let parent = anchor?.event_id ?? `${home}#0`;
|
|
256
|
+
for (const c of fresh) {
|
|
257
|
+
out.push({
|
|
258
|
+
session_id: home,
|
|
259
|
+
actor: 'user',
|
|
260
|
+
event_type: 'user_prompt',
|
|
261
|
+
plan_version: anchor?.record.plan_version ?? 1,
|
|
262
|
+
causal_parent: parent,
|
|
263
|
+
payload: c.payload,
|
|
264
|
+
cli_session_id: cli,
|
|
265
|
+
ts: c.ts,
|
|
266
|
+
...(project === undefined ? {} : { project }),
|
|
267
|
+
});
|
|
268
|
+
parent = `${home}#${existing.length + out.length - 1}`;
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
};
|
|
272
|
+
const stateDir = store.eventsDir;
|
|
273
|
+
if (opts.dryRun)
|
|
274
|
+
return { events: plan(await readEvents(home, { stateDir })), summary };
|
|
275
|
+
let events = [];
|
|
276
|
+
await appendWithin(home, (existing) => {
|
|
277
|
+
events = plan(existing);
|
|
278
|
+
return events;
|
|
279
|
+
}, { stateDir, cliSessionId: cli, keepTs: true });
|
|
280
|
+
return { events, summary };
|
|
281
|
+
}
|
|
@@ -22,6 +22,19 @@ import { appendEvent } from './events.js';
|
|
|
22
22
|
*/
|
|
23
23
|
export class PromptError extends SmithError {
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* True when `text` is harness-injected rather than something the operator
|
|
27
|
+
* typed: blank, or a record opening with one of the CLI's own tags (reminders,
|
|
28
|
+
* local and shell command echoes, task notifications, memory input, and a
|
|
29
|
+
* subagent's hand-back, which is model output delivered as a user turn). The
|
|
30
|
+
* one list, shared by the dashboard's reader and any writer of operator
|
|
31
|
+
* prompts.
|
|
32
|
+
*/
|
|
33
|
+
export function isHarnessText(text) {
|
|
34
|
+
const trimmed = text.trim();
|
|
35
|
+
return (trimmed === '' ||
|
|
36
|
+
/^<(system-reminder|local-command|command-message|bash-input|bash-stdout|bash-stderr|task-notification|user-memory-input|agent-message)/.test(trimmed));
|
|
37
|
+
}
|
|
25
38
|
/**
|
|
26
39
|
* Append one `user_prompt`, returning the stored event.
|
|
27
40
|
*
|
|
@@ -93,7 +93,7 @@ export function extractBalancedJson(text) {
|
|
|
93
93
|
/**
|
|
94
94
|
* Schemas that describe ONE item a `kind: review` judge returns MANY of. Both
|
|
95
95
|
* of them: `finding` is the stored record (the native reviewer's contract,
|
|
96
|
-
* .claude/agents/reviewer.md), `finding-evidence` is the judge's half of it —
|
|
96
|
+
* .claude/agents/bs-reviewer.md), `finding-evidence` is the judge's half of it —
|
|
97
97
|
* the same evidence without the six identity fields the orchestrator owns.
|
|
98
98
|
* A set, not a comparison, because the second entry was added a year after the
|
|
99
99
|
* first and the `=== 'finding'` it replaced would have silently validated an
|
|
@@ -136,7 +136,7 @@ function describeShape(parsed) {
|
|
|
136
136
|
/**
|
|
137
137
|
* Extract + schema-validate a judge's raw text response. `finding.schema.json`
|
|
138
138
|
* describes ONE finding, but a `kind: review` judge's contract
|
|
139
|
-
* (.claude/agents/reviewer.md) returns an ARRAY of findings — so against an
|
|
139
|
+
* (.claude/agents/bs-reviewer.md) returns an ARRAY of findings — so against an
|
|
140
140
|
* ARRAY_VALUED_SCHEMAS name the top-level value must be that array, bare or
|
|
141
141
|
* wrapped in one single-key object (see `unwrapArray`), and validates
|
|
142
142
|
* element-wise; the value returned is the array itself, unwrapped. A bare
|