@juzser/blacksmith 0.3.0 → 0.4.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 +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +12 -5
- package/.claude/skills/bs/dispatch.md +40 -4
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +11 -5
- package/.claude/skills/bs/wave.md +40 -3
- package/README.md +1 -1
- package/factory/orchestrator/dist/agentsSync.js +23 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/cli.js +309 -28
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +198 -31
- package/factory/orchestrator/dist/db/projector.js +51 -27
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +179 -12
- package/factory/orchestrator/dist/errorIssues.js +175 -10
- package/factory/orchestrator/dist/events.js +59 -11
- package/factory/orchestrator/dist/findings.js +25 -2
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +130 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +469 -28
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +4 -3
- package/factory/orchestrator/dist/usage.js +18 -10
- package/factory/orchestrator/dist/waivers.js +60 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/specs/roadmap.md +24 -0
- package/package.json +1 -1
|
@@ -29,6 +29,7 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
|
29
29
|
import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
|
|
30
30
|
import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
|
|
31
31
|
import { checkDispatchAsymmetry } from './dispatchAudit.js';
|
|
32
|
+
import { lintDispatchPrompt } from './dispatchLint.js';
|
|
32
33
|
import { loadDotEnv } from './dotenv.js';
|
|
33
34
|
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
34
35
|
import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
|
|
@@ -48,23 +49,23 @@ import { judgePreflight } from './judgePreflight.js';
|
|
|
48
49
|
import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
|
|
49
50
|
import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
|
|
50
51
|
import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
51
|
-
import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
|
|
52
|
+
import { bareTaskId, diffPlans, livePlanTasks, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
|
|
52
53
|
import { runPlanQuorum } from './planQuorum.js';
|
|
53
54
|
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
54
55
|
import { factoryProjects } from './projects.js';
|
|
55
56
|
import { recordUserPrompt } from './prompts.js';
|
|
56
57
|
import { checkBrief, wrapIngested } from './provenance.js';
|
|
57
58
|
import { runJudge } from './providers/index.js';
|
|
58
|
-
import { admit, adopt, step } from './queue.js';
|
|
59
|
+
import { admit, adopt, batchStep, groupForBatch, step } from './queue.js';
|
|
59
60
|
import { stampResultEnvelope } from './results.js';
|
|
60
|
-
import { isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
|
|
61
|
+
import { FACTORY_PROJECT, isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
|
|
61
62
|
import { checkRuntime } from './runtime.js';
|
|
62
63
|
import { checkSameMistakeKpi } from './sameMistakeKpi.js';
|
|
63
64
|
import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
|
|
64
65
|
import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
|
|
65
66
|
import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
|
|
66
67
|
import { parseLessons } from './severity.js';
|
|
67
|
-
import { amendPlan, recordSpecReview } from './spec.js';
|
|
68
|
+
import { amendPlan, recordSpecReview, taskSuccessors } from './spec.js';
|
|
68
69
|
import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
|
|
69
70
|
import { checkStack, loadStackAnswers } from './stack.js';
|
|
70
71
|
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
@@ -84,6 +85,29 @@ function printJson(value) {
|
|
|
84
85
|
function readJsonFile(filePath) {
|
|
85
86
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* `--checks <file>`'s intake, both places it's read (`gate run`,
|
|
90
|
+
* `integration check`). Same fail-closed shape as `boundedIntFlag`, moved
|
|
91
|
+
* from a flag to a JSON field: a check's own `timeout_ms` goes straight into
|
|
92
|
+
* a `setTimeout` call in testgate.ts, and a non-positive or non-integer value
|
|
93
|
+
* there does not fail loudly — it fails as either an instant, unexplained
|
|
94
|
+
* timeout or one that silently never fires. Above 2^31-1 Node clamps the
|
|
95
|
+
* delay to 1ms, so an oversized value is the instant kind too. Refused here,
|
|
96
|
+
* at the point the file is read, rather than let a gate run act on either.
|
|
97
|
+
*/
|
|
98
|
+
const MAX_CHECK_TIMEOUT_MS = 2_147_483_647;
|
|
99
|
+
function readChecksFile(filePath) {
|
|
100
|
+
const checks = readJsonFile(filePath);
|
|
101
|
+
for (const check of checks) {
|
|
102
|
+
const timeoutMs = check.timeout_ms;
|
|
103
|
+
if (timeoutMs === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_CHECK_TIMEOUT_MS) {
|
|
106
|
+
throw new SmithError('gate.invalid-check-timeout', `"${check.name}" in ${filePath} has timeout_ms ${JSON.stringify(timeoutMs)}; it must be a positive whole number of milliseconds, at most ${MAX_CHECK_TIMEOUT_MS}.`, { name: check.name, timeout_ms: timeoutMs, file: filePath });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return checks;
|
|
110
|
+
}
|
|
87
111
|
/** A task spec's `budget.tokens` when it is a usable positive number, else null. */
|
|
88
112
|
function declaredTokens(budget) {
|
|
89
113
|
if (typeof budget !== 'object' || budget === null)
|
|
@@ -333,17 +357,81 @@ function scopeIssueCandidates(events, flags) {
|
|
|
333
357
|
return since === undefined || record.ts >= since;
|
|
334
358
|
});
|
|
335
359
|
}
|
|
336
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* Both `issues` actions read the same inputs; only the entry point differs.
|
|
362
|
+
*
|
|
363
|
+
* `resolveProject` and `selfFallback` together close the privacy-leak gap an
|
|
364
|
+
* unstamped row otherwise falls into: a session or plan driving a project
|
|
365
|
+
* other than this factory writes error/gate/task-added rows with no
|
|
366
|
+
* top-level `project` stamp. Both answer from the row's own task ref's
|
|
367
|
+
* epic's plan file (D-246's `db/projector.ts` precedent, restated here
|
|
368
|
+
* without a DB dependency `cli.ts`'s boot graph cannot carry) -- a plan on
|
|
369
|
+
* disk naming a project wins outright via `resolveProject`; a plan on disk
|
|
370
|
+
* naming none is this checkout's own epic, so `selfFallback` resolves it to
|
|
371
|
+
* `FACTORY_PROJECT`; no plan found at all (a foreign epic, or a bare ref)
|
|
372
|
+
* stays `null` from both, and `reportErrors`/`previewOutcomes` skip that row
|
|
373
|
+
* with `skipped-unresolved-project` rather than ever defaulting it into this
|
|
374
|
+
* factory's own repository.
|
|
375
|
+
*
|
|
376
|
+
* The two are kept as SEPARATE resolvers, not one combined
|
|
377
|
+
* `planProjectResolverForTaskRefOrSelf`, so `withSessionFallback`
|
|
378
|
+
* (`errorIssues.ts`) can try them as distinct tiers with a session's own
|
|
379
|
+
* stamp riding between them: `resolveProject` first, then any `project`
|
|
380
|
+
* stamp elsewhere in the same session (or a bare ref's `task-added`
|
|
381
|
+
* `epic_id` re-asked of `resolveProject`), and only then `selfFallback`
|
|
382
|
+
* (S2-c) -- a session-mate's real stamp naming a different project must
|
|
383
|
+
* outrank this weakest, locally-guessed signal, which a single combined
|
|
384
|
+
* resolver tried as one first tier could not guarantee.
|
|
385
|
+
*
|
|
386
|
+
* `--specs-dir` (via `planOptsFromFlags`) and `--state-dir` (via
|
|
387
|
+
* `eventOptsFromFlags`) are two independent flags on purpose: `--state-dir`
|
|
388
|
+
* says where THIS session's own events live, `--specs-dir` says where the
|
|
389
|
+
* epics IT references keep their plans, and a session's events and the
|
|
390
|
+
* plans its tasks belong to are not always colocated (the daemon's own
|
|
391
|
+
* event log and a target checkout's specs tree, for one). Requiring them to
|
|
392
|
+
* travel together would make `issues report` unable to point one flag at a
|
|
393
|
+
* remote-events state dir while resolving projects from this checkout's own
|
|
394
|
+
* specs/active, which is `smith`'s ordinary daemon posture. Callers who do
|
|
395
|
+
* mean the same tree for both pass the same path to both flags.
|
|
396
|
+
*
|
|
397
|
+
* `lineageEvents` is the session's UNSCOPED lineage, read before
|
|
398
|
+
* `scopeIssueCandidates` narrows `events` to `--epic`/`--since` (S3):
|
|
399
|
+
* `ORIGIN_STAMP_EVENT_TYPES` overlaps the scoped `ISSUE_CANDIDATE_EVENT_TYPES`
|
|
400
|
+
* set, so `sessionProjectStamps` reading straight off the narrowed `events`
|
|
401
|
+
* could lose a session-mate's stamp purely because `--epic`/`--since`
|
|
402
|
+
* excluded that row, changing an unrelated in-scope row's resolved project
|
|
403
|
+
* -- and therefore its fingerprint -- based only on which scoping flags were
|
|
404
|
+
* passed. `reportErrors`/`previewOutcomes` fold `events` for candidates but
|
|
405
|
+
* consult `lineageEvents` for the session-stamp tier, so `--epic`/`--since`
|
|
406
|
+
* narrow what gets reported without perturbing how a row's project is
|
|
407
|
+
* resolved.
|
|
408
|
+
*/
|
|
337
409
|
async function issueInputs(flags) {
|
|
338
410
|
const sessionId = requireFlag(flags, 'session');
|
|
339
411
|
const eventOpts = eventOptsFromFlags(flags);
|
|
340
412
|
requireSession(sessionId, eventOpts);
|
|
341
|
-
const
|
|
413
|
+
const lineageEvents = await readLineageEvents(sessionId, eventOpts);
|
|
414
|
+
const events = scopeIssueCandidates(lineageEvents, flags);
|
|
342
415
|
const milestones = loadRoadmap(flags['roadmap-path']);
|
|
343
416
|
const isEnabled = (project) => isErrorTrackerWritable(milestones, project);
|
|
344
|
-
|
|
417
|
+
const planOpts = planOptsFromFlags(flags);
|
|
418
|
+
const resolveProject = planProjectResolverForTaskRef(planOpts);
|
|
419
|
+
const selfFallback = planSelfFallbackForTaskRef(FACTORY_PROJECT, planOpts);
|
|
420
|
+
return {
|
|
421
|
+
events,
|
|
422
|
+
isEnabled,
|
|
423
|
+
register: factoryProjects(),
|
|
424
|
+
eventOpts,
|
|
425
|
+
resolveProject,
|
|
426
|
+
selfFallback,
|
|
427
|
+
lineageEvents,
|
|
428
|
+
};
|
|
345
429
|
}
|
|
346
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Where plan version files are read from and written to; defaults to
|
|
432
|
+
* factory/specs/active. See `issueInputs`'s doc comment for why this
|
|
433
|
+
* deliberately does not travel with `--state-dir`.
|
|
434
|
+
*/
|
|
347
435
|
function planOptsFromFlags(flags) {
|
|
348
436
|
return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
|
|
349
437
|
}
|
|
@@ -732,8 +820,19 @@ async function nextWaveInputFrom(planFile, flags) {
|
|
|
732
820
|
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
733
821
|
: [];
|
|
734
822
|
const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
|
|
823
|
+
// A lineage is not one epic. `readAddedTasks` reads the WHOLE lineage
|
|
824
|
+
// (D-119), so a resumed session that ever ran a different, earlier epic
|
|
825
|
+
// carries that epic's `task-added` rows too — and a row an operator
|
|
826
|
+
// abandoned non-terminal (never merged, waived, or superseded) reads back
|
|
827
|
+
// here exactly like a genuine follow-up of THIS plan. Unfiltered, it was
|
|
828
|
+
// spliced into `plan.tasks` below and reached `computeNextWave` as a
|
|
829
|
+
// candidate or an occupied claim-holder: proposed as this epic's own work,
|
|
830
|
+
// and able to defer this epic's tasks on a claim overlap that has nothing
|
|
831
|
+
// to do with them. `wave check` never has this problem because it only
|
|
832
|
+
// ever looks at the ids an operator names — matching that, a follow-up
|
|
833
|
+
// only counts here when its own `task-added` named this plan's epic.
|
|
735
834
|
const followUps = logged
|
|
736
|
-
.filter((t) => !planIds.has(t.taskId))
|
|
835
|
+
.filter((t) => !planIds.has(t.taskId) && t.epicId === planOnDisk.epic_id)
|
|
737
836
|
.map((t) => ({
|
|
738
837
|
task_id: t.taskId,
|
|
739
838
|
// The log is the only register that holds this task's status, so a
|
|
@@ -898,7 +997,20 @@ async function main() {
|
|
|
898
997
|
}
|
|
899
998
|
if (namespace === 'audit' && action === 'resolve') {
|
|
900
999
|
const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
|
|
901
|
-
|
|
1000
|
+
// Comma-split, not repeated: a fingerprint has no commas, and deferring
|
|
1001
|
+
// more than one at a time is the common case (D-33's precedent, `plan
|
|
1002
|
+
// amend --findings`).
|
|
1003
|
+
const except = flags.except
|
|
1004
|
+
? flags.except
|
|
1005
|
+
.split(',')
|
|
1006
|
+
.map((fp) => fp.trim())
|
|
1007
|
+
.filter((fp) => fp !== '')
|
|
1008
|
+
: undefined;
|
|
1009
|
+
printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags), {
|
|
1010
|
+
...(except ? { except } : {}),
|
|
1011
|
+
...(flags.plan ? { plan: readJsonFile(flags.plan) } : {}),
|
|
1012
|
+
planOpts: planOptsFromFlags(flags),
|
|
1013
|
+
}));
|
|
902
1014
|
return 0;
|
|
903
1015
|
}
|
|
904
1016
|
if (namespace === 'audit' && action === 'close') {
|
|
@@ -1004,6 +1116,14 @@ async function main() {
|
|
|
1004
1116
|
// what went unnoticed.
|
|
1005
1117
|
const edges = await emitEdgesRecorded(plan, ctx, opts);
|
|
1006
1118
|
const added = written.filter((e) => e.record.event_type === 'task-added').length;
|
|
1119
|
+
// Criterion 7: a plan that serializes by claim geometry alone used to be
|
|
1120
|
+
// invisible until `/bs run` actually hit the ceiling. Dynamic for the
|
|
1121
|
+
// same reason `wave schedule` imports it dynamically -- nothing an
|
|
1122
|
+
// ingest that never schedules needs should be on the boot path every
|
|
1123
|
+
// `smith --help` walks. No repo scan here: `parallel_with` is
|
|
1124
|
+
// claims-and-edges only, so the input carries no crossings.
|
|
1125
|
+
const { computePlanParallelism } = await import('./waveSchedule.js');
|
|
1126
|
+
const parallelism = computePlanParallelism(plan, loadWorktreePolicy());
|
|
1007
1127
|
printJson({
|
|
1008
1128
|
epic: plan.epic_id,
|
|
1009
1129
|
version: plan.version,
|
|
@@ -1011,6 +1131,7 @@ async function main() {
|
|
|
1011
1131
|
superseded: written.length - added,
|
|
1012
1132
|
skipped: plan.tasks.length - added,
|
|
1013
1133
|
edges: edges.length,
|
|
1134
|
+
parallelism,
|
|
1014
1135
|
});
|
|
1015
1136
|
return 0;
|
|
1016
1137
|
}
|
|
@@ -1280,9 +1401,13 @@ async function main() {
|
|
|
1280
1401
|
// Static would be fine — waveSchedule.js reaches nothing waveNext.js has
|
|
1281
1402
|
// not already put on the boot path — but the input assembly it is handed
|
|
1282
1403
|
// may dynamically import `db/projector.js`, so the await is here anyway.
|
|
1283
|
-
const { scheduleWaves } = await import('./waveSchedule.js');
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1404
|
+
const { computePlanParallelism, scheduleWaves } = await import('./waveSchedule.js');
|
|
1405
|
+
const input = await nextWaveInputFrom(planFile, flags);
|
|
1406
|
+
const schedule = scheduleWaves(input);
|
|
1407
|
+
// Same `size` block `plan ingest` prints under `parallelism`, so the
|
|
1408
|
+
// operator sees the small-epic bound from either command.
|
|
1409
|
+
const { size } = computePlanParallelism(input.plan, input.policy);
|
|
1410
|
+
printJson({ ...schedule, size });
|
|
1286
1411
|
// Writes nothing, for the reason `wave next` writes nothing and one more:
|
|
1287
1412
|
// every round after the first is a simulation. The tasks it marks complete
|
|
1288
1413
|
// were completed by nobody, and a log that recorded them would be claiming
|
|
@@ -1401,7 +1526,21 @@ async function main() {
|
|
|
1401
1526
|
const projectDirs = resolveProjectDirs(repeated.project, {
|
|
1402
1527
|
self: flags['no-self'] !== 'true',
|
|
1403
1528
|
});
|
|
1404
|
-
|
|
1529
|
+
// Same resolver `issues report`/`preview` and the daemon use (D-246): an
|
|
1530
|
+
// error-report proposal's fingerprint is hashed over its `project`
|
|
1531
|
+
// (computeFingerprint), so this verb has to resolve a row's project the
|
|
1532
|
+
// same way the reporter that later clears the proposal does. A divergent
|
|
1533
|
+
// resolver here -- or none, which folds every row to `project: null` --
|
|
1534
|
+
// hashes a different fingerprint than the reporter's `issue-reported`
|
|
1535
|
+
// event names, so the proposal `scheduler run` keeps proposing never
|
|
1536
|
+
// matches the one already answered and is re-appended forever (S1-b).
|
|
1537
|
+
const input = {
|
|
1538
|
+
events,
|
|
1539
|
+
now,
|
|
1540
|
+
projectDirs,
|
|
1541
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1542
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1543
|
+
};
|
|
1405
1544
|
if (dry) {
|
|
1406
1545
|
printJson({ proposals: computeProposals(input) });
|
|
1407
1546
|
return 0;
|
|
@@ -1448,11 +1587,17 @@ async function main() {
|
|
|
1448
1587
|
// This clone joins the pass by default (contract clause 1); `--no-self`
|
|
1449
1588
|
// is the refusal path.
|
|
1450
1589
|
const { resolveProjectDirs } = await import('./projects.js');
|
|
1590
|
+
// Same resolver as `scheduler run` above, for the same reason (S1-b): a
|
|
1591
|
+
// divergent or absent resolver hashes a different fingerprint than the
|
|
1592
|
+
// reporter's `issue-reported` event names, so an already-answered
|
|
1593
|
+
// error-report proposal never clears.
|
|
1451
1594
|
const proposals = computeProposals({
|
|
1452
1595
|
events,
|
|
1453
1596
|
now,
|
|
1454
1597
|
policy,
|
|
1455
1598
|
projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
|
|
1599
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1600
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1456
1601
|
});
|
|
1457
1602
|
// A RecheckProposal names a task and no paths, so without this the
|
|
1458
1603
|
// security match would see an opaque id and clear a recheck of
|
|
@@ -1501,6 +1646,15 @@ async function main() {
|
|
|
1501
1646
|
...eventOptsFromFlags(flags),
|
|
1502
1647
|
projectDirs: resolveProjectDirs(repeated.project, { self }),
|
|
1503
1648
|
readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
|
|
1649
|
+
// Same D-246 self-fallback as issueInputs (see its doc comment): a
|
|
1650
|
+
// plan on disk naming no project is this checkout's own epic, so it
|
|
1651
|
+
// reads as FACTORY_PROJECT rather than staying an unresolved null the
|
|
1652
|
+
// daemon would report as an unlabeled finding and the on/off switch
|
|
1653
|
+
// for 'black-smith' would never get asked about. Split into strict
|
|
1654
|
+
// (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
|
|
1655
|
+
// tiers so a session's own stamp can ride between them (S2-c).
|
|
1656
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1657
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1504
1658
|
...(flags.db ? { dbPath: flags.db } : {}),
|
|
1505
1659
|
...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
|
|
1506
1660
|
};
|
|
@@ -1636,6 +1790,25 @@ async function main() {
|
|
|
1636
1790
|
}
|
|
1637
1791
|
if (namespace === 'worktree' && action === 'create') {
|
|
1638
1792
|
const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
|
|
1793
|
+
if (flags.from !== undefined) {
|
|
1794
|
+
// `--from` reuses a predecessor's commits, so it may only be spent on a
|
|
1795
|
+
// task the log actually names as that predecessor's successor — never
|
|
1796
|
+
// on a bare "I want the same branch history" request. `--session` is
|
|
1797
|
+
// what supplies the log to check against; without it there is nothing
|
|
1798
|
+
// to refuse against, so demand it explicitly rather than silently
|
|
1799
|
+
// trusting an unauthenticated `--from`.
|
|
1800
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1801
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1802
|
+
const successors = taskSuccessors(events, epic);
|
|
1803
|
+
const from = flags.from;
|
|
1804
|
+
const expected = bareTaskId(epic, taskId);
|
|
1805
|
+
const actual = successors.get(from) ?? successors.get(`${epic}/${from}`);
|
|
1806
|
+
if (actual === undefined || bareTaskId(epic, actual) !== expected) {
|
|
1807
|
+
throw new SmithError('worktree.not-a-successor', `${epic}/${taskId} is not logged as the successor of ${epic}/${from}; run \`plan propose\`/\`plan approve\` with a supersede pairing them first.`, { epic, taskId, from });
|
|
1808
|
+
}
|
|
1809
|
+
printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
|
|
1810
|
+
return 0;
|
|
1811
|
+
}
|
|
1639
1812
|
printJson(createTaskWorktree(projectDir, epic, taskId));
|
|
1640
1813
|
return 0;
|
|
1641
1814
|
}
|
|
@@ -1752,6 +1925,28 @@ async function main() {
|
|
|
1752
1925
|
if (events && !flags.plan) {
|
|
1753
1926
|
throw new SmithError('cli.missing-flag', 'queue run --session also needs --plan <plan.json>: a merge may only be logged under the task id the plan declares.', { epic });
|
|
1754
1927
|
}
|
|
1928
|
+
// --batch (roadmap merge-lanes) groups tasks with groupForBatch before
|
|
1929
|
+
// any of them run, and that grouping needs two things `--tasks` alone
|
|
1930
|
+
// does not carry: the plan's `edges`, so a task never stacks with
|
|
1931
|
+
// something it depends_on, and — for a tasks-file entry that omits its
|
|
1932
|
+
// own `claims` — the plan's claim list, so a hand-typed batch run is not
|
|
1933
|
+
// blind to a disjointness `wave admit` already knows. Same shape as the
|
|
1934
|
+
// `--session` guard just above, and for the same reason: refuse the run
|
|
1935
|
+
// whole rather than group some tasks correctly and others by guesswork.
|
|
1936
|
+
if (flags.batch === 'true' && !flags.plan) {
|
|
1937
|
+
throw new SmithError('cli.missing-flag', 'queue run --batch also needs --plan <plan.json>: grouping reads its dependency edges and claim lists from there.', { epic });
|
|
1938
|
+
}
|
|
1939
|
+
// `attemptCandidate` runs the epic's test command directly against the
|
|
1940
|
+
// whole batch candidate — there is no per-task file set to narrow it to,
|
|
1941
|
+
// so a `--select-test-cmd` template would either render nonsensically or
|
|
1942
|
+
// silently run the full suite while the outcome claimed it was selected
|
|
1943
|
+
// (the same lie D-260 refuses above). Refuse the combination instead of
|
|
1944
|
+
// ignoring the flag.
|
|
1945
|
+
if (flags.batch === 'true' && selectTestCmd !== undefined) {
|
|
1946
|
+
throw new SmithError('cli.incompatible-flags', 'queue run --batch does not support --select-test-cmd: a batch candidate has no single task to narrow the test command to.', { epic });
|
|
1947
|
+
}
|
|
1948
|
+
let edges = [];
|
|
1949
|
+
let claimsById = new Map();
|
|
1755
1950
|
if (flags.plan) {
|
|
1756
1951
|
const plan = readJsonFile(flags.plan);
|
|
1757
1952
|
// …and the plan is not the only register: a follow-up minted by
|
|
@@ -1763,6 +1958,11 @@ async function main() {
|
|
|
1763
1958
|
const loggedIds = logged.map((t) => t.taskId);
|
|
1764
1959
|
for (const task of tasks)
|
|
1765
1960
|
task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
|
|
1961
|
+
edges = plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn }));
|
|
1962
|
+
claimsById = new Map(plan.tasks.map((t) => [
|
|
1963
|
+
t.task_id,
|
|
1964
|
+
Array.isArray(t.claims) ? t.claims.filter((c) => typeof c === 'string') : [],
|
|
1965
|
+
]));
|
|
1766
1966
|
// D-186: the ids are the plan's now, so the order can be too. `--tasks`
|
|
1767
1967
|
// is hand-written, and merging in the order someone typed lets a task
|
|
1768
1968
|
// land before the task it declares `depends_on` — the epic's cumulative
|
|
@@ -1770,21 +1970,81 @@ async function main() {
|
|
|
1770
1970
|
// and blames the wrong branch when it goes red. `admit()` exists for
|
|
1771
1971
|
// exactly this and had no caller until here; a follow-up the plan never
|
|
1772
1972
|
// declared carries no edges, so it just sorts by id among its peers.
|
|
1773
|
-
const order = admit(tasks.map((t) => ({ task_id: t.taskId })),
|
|
1973
|
+
const order = admit(tasks.map((t) => ({ task_id: t.taskId })), edges);
|
|
1774
1974
|
// Stable: two tasks the plan does not order keep the order admit gave
|
|
1775
1975
|
// them, and a duplicated id is still run twice rather than dropped.
|
|
1776
1976
|
tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
|
|
1777
1977
|
}
|
|
1978
|
+
const stepOpts = {
|
|
1979
|
+
projectDir,
|
|
1980
|
+
epic,
|
|
1981
|
+
testCmd,
|
|
1982
|
+
...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
|
|
1983
|
+
...(events ? { events } : {}),
|
|
1984
|
+
};
|
|
1778
1985
|
const outcomes = [];
|
|
1779
1986
|
let allMerged = true;
|
|
1987
|
+
if (flags.batch === 'true') {
|
|
1988
|
+
const byId = new Map(tasks.map((t) => [t.taskId, t]));
|
|
1989
|
+
// A tasks-file entry's own `claims` wins over the plan's — the plan
|
|
1990
|
+
// describes what the task was authored to touch, the tasks file is
|
|
1991
|
+
// what actually ran, and the two can disagree the same way a diff can
|
|
1992
|
+
// outgrow the claim it was cut from.
|
|
1993
|
+
const groupable = tasks.map((t) => ({
|
|
1994
|
+
taskId: t.taskId,
|
|
1995
|
+
claims: t.claims ?? claimsById.get(t.taskId),
|
|
1996
|
+
}));
|
|
1997
|
+
const groups = groupForBatch(groupable, edges, loadWorktreePolicy().serializeAlwaysGlobs);
|
|
1998
|
+
const batches = [];
|
|
1999
|
+
for (const group of groups) {
|
|
2000
|
+
const groupTasks = group.map((id) => {
|
|
2001
|
+
const t = byId.get(id);
|
|
2002
|
+
if (!t) {
|
|
2003
|
+
// groupForBatch only ever returns ids it was handed, so this
|
|
2004
|
+
// would mean the group and --tasks have drifted apart — a wrong
|
|
2005
|
+
// candidate is worse than a refusal.
|
|
2006
|
+
throw new SmithError('queue.batch-unknown-task', `--batch grouped task "${id}" but no task by that id was in --tasks.`, { epic, taskId: id });
|
|
2007
|
+
}
|
|
2008
|
+
return t;
|
|
2009
|
+
});
|
|
2010
|
+
// groupForBatch's own contract (see its doc comment): a group of one
|
|
2011
|
+
// is not a batch, it's a task that closed the open group before it —
|
|
2012
|
+
// no candidate to stack, so it runs through the plain step it would
|
|
2013
|
+
// have taken without --batch at all, rather than through batchStep's
|
|
2014
|
+
// fold/candidate-worktree machinery for a "stack" of one.
|
|
2015
|
+
if (groupTasks.length === 1) {
|
|
2016
|
+
const [only] = groupTasks;
|
|
2017
|
+
const outcome = await step(only, stepOpts);
|
|
2018
|
+
outcomes.push(outcome);
|
|
2019
|
+
const landed = outcome.outcome === 'merged';
|
|
2020
|
+
// `step` refuses before any test runs on `nothing-to-merge` (D-30's
|
|
2021
|
+
// uncommitted-work guard) and `rebase-conflict` — 0 suites ran, not
|
|
2022
|
+
// 1, for either. Every other outcome it can return (`merged`,
|
|
2023
|
+
// `tests-failed`, `integration-dirty`) only happens after
|
|
2024
|
+
// `runTestCmd`.
|
|
2025
|
+
const suiteRuns = outcome.outcome === 'nothing-to-merge' || outcome.outcome === 'rebase-conflict' ? 0 : 1;
|
|
2026
|
+
batches.push({ task_ids: group, suite_runs: suiteRuns, landed });
|
|
2027
|
+
if (!landed) {
|
|
2028
|
+
allMerged = false;
|
|
2029
|
+
break;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
const result = await batchStep(groupTasks, stepOpts);
|
|
2034
|
+
outcomes.push(...result.outcomes);
|
|
2035
|
+
const landed = result.outcomes.every((o) => o.outcome === 'merged');
|
|
2036
|
+
batches.push({ task_ids: group, suite_runs: result.suiteRuns, landed });
|
|
2037
|
+
if (!landed) {
|
|
2038
|
+
allMerged = false;
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
printJson({ outcomes, batches });
|
|
2044
|
+
return allMerged ? 0 : 1;
|
|
2045
|
+
}
|
|
1780
2046
|
for (const task of tasks) {
|
|
1781
|
-
const outcome = await step(task,
|
|
1782
|
-
projectDir,
|
|
1783
|
-
epic,
|
|
1784
|
-
testCmd,
|
|
1785
|
-
...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
|
|
1786
|
-
...(events ? { events } : {}),
|
|
1787
|
-
});
|
|
2047
|
+
const outcome = await step(task, stepOpts);
|
|
1788
2048
|
outcomes.push(outcome);
|
|
1789
2049
|
if (outcome.outcome !== 'merged') {
|
|
1790
2050
|
allMerged = false;
|
|
@@ -2013,6 +2273,27 @@ async function main() {
|
|
|
2013
2273
|
printJson(report);
|
|
2014
2274
|
return report.ok ? 0 : 1;
|
|
2015
2275
|
}
|
|
2276
|
+
if (namespace === 'dispatch' && action === 'lint') {
|
|
2277
|
+
// dispatch.md "Carry into the prompt" / "Declare each judge's artifact":
|
|
2278
|
+
// catches a stated turn budget that disagrees with the template's
|
|
2279
|
+
// `maxTurns` in either direction, a template it cannot read at all, and a
|
|
2280
|
+
// judge prompt missing (or mismatching) its declared-artifact line,
|
|
2281
|
+
// before the agent ever runs. Fails closed: any applicable status but
|
|
2282
|
+
// `ok` exits 1. See dispatchLint.ts for why each status means what it
|
|
2283
|
+
// means. `-` reads stdin, same convention as `prompt wrap`/`prompt record`.
|
|
2284
|
+
const [file] = requirePositionals(positional, usageFor('dispatch lint'), 1);
|
|
2285
|
+
const prompt = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
|
|
2286
|
+
const report = await lintDispatchPrompt({
|
|
2287
|
+
prompt,
|
|
2288
|
+
role: requireFlag(flags, 'role'),
|
|
2289
|
+
taskId: requireFlag(flags, 'task'),
|
|
2290
|
+
sessionId: requireFlag(flags, 'session'),
|
|
2291
|
+
...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
|
|
2292
|
+
eventOpts: eventOptsFromFlags(flags),
|
|
2293
|
+
});
|
|
2294
|
+
printJson(report);
|
|
2295
|
+
return report.exitCode;
|
|
2296
|
+
}
|
|
2016
2297
|
if (namespace === 'tester' && action === 'check') {
|
|
2017
2298
|
// The other half of `dispatch check`: that one asks whether the critic ran
|
|
2018
2299
|
// on the finder's model, this one asks whether a tester ran at all, in a
|
|
@@ -2179,20 +2460,20 @@ async function main() {
|
|
|
2179
2460
|
}
|
|
2180
2461
|
if (namespace === 'issues' && action === 'report') {
|
|
2181
2462
|
// The one path in this epic that runs `gh` for real.
|
|
2182
|
-
const { events, isEnabled, register, eventOpts } = await issueInputs(flags);
|
|
2463
|
+
const { events, isEnabled, register, eventOpts, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
|
|
2183
2464
|
const clock = () => new Date().toISOString();
|
|
2184
|
-
printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts));
|
|
2465
|
+
printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts, resolveProject, selfFallback, lineageEvents));
|
|
2185
2466
|
return 0;
|
|
2186
2467
|
}
|
|
2187
2468
|
if (namespace === 'issues' && action === 'preview') {
|
|
2188
2469
|
// Same inputs, no `gh` and no event: the runner throws if anything
|
|
2189
2470
|
// reaches it, so a preview that spawned would fail loudly, not quietly.
|
|
2190
|
-
const { events, isEnabled, register } = await issueInputs(flags);
|
|
2471
|
+
const { events, isEnabled, register, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
|
|
2191
2472
|
const neverRun = (cmd) => {
|
|
2192
2473
|
throw new Error(`issues preview must never run a command, asked for ${cmd}`);
|
|
2193
2474
|
};
|
|
2194
2475
|
const clock = () => new Date().toISOString();
|
|
2195
|
-
printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock));
|
|
2476
|
+
printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock, resolveProject, selfFallback, lineageEvents));
|
|
2196
2477
|
return 0;
|
|
2197
2478
|
}
|
|
2198
2479
|
// The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
|
|
@@ -2367,7 +2648,7 @@ async function main() {
|
|
|
2367
2648
|
const { runGate } = await import('./gate.js');
|
|
2368
2649
|
const [taskId] = requirePositionals(positional, usageFor('gate run'));
|
|
2369
2650
|
const worktreeDir = requireFlag(flags, 'worktree');
|
|
2370
|
-
const checks =
|
|
2651
|
+
const checks = readChecksFile(requireFlag(flags, 'checks'));
|
|
2371
2652
|
// The result file has the same two intake shapes as findings below, and for
|
|
2372
2653
|
// the same reason. With `--agent`, `--result` is the worker's half —
|
|
2373
2654
|
// run_status/structured_output/artifacts — and the dispatcher stamps the
|
|
@@ -2564,7 +2845,7 @@ async function main() {
|
|
|
2564
2845
|
if (namespace === 'integration' && action === 'check') {
|
|
2565
2846
|
const epicId = requireFlag(flags, 'epic');
|
|
2566
2847
|
const projectDir = requireFlag(flags, 'project');
|
|
2567
|
-
const checks =
|
|
2848
|
+
const checks = readChecksFile(requireFlag(flags, 'checks'));
|
|
2568
2849
|
const ctx = eventContextFromFlags(flags);
|
|
2569
2850
|
const record = await runIntegrationCheck({
|
|
2570
2851
|
epicId,
|
|
@@ -123,6 +123,26 @@ export function reconcile(input) {
|
|
|
123
123
|
if (!nativeByFingerprint.has(finding.fingerprint)) {
|
|
124
124
|
nativeByFingerprint.set(finding.fingerprint, finding);
|
|
125
125
|
}
|
|
126
|
+
// Also key by the fingerprint computeFingerprint() would produce for this
|
|
127
|
+
// record TODAY, not just the one stored at raise time. `input.native`
|
|
128
|
+
// comes straight out of listFindings() (a lineage-wide, possibly
|
|
129
|
+
// long-lived read) while `group.fingerprint` below is always computed
|
|
130
|
+
// fresh from the independent provider's evidence -- so a native finding
|
|
131
|
+
// raised before issue #178 widened the normalizer (its summary containing
|
|
132
|
+
// a line span the old algorithm did not strip) would otherwise never
|
|
133
|
+
// match an independent finder's evidence for the identical bug, and get
|
|
134
|
+
// reported as `co-located`/`independent-only` plus a duplicate
|
|
135
|
+
// `native-only` instead of `corroborated`.
|
|
136
|
+
if (finding.file_path !== undefined) {
|
|
137
|
+
const recomputed = computeFingerprint({
|
|
138
|
+
filePath: finding.file_path,
|
|
139
|
+
category: finding.finding_category,
|
|
140
|
+
summary: finding.summary,
|
|
141
|
+
});
|
|
142
|
+
if (!nativeByFingerprint.has(recomputed)) {
|
|
143
|
+
nativeByFingerprint.set(recomputed, finding);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
126
146
|
if (finding.file_path === undefined)
|
|
127
147
|
continue;
|
|
128
148
|
const key = coLocationKey(finding.file_path, finding.finding_category);
|