@yagni-app/code-staging 0.3.2-staging.1112.1 → 0.3.2-staging.1115.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/dist/cli.js +13 -0
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +22 -0
- package/dist/extension/permission.d.ts +7 -0
- package/dist/extension/permission.js +100 -1
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -1
- package/dist/extension/pipeline/invocation.js +7 -1
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +117 -6
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, register
|
|
|
28
28
|
import { registerGoCommand } from "./pipeline/goCommand.js";
|
|
29
29
|
import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
30
30
|
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
|
|
31
|
+
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
31
32
|
import { registerSubagents } from "./subagents.js";
|
|
32
33
|
import { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
33
34
|
import { registerTodos } from "./todos.js";
|
|
@@ -206,6 +207,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
206
207
|
// The grounded multi-agent pipeline entry point: /go <ticket> runs
|
|
207
208
|
// map → plan → implement → review → fix, each child grounded by inheritance.
|
|
208
209
|
registerGoCommand(pi, {
|
|
210
|
+
// /ultra is one dial for the whole session: the same holder the subagent
|
|
211
|
+
// tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
|
|
212
|
+
// the fan and its fix turns. Read per run, so a toggle lands on the next /go.
|
|
213
|
+
isUltra: () => ultraHolder.get(),
|
|
209
214
|
// Task 8: /go's end-of-run summary prefers the server-priced per-stage
|
|
210
215
|
// breakdown for the ONE run that just finished (`?runId=`, aliasing the
|
|
211
216
|
// spend endpoint's `sessionId` param — see the backend route), over
|
|
@@ -280,6 +285,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
280
285
|
// rejected as a same-session self-authorization path, PR #1698).
|
|
281
286
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
282
287
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
288
|
+
// YAG-506: load user-configurable lifecycle hooks config and create the
|
|
289
|
+
// hook runner for the permission gate. Skipped in eval mode.
|
|
290
|
+
const hooksConfig = evalMode ? {} : loadHooksConfig();
|
|
291
|
+
const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
|
|
292
|
+
if (!evalMode)
|
|
293
|
+
registerHooks(pi, { config: hooksConfig });
|
|
283
294
|
registerPermissionGate(pi, {
|
|
284
295
|
modeHolder,
|
|
285
296
|
guardianState,
|
|
@@ -362,6 +373,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
362
373
|
onBlessRemember: decisionCapture
|
|
363
374
|
? (ctx, info) => decisionCapture.captureFromBless(ctx, info)
|
|
364
375
|
: undefined,
|
|
376
|
+
...(hookRunner ? { hookRunner } : {}),
|
|
365
377
|
});
|
|
366
378
|
// P2/YAG-383: /cost prefers the server-authoritative session spend (covers
|
|
367
379
|
// subagents and advisor consults directly, since they bill under this same
|
|
@@ -859,12 +871,22 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
|
|
|
859
871
|
// Onramp Door B (F2a): the one-time init-pass idempotency marker.
|
|
860
872
|
export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
|
|
861
873
|
export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
|
|
874
|
+
// YAG-506: user-configurable lifecycle hooks.
|
|
875
|
+
export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
|
|
862
876
|
// Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
|
|
863
877
|
export { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
864
878
|
export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
|
|
865
879
|
export { buildYagniProvider } from "./provider.js";
|
|
866
880
|
export { registerGoCommand } from "./pipeline/goCommand.js";
|
|
867
881
|
export { runPipeline } from "./pipeline/orchestrator.js";
|
|
882
|
+
// The headless front door (`yagni go --headless`): the same pipeline, driven by
|
|
883
|
+
// a script or the mission sandbox instead of a session.
|
|
884
|
+
export { HEADLESS_GO_EXIT, HEADLESS_GO_USAGE, parseHeadlessGoArgs, runHeadlessGo, validateHeadlessGoArgs, } from "./pipeline/headlessGo.js";
|
|
885
|
+
// Mission mode: the pure rules for entering the pipeline on an already-approved
|
|
886
|
+
// plan (map/plan skipped, memo as the stand-in repo brief, FINISH not ours).
|
|
887
|
+
export { isMissionMode, missionSeed, missionSkippedStages, normalizeMission } from "./pipeline/mission.js";
|
|
888
|
+
// The eval/smoke tier ceiling (YAGNI_GO_TIER_CAP), clamped centrally in runStage.
|
|
889
|
+
export { clampTier, parseTierCap, resolveTierCap, TIER_CAP_ENV } from "./pipeline/tierCap.js";
|
|
868
890
|
// R1: the in-loop resilience HOF a dev can compose with (or replace at) the
|
|
869
891
|
// `runStage` seam, plus its policy type and the default policy.
|
|
870
892
|
export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
|
|
31
|
+
import type { HookRunner } from "./hooks.js";
|
|
31
32
|
import { type BlessStore } from "./bless.js";
|
|
32
33
|
import { type ExecPolicy } from "./execPolicy.js";
|
|
33
34
|
import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
|
|
@@ -179,6 +180,12 @@ export interface RegisterPermissionDeps {
|
|
|
179
180
|
* Fail-soft; never blocks.
|
|
180
181
|
*/
|
|
181
182
|
onGuardianEvent?: (event: GuardianGateEvent) => void;
|
|
183
|
+
/**
|
|
184
|
+
* User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
|
|
185
|
+
* hooks run before decideGate and can short-circuit (allow/deny/ask),
|
|
186
|
+
* and PermissionRequest hooks run before the confirm dialog.
|
|
187
|
+
*/
|
|
188
|
+
hookRunner?: HookRunner;
|
|
182
189
|
}
|
|
183
190
|
/** The customType tag on injected mode-context messages (filterable later). */
|
|
184
191
|
export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
@@ -296,6 +296,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
296
296
|
const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
|
|
297
297
|
let mode = deps.mode ?? "auto";
|
|
298
298
|
const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
|
|
299
|
+
const hookRunner = deps.hookRunner;
|
|
299
300
|
deps.modeHolder?.onSet((m) => {
|
|
300
301
|
if (m !== mode)
|
|
301
302
|
approvedCommands.clear();
|
|
@@ -422,7 +423,48 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
422
423
|
const modeAtEntry = mode;
|
|
423
424
|
try {
|
|
424
425
|
const input = event.input ?? {};
|
|
425
|
-
|
|
426
|
+
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
427
|
+
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
428
|
+
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
429
|
+
// re-invoke the hook (hooks have side effects — notifications etc.).
|
|
430
|
+
let preToolUseResult;
|
|
431
|
+
if (hookRunner) {
|
|
432
|
+
const cwd = ctx?.cwd ?? ".";
|
|
433
|
+
try {
|
|
434
|
+
preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
|
|
435
|
+
if (preToolUseResult) {
|
|
436
|
+
if (preToolUseResult.decision === "deny") {
|
|
437
|
+
return { block: true, reason: preToolUseResult.reason };
|
|
438
|
+
}
|
|
439
|
+
if (preToolUseResult.decision === "allow") {
|
|
440
|
+
// Allow bypasses Guardian/confirm, but the exec policy's forbidden
|
|
441
|
+
// band still runs as a hard safety floor (deliberate deviation
|
|
442
|
+
// from Claude Code: we don't let a hook auto-allow a forbidden cmd).
|
|
443
|
+
if (event.toolName === "bash") {
|
|
444
|
+
const cmdRaw = input.command;
|
|
445
|
+
const command = typeof cmdRaw === "string" ? cmdRaw.trim() : "";
|
|
446
|
+
if (command) {
|
|
447
|
+
const execPolicy = effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
448
|
+
const classification = classifyCommand(command, execPolicy);
|
|
449
|
+
if (classification.decision === "forbidden") {
|
|
450
|
+
return {
|
|
451
|
+
block: true,
|
|
452
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
// "ask" → force confirm by overriding the gate decision
|
|
460
|
+
// Falls through to decision.confirm logic below
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Fail-soft: a hook error never blocks or allows; fall through to gate
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
426
468
|
if (decision.block)
|
|
427
469
|
return { block: true, reason: decision.reason };
|
|
428
470
|
// Prompt band (YAG-510 order): grants → exact-command cache → cap/
|
|
@@ -587,6 +629,41 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
587
629
|
reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
|
|
588
630
|
};
|
|
589
631
|
}
|
|
632
|
+
// YAG-506: PermissionRequest hooks fire before the confirm dialog.
|
|
633
|
+
// Only when a UI is present (headless path already failed closed above).
|
|
634
|
+
if (hookRunner && ctx?.hasUI) {
|
|
635
|
+
try {
|
|
636
|
+
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
637
|
+
if (hookResult) {
|
|
638
|
+
if (hookResult.decision === "allow") {
|
|
639
|
+
rememberApproved(cwd, command);
|
|
640
|
+
emitGateEvent({
|
|
641
|
+
...eventBase,
|
|
642
|
+
outcome: "ask_approved",
|
|
643
|
+
riskLevel: verdict.riskLevel,
|
|
644
|
+
rationale: verdict.rationale,
|
|
645
|
+
durationMs,
|
|
646
|
+
consulted: true,
|
|
647
|
+
});
|
|
648
|
+
return {};
|
|
649
|
+
}
|
|
650
|
+
if (hookResult.decision === "deny") {
|
|
651
|
+
emitGateEvent({
|
|
652
|
+
...eventBase,
|
|
653
|
+
outcome: "ask_denied",
|
|
654
|
+
riskLevel: verdict.riskLevel,
|
|
655
|
+
rationale: verdict.rationale,
|
|
656
|
+
durationMs,
|
|
657
|
+
consulted: true,
|
|
658
|
+
});
|
|
659
|
+
return { block: true, reason: hookResult.reason };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Fail-soft: hook error → dialog proceeds normally
|
|
665
|
+
}
|
|
666
|
+
}
|
|
590
667
|
// Offer "don't ask again" only when the grant would actually
|
|
591
668
|
// cover this command (grant-time validation).
|
|
592
669
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
@@ -725,7 +802,29 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
725
802
|
}
|
|
726
803
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
727
804
|
}
|
|
805
|
+
// YAG-506: PreToolUse "ask" forces confirmation even in auto mode.
|
|
806
|
+
// Uses the cached result from the top of the handler — no re-invocation.
|
|
807
|
+
if (preToolUseResult?.decision === "ask") {
|
|
808
|
+
decision = { block: false, confirm: true };
|
|
809
|
+
}
|
|
728
810
|
if (decision.confirm) {
|
|
811
|
+
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
812
|
+
if (hookRunner) {
|
|
813
|
+
try {
|
|
814
|
+
const cwd = ctx?.cwd ?? ".";
|
|
815
|
+
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
816
|
+
if (hookResult) {
|
|
817
|
+
if (hookResult.decision === "allow")
|
|
818
|
+
return {};
|
|
819
|
+
if (hookResult.decision === "deny") {
|
|
820
|
+
return { block: true, reason: hookResult.reason };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Fail-soft: hook error → dialog proceeds normally
|
|
826
|
+
}
|
|
827
|
+
}
|
|
729
828
|
// Review mode needs a confirmation. With no dialog-capable UI (headless),
|
|
730
829
|
// fail CLOSED: the user explicitly chose a stricter mode, so a write we
|
|
731
830
|
// cannot get consent for is held rather than silently auto-applied (this
|
|
@@ -92,9 +92,10 @@ export class ActivityFeed {
|
|
|
92
92
|
}
|
|
93
93
|
/** Fold a structured progress signal into stage transitions + the header tally. */
|
|
94
94
|
applyProgress(p) {
|
|
95
|
-
// A per-lens signal is a desktop concern: it must not steal
|
|
96
|
-
// the
|
|
97
|
-
|
|
95
|
+
// A per-lens or per-workstream signal is a desktop concern: it must not steal
|
|
96
|
+
// the buffer from the stage that owns it, nor reset it mid fan-out. The
|
|
97
|
+
// implement diamond's builders get exactly the review lenses' treatment.
|
|
98
|
+
if (p.kind === "stage_start" && !p.lens && !p.workstream && p.stageId !== this.bufferStageId) {
|
|
98
99
|
this.bufferStageId = p.stageId;
|
|
99
100
|
this.actions = [];
|
|
100
101
|
}
|
|
@@ -129,6 +130,10 @@ export class ActivityFeed {
|
|
|
129
130
|
headerParts.push(elapsed);
|
|
130
131
|
const header = theme.bold(clipRow(headerParts.join(" · "), ROW_MAX));
|
|
131
132
|
const lines = [header];
|
|
133
|
+
// The implement diamond collapses to ONE row plus a compact summary, the same
|
|
134
|
+
// way the review fan-out collapses to one review row: per-child rows are the
|
|
135
|
+
// desktop's business and would break the 10-line cap here.
|
|
136
|
+
const fan = this.run.fanoutRow();
|
|
132
137
|
// Only the lens-less stage rows: the review fan-out's per-lens children are
|
|
133
138
|
// the desktop's business, and surfacing them here would break the 10-line cap.
|
|
134
139
|
for (const s of this.run.stageAgents()) {
|
|
@@ -138,8 +143,17 @@ export class ActivityFeed {
|
|
|
138
143
|
if (s.id === "finish" && s.status === "pending")
|
|
139
144
|
continue;
|
|
140
145
|
const glyph = glyphForStatus(s.status, spinnerFrame);
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
// Padded past the longest stage name ("implement", 9) so every row keeps a
|
|
147
|
+
// separator between the label and its note; at 9 the implement row ran its
|
|
148
|
+
// name straight into its own summary.
|
|
149
|
+
const label = s.stageId.padEnd(10);
|
|
150
|
+
// While the fan is live the implement row's note IS the fan summary; once
|
|
151
|
+
// the stage settles its own narration takes the row back (and the summary
|
|
152
|
+
// stands in when there is no narration to show).
|
|
153
|
+
const fanNote = s.stageId === "implement" && fan?.mode === "fan" && (s.status === "active" || !s.summary)
|
|
154
|
+
? `fanned ${fan.total} ways · ${fan.done}/${fan.total} done`
|
|
155
|
+
: undefined;
|
|
156
|
+
const row = clipRow(`${glyph} ${label}${fanNote ?? s.summary}`.trimEnd(), ROW_MAX);
|
|
143
157
|
lines.push(theme.fg(themeColorForStatus[s.status], row));
|
|
144
158
|
// The ring buffer renders under its OWNING stage (see RunState), so a
|
|
145
159
|
// just-finished stage's resolved actions stay briefly visible beneath its ✔
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE checker-side helpers for the implement diamond (spec decisions 5-7).
|
|
3
|
+
*
|
|
4
|
+
* The checker itself is deterministic and lives in `verify.ts` (the per-workstream
|
|
5
|
+
* scoped typecheck plus the one full `makeRunVerify` on the merged tree). What is
|
|
6
|
+
* left is the reading of its verdict, and that is all pure text math:
|
|
7
|
+
*
|
|
8
|
+
* - {@link checkerFindings} unions the scoped + full findings into one deduped list.
|
|
9
|
+
* - {@link composeSynthesizerInput} turns the fan handoff plus the checker's
|
|
10
|
+
* verdict into the synthesizer's `{previous}`.
|
|
11
|
+
* - {@link attributeFindings} routes each finding to the workstream whose claims
|
|
12
|
+
* cover its file; anything unattributable is the synthesizer's (or, on the
|
|
13
|
+
* single-writer path, the one builder's).
|
|
14
|
+
* - {@link parseOpenFindings} reads the synthesizer's own "## Open findings"
|
|
15
|
+
* section, because decision 7 lets the summary report defects the deterministic
|
|
16
|
+
* checker cannot see.
|
|
17
|
+
* - {@link composeResidue} names what is still open when the fix cap is reached,
|
|
18
|
+
* so `reviewInput` hands the review loop the truth rather than a clean-looking
|
|
19
|
+
* summary.
|
|
20
|
+
*
|
|
21
|
+
* No I/O, no model calls: the orchestrator owns the children, this module owns the
|
|
22
|
+
* reading, exactly the split `findings.ts` and `fanout.ts` already use.
|
|
23
|
+
*/
|
|
24
|
+
import { type PartitionWorkstream } from "./fanout.js";
|
|
25
|
+
import type { Finding } from "./types.js";
|
|
26
|
+
import type { VerifyOutcome, WorkstreamCheckResult } from "./verify.js";
|
|
27
|
+
/** Everything the deterministic checker produced for one pass over the tree. */
|
|
28
|
+
export interface CheckerReport {
|
|
29
|
+
/** Per-workstream scoped typecheck verdicts (empty on the single-writer path). */
|
|
30
|
+
scoped: WorkstreamCheckResult[];
|
|
31
|
+
/** The one full verify on the merged tree; null when it could not be consulted. */
|
|
32
|
+
verify: VerifyOutcome | null;
|
|
33
|
+
}
|
|
34
|
+
/** Findings routed to one workstream's builder for a fix turn. */
|
|
35
|
+
export interface FindingAssignment {
|
|
36
|
+
workstream: PartitionWorkstream;
|
|
37
|
+
findings: Finding[];
|
|
38
|
+
}
|
|
39
|
+
/** How the fix loop splits a checker verdict across the children that can fix it. */
|
|
40
|
+
export interface FindingAttribution {
|
|
41
|
+
assigned: FindingAssignment[];
|
|
42
|
+
/** Findings no workstream's claims cover: the synthesizer's seam work. */
|
|
43
|
+
unassigned: Finding[];
|
|
44
|
+
}
|
|
45
|
+
/** Union findings in first-seen order, deduped by file + line + message. */
|
|
46
|
+
export declare function mergeFindings(...groups: Finding[][]): Finding[];
|
|
47
|
+
/** Every defect this checker pass found: the scoped typechecks plus the full verify. */
|
|
48
|
+
export declare function checkerFindings(report: CheckerReport): Finding[];
|
|
49
|
+
/** One finding as a line a builder can act on (mirrors the review loop's handoff shape). */
|
|
50
|
+
export declare function renderFindings(findings: Finding[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* The synthesizer's `{previous}`: the fan's own handoff (which already names each
|
|
53
|
+
* workstream, its claims, and any out-of-claim edits) plus the checker's verdict,
|
|
54
|
+
* with a broken workstream attributed BY NAME so the reconciler knows where to look.
|
|
55
|
+
*/
|
|
56
|
+
export declare function composeSynthesizerInput(handoff: string, report: CheckerReport): string;
|
|
57
|
+
/**
|
|
58
|
+
* Route findings to the builders that can fix them: a finding whose file sits under
|
|
59
|
+
* a workstream's claims goes to that workstream (at its original tier), and anything
|
|
60
|
+
* else - no file, a file outside every claim, a seam between two workstreams - is
|
|
61
|
+
* unattributable and belongs to the synthesizer. With no workstreams (the
|
|
62
|
+
* single-writer path) everything is unassigned, which is exactly right: there is one
|
|
63
|
+
* builder and it owns the whole tree.
|
|
64
|
+
*/
|
|
65
|
+
export declare function attributeFindings(findings: Finding[], workstreams: PartitionWorkstream[]): FindingAttribution;
|
|
66
|
+
/**
|
|
67
|
+
* The synthesizer's own "## Open findings" section (its persona output contract),
|
|
68
|
+
* as findings the fix loop can act on. "none" is a real answer and returns nothing;
|
|
69
|
+
* an absent section returns nothing too, because a summary that never claimed a
|
|
70
|
+
* defect is not evidence of one. Deliberately forgiving about the prose: what the
|
|
71
|
+
* loop needs is the line and, where the synthesizer named one, the file.
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseOpenFindings(summary: string): Finding[];
|
|
74
|
+
/**
|
|
75
|
+
* The same "## Open findings" section {@link parseOpenFindings} reads, REMOVED
|
|
76
|
+
* from a summary (heading and body, up to the next heading of any depth).
|
|
77
|
+
*
|
|
78
|
+
* A fix turn that only re-engaged builders never re-runs the synthesizer, so its
|
|
79
|
+
* summary stays the handoff verbatim. Once those defects are answered and the
|
|
80
|
+
* re-check is clean, leaving the section in place would hand the review loop a
|
|
81
|
+
* document that names open defects in one paragraph and calls the final check
|
|
82
|
+
* clean in the next. Everything else the synthesizer wrote is untouched: this
|
|
83
|
+
* drops only the section the fix loop has since resolved.
|
|
84
|
+
*/
|
|
85
|
+
export declare function stripOpenFindings(summary: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* What `reviewInput` says when the fix loop ran and then came back clean: the
|
|
88
|
+
* reviewers should know the candidate was repaired inside the implement stage, not
|
|
89
|
+
* that it was right first time. Absent when no fix turn ran, which keeps a
|
|
90
|
+
* clean-on-the-first-check run byte-identical to today's handoff.
|
|
91
|
+
*/
|
|
92
|
+
export declare function composeFixNote(turns: number): string;
|
|
93
|
+
/**
|
|
94
|
+
* What `reviewInput` says when the fix cap is reached with defects still open
|
|
95
|
+
* (spec decision 7): named, not hidden, so the review loop picks them up knowing
|
|
96
|
+
* the implement stage already spent its turns on them.
|
|
97
|
+
*/
|
|
98
|
+
export declare function composeResidue(findings: Finding[], turns: number): string;
|
|
99
|
+
//# sourceMappingURL=checker.d.ts.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE checker-side helpers for the implement diamond (spec decisions 5-7).
|
|
3
|
+
*
|
|
4
|
+
* The checker itself is deterministic and lives in `verify.ts` (the per-workstream
|
|
5
|
+
* scoped typecheck plus the one full `makeRunVerify` on the merged tree). What is
|
|
6
|
+
* left is the reading of its verdict, and that is all pure text math:
|
|
7
|
+
*
|
|
8
|
+
* - {@link checkerFindings} unions the scoped + full findings into one deduped list.
|
|
9
|
+
* - {@link composeSynthesizerInput} turns the fan handoff plus the checker's
|
|
10
|
+
* verdict into the synthesizer's `{previous}`.
|
|
11
|
+
* - {@link attributeFindings} routes each finding to the workstream whose claims
|
|
12
|
+
* cover its file; anything unattributable is the synthesizer's (or, on the
|
|
13
|
+
* single-writer path, the one builder's).
|
|
14
|
+
* - {@link parseOpenFindings} reads the synthesizer's own "## Open findings"
|
|
15
|
+
* section, because decision 7 lets the summary report defects the deterministic
|
|
16
|
+
* checker cannot see.
|
|
17
|
+
* - {@link composeResidue} names what is still open when the fix cap is reached,
|
|
18
|
+
* so `reviewInput` hands the review loop the truth rather than a clean-looking
|
|
19
|
+
* summary.
|
|
20
|
+
*
|
|
21
|
+
* No I/O, no model calls: the orchestrator owns the children, this module owns the
|
|
22
|
+
* reading, exactly the split `findings.ts` and `fanout.ts` already use.
|
|
23
|
+
*/
|
|
24
|
+
import { claimCovers } from "./fanout.js";
|
|
25
|
+
/** Cap on how many open findings are ever carried into a prompt or the residue. */
|
|
26
|
+
const MAX_CARRIED_FINDINGS = 25;
|
|
27
|
+
const key = (f) => `${f.file ?? ""}:${f.line ?? ""}:${f.message}`;
|
|
28
|
+
/** Union findings in first-seen order, deduped by file + line + message. */
|
|
29
|
+
export function mergeFindings(...groups) {
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const group of groups) {
|
|
33
|
+
for (const f of group) {
|
|
34
|
+
const k = key(f);
|
|
35
|
+
if (seen.has(k))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(k);
|
|
38
|
+
out.push(f);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Every defect this checker pass found: the scoped typechecks plus the full verify. */
|
|
44
|
+
export function checkerFindings(report) {
|
|
45
|
+
return mergeFindings(report.scoped.flatMap((s) => s.findings), report.verify?.findings ?? []);
|
|
46
|
+
}
|
|
47
|
+
/** One finding as a line a builder can act on (mirrors the review loop's handoff shape). */
|
|
48
|
+
export function renderFindings(findings) {
|
|
49
|
+
return findings
|
|
50
|
+
.slice(0, MAX_CARRIED_FINDINGS)
|
|
51
|
+
.map((f) => {
|
|
52
|
+
const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "";
|
|
53
|
+
return `${f.severity} | ${loc} | ${f.message}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
/** One workstream's scoped-typecheck line, stating honestly whether it ran. */
|
|
58
|
+
function scopedLine(s) {
|
|
59
|
+
if (!s.ran)
|
|
60
|
+
return `- ${s.name}: not checked (${s.reason ?? "no scoped build check"})`;
|
|
61
|
+
if (s.findings.length === 0)
|
|
62
|
+
return `- ${s.name}: clean (${s.command ?? "check"})`;
|
|
63
|
+
return `- ${s.name}: ${s.findings.length} finding${s.findings.length === 1 ? "" : "s"} (${s.command ?? "check"})`;
|
|
64
|
+
}
|
|
65
|
+
/** The full verify's one-line verdict, including the fail-open case. */
|
|
66
|
+
function verifyLine(verify) {
|
|
67
|
+
if (!verify)
|
|
68
|
+
return "The full verify did not run on this pass.";
|
|
69
|
+
if (!verify.ran)
|
|
70
|
+
return `The full verify did not produce a verdict: ${verify.reason ?? "it could not run"}.`;
|
|
71
|
+
const label = verify.command ? ` (${verify.command})` : "";
|
|
72
|
+
if (verify.ok)
|
|
73
|
+
return `The full verify passed${label}.`;
|
|
74
|
+
return `The full verify failed${label}: ${verify.findings.length} finding${verify.findings.length === 1 ? "" : "s"}.`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The synthesizer's `{previous}`: the fan's own handoff (which already names each
|
|
78
|
+
* workstream, its claims, and any out-of-claim edits) plus the checker's verdict,
|
|
79
|
+
* with a broken workstream attributed BY NAME so the reconciler knows where to look.
|
|
80
|
+
*/
|
|
81
|
+
export function composeSynthesizerInput(handoff, report) {
|
|
82
|
+
const parts = [handoff];
|
|
83
|
+
if (report.scoped.length > 0) {
|
|
84
|
+
parts.push(["## Checker: scoped typecheck per workstream", ...report.scoped.map(scopedLine)].join("\n"));
|
|
85
|
+
}
|
|
86
|
+
parts.push(`## Checker: full verify on the merged tree\n${verifyLine(report.verify)}`);
|
|
87
|
+
const findings = checkerFindings(report);
|
|
88
|
+
if (findings.length > 0) {
|
|
89
|
+
parts.push(`## Checker findings\n${renderFindings(findings)}`);
|
|
90
|
+
}
|
|
91
|
+
return parts.join("\n\n");
|
|
92
|
+
}
|
|
93
|
+
/** The workstream whose claims cover this finding's file, if exactly one does. */
|
|
94
|
+
function ownerOf(finding, workstreams) {
|
|
95
|
+
const file = finding.file?.trim();
|
|
96
|
+
if (!file)
|
|
97
|
+
return undefined;
|
|
98
|
+
return workstreams.find((w) => w.files.some((claim) => claimCovers(claim, file)));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Route findings to the builders that can fix them: a finding whose file sits under
|
|
102
|
+
* a workstream's claims goes to that workstream (at its original tier), and anything
|
|
103
|
+
* else - no file, a file outside every claim, a seam between two workstreams - is
|
|
104
|
+
* unattributable and belongs to the synthesizer. With no workstreams (the
|
|
105
|
+
* single-writer path) everything is unassigned, which is exactly right: there is one
|
|
106
|
+
* builder and it owns the whole tree.
|
|
107
|
+
*/
|
|
108
|
+
export function attributeFindings(findings, workstreams) {
|
|
109
|
+
const assigned = [];
|
|
110
|
+
const unassigned = [];
|
|
111
|
+
for (const finding of findings) {
|
|
112
|
+
const owner = ownerOf(finding, workstreams);
|
|
113
|
+
if (!owner) {
|
|
114
|
+
unassigned.push(finding);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const existing = assigned.find((a) => a.workstream.name === owner.name);
|
|
118
|
+
if (existing)
|
|
119
|
+
existing.findings.push(finding);
|
|
120
|
+
else
|
|
121
|
+
assigned.push({ workstream: owner, findings: [finding] });
|
|
122
|
+
}
|
|
123
|
+
return { assigned, unassigned };
|
|
124
|
+
}
|
|
125
|
+
/** A heading line, at any depth: `## Open findings`. */
|
|
126
|
+
const HEADING = /^#{1,6}\s+(.*)$/;
|
|
127
|
+
/** `path/to/file.ts` or `path/to/file.ts:42` — a path token, not prose. */
|
|
128
|
+
const PATH_SHAPE = /^[\w./@-]+\.[A-Za-z]{1,5}(?::\d+)?$/;
|
|
129
|
+
/**
|
|
130
|
+
* The first path-shaped token in a synthesizer's finding line (backticked first,
|
|
131
|
+
* since that is how the persona writes paths), with any `:line` suffix dropped so
|
|
132
|
+
* it compares against claim prefixes. Undefined when the line names no file, which
|
|
133
|
+
* makes the finding unattributable and therefore the synthesizer's own.
|
|
134
|
+
*/
|
|
135
|
+
function pathIn(line) {
|
|
136
|
+
const quoted = [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim());
|
|
137
|
+
for (const token of [...quoted, ...line.split(/[\s,;()[\]]+/)]) {
|
|
138
|
+
const clean = token.replace(/[.,;:]+$/, "");
|
|
139
|
+
if (clean.includes("/") && PATH_SHAPE.test(clean))
|
|
140
|
+
return clean.replace(/:\d+$/, "");
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The synthesizer's own "## Open findings" section (its persona output contract),
|
|
146
|
+
* as findings the fix loop can act on. "none" is a real answer and returns nothing;
|
|
147
|
+
* an absent section returns nothing too, because a summary that never claimed a
|
|
148
|
+
* defect is not evidence of one. Deliberately forgiving about the prose: what the
|
|
149
|
+
* loop needs is the line and, where the synthesizer named one, the file.
|
|
150
|
+
*/
|
|
151
|
+
export function parseOpenFindings(summary) {
|
|
152
|
+
const lines = summary.split("\n");
|
|
153
|
+
const start = lines.findIndex((l) => {
|
|
154
|
+
const m = l.trim().match(HEADING);
|
|
155
|
+
return m ? /^open findings\b/i.test(m[1].trim()) : false;
|
|
156
|
+
});
|
|
157
|
+
if (start < 0)
|
|
158
|
+
return [];
|
|
159
|
+
const body = [];
|
|
160
|
+
for (const raw of lines.slice(start + 1)) {
|
|
161
|
+
if (HEADING.test(raw.trim()))
|
|
162
|
+
break;
|
|
163
|
+
const line = raw.trim().replace(/^[-*]\s+/, "").trim();
|
|
164
|
+
if (line)
|
|
165
|
+
body.push(line);
|
|
166
|
+
}
|
|
167
|
+
if (body.length === 0)
|
|
168
|
+
return [];
|
|
169
|
+
if (body.length === 1 && /^none\b/i.test(body[0].replace(/[.*_`]/g, "")))
|
|
170
|
+
return [];
|
|
171
|
+
return body.slice(0, MAX_CARRIED_FINDINGS).map((line) => {
|
|
172
|
+
const file = pathIn(line);
|
|
173
|
+
const finding = {
|
|
174
|
+
severity: "critical",
|
|
175
|
+
lens: "does_it_hold",
|
|
176
|
+
message: `synthesizer: ${line}`,
|
|
177
|
+
};
|
|
178
|
+
if (file)
|
|
179
|
+
finding.file = file;
|
|
180
|
+
return finding;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The same "## Open findings" section {@link parseOpenFindings} reads, REMOVED
|
|
185
|
+
* from a summary (heading and body, up to the next heading of any depth).
|
|
186
|
+
*
|
|
187
|
+
* A fix turn that only re-engaged builders never re-runs the synthesizer, so its
|
|
188
|
+
* summary stays the handoff verbatim. Once those defects are answered and the
|
|
189
|
+
* re-check is clean, leaving the section in place would hand the review loop a
|
|
190
|
+
* document that names open defects in one paragraph and calls the final check
|
|
191
|
+
* clean in the next. Everything else the synthesizer wrote is untouched: this
|
|
192
|
+
* drops only the section the fix loop has since resolved.
|
|
193
|
+
*/
|
|
194
|
+
export function stripOpenFindings(summary) {
|
|
195
|
+
const lines = summary.split("\n");
|
|
196
|
+
const start = lines.findIndex((l) => {
|
|
197
|
+
const m = l.trim().match(HEADING);
|
|
198
|
+
return m ? /^open findings\b/i.test(m[1].trim()) : false;
|
|
199
|
+
});
|
|
200
|
+
if (start < 0)
|
|
201
|
+
return summary;
|
|
202
|
+
let end = lines.length;
|
|
203
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
204
|
+
if (HEADING.test(lines[i].trim())) {
|
|
205
|
+
end = i;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* What `reviewInput` says when the fix loop ran and then came back clean: the
|
|
213
|
+
* reviewers should know the candidate was repaired inside the implement stage, not
|
|
214
|
+
* that it was right first time. Absent when no fix turn ran, which keeps a
|
|
215
|
+
* clean-on-the-first-check run byte-identical to today's handoff.
|
|
216
|
+
*/
|
|
217
|
+
export function composeFixNote(turns) {
|
|
218
|
+
return [
|
|
219
|
+
"## Verification",
|
|
220
|
+
`${turns} fix pass${turns === 1 ? "" : "es"} ran inside the implement stage. The final check came back clean.`,
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* What `reviewInput` says when the fix cap is reached with defects still open
|
|
225
|
+
* (spec decision 7): named, not hidden, so the review loop picks them up knowing
|
|
226
|
+
* the implement stage already spent its turns on them.
|
|
227
|
+
*/
|
|
228
|
+
export function composeResidue(findings, turns) {
|
|
229
|
+
const header = turns === 0
|
|
230
|
+
? "## Open findings from verification"
|
|
231
|
+
: `## Open findings after ${turns} fix pass${turns === 1 ? "" : "es"}`;
|
|
232
|
+
return [
|
|
233
|
+
header,
|
|
234
|
+
"Verification still reports these. They were not resolved inside the implement stage:",
|
|
235
|
+
renderFindings(findings),
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=checker.js.map
|