@dzhechkov/harness-core 0.8.11 → 0.8.21
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/.dz-manifest.json +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
package/src/setup.ts
CHANGED
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { execSync } from 'node:child_process';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
22
22
|
|
|
23
23
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
24
|
+
import {
|
|
25
|
+
CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
|
|
26
|
+
CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
|
|
27
|
+
CLAUDE_DESTRUCTIVE_HOOK_RELPATH,
|
|
28
|
+
generateClaudeDestructiveHook,
|
|
29
|
+
isDzManagedHookBody,
|
|
30
|
+
} from './claude-hooks-assets.js';
|
|
24
31
|
import { applyIntegrationFragments, IntegrationApplyError } from './integration-apply.js';
|
|
25
32
|
|
|
26
33
|
/** Memory backend type. */
|
|
@@ -251,6 +258,102 @@ export function commandsOf(entry: unknown): string[] {
|
|
|
251
258
|
return [String(e?.command ?? '')];
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
export interface GuardInstallReceipt {
|
|
262
|
+
/** True ONLY when the installed file was RUN and refused a command it must refuse. */
|
|
263
|
+
readonly ok: boolean;
|
|
264
|
+
/** What was observed — an exit code and the first line it printed, or why it could not run. */
|
|
265
|
+
readonly detail: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Prove the installed hook WORKS, by running it (feature `destructive-command-guard`, review
|
|
270
|
+
* round 4, P2).
|
|
271
|
+
*
|
|
272
|
+
* Presence is not proof and a successful write is not proof either: the body must load its decider
|
|
273
|
+
* (which lives in another package directory), and that resolution is exactly what broke in the
|
|
274
|
+
* global-install layout one round ago. So the receipt is POSITIVE and end-to-end — the file is
|
|
275
|
+
* spawned with a payload it is obliged to refuse, and only `exit 2` carrying our marker counts.
|
|
276
|
+
* Anything else — a crash, a silent pass, a missing file, a spawn that could not happen — is `ok:
|
|
277
|
+
* false` with the observation named, never an assumption about the cause.
|
|
278
|
+
*
|
|
279
|
+
* The registry entry is written only when this returns `ok`. A hook that is registered but cannot
|
|
280
|
+
* run is worse than no hook at all: the breakage lands on EVERY Bash call instead of on none.
|
|
281
|
+
*
|
|
282
|
+
* SAFETY PRECONDITION (round 11): this SPAWNS the file, so the caller must only ever call it on a
|
|
283
|
+
* body dz owns — one carrying the ownership marker, or one dz has just written itself. Calling it
|
|
284
|
+
* on a preserved foreign body turns `dz setup` into a runner for whatever a cloned repository
|
|
285
|
+
* committed at that path.
|
|
286
|
+
*/
|
|
287
|
+
export function probeInstalledGuard(
|
|
288
|
+
hookPath: string,
|
|
289
|
+
opts: { projectRoot?: string } = {},
|
|
290
|
+
): GuardInstallReceipt {
|
|
291
|
+
const payload = JSON.stringify({
|
|
292
|
+
hook_event_name: 'PreToolUse',
|
|
293
|
+
tool_name: 'Bash',
|
|
294
|
+
tool_input: { command: 'rm -rf .agentic-qe' },
|
|
295
|
+
});
|
|
296
|
+
let run;
|
|
297
|
+
try {
|
|
298
|
+
// DZ_GUARD_TRUSTED_ONLY makes the hook resolve its decision module from the INSTALLED
|
|
299
|
+
// harness-core alone. Without it the probe imports the project's own copy first, so a cloned
|
|
300
|
+
// repository that commits `packages/@dzhechkov/harness-core/dist/destructive-guard-hook.js`
|
|
301
|
+
// gets its top-level JavaScript executed by `dz setup` — the round-11 protection ("a foreign
|
|
302
|
+
// BODY is never spawned") one resolution step further in (cross-family review, gpt-5.6-sol,
|
|
303
|
+
// round 13). MEASURED before the fix: the planted module's marker reached stderr and its
|
|
304
|
+
// decider answered `allow`.
|
|
305
|
+
const env: NodeJS.ProcessEnv = { ...process.env, DZ_GUARD_TRUSTED_ONLY: '1' };
|
|
306
|
+
if (opts.projectRoot !== undefined) env.CLAUDE_PROJECT_DIR = opts.projectRoot;
|
|
307
|
+
run = spawnSync(process.execPath, [hookPath], {
|
|
308
|
+
input: payload,
|
|
309
|
+
encoding: 'utf-8',
|
|
310
|
+
timeout: 15_000,
|
|
311
|
+
env,
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return { ok: false, detail: `не удалось запустить хук: ${String((err as Error).message)}` };
|
|
315
|
+
}
|
|
316
|
+
if (run.error !== undefined) return { ok: false, detail: `не удалось запустить хук: ${run.error.message}` };
|
|
317
|
+
const firstLine = String(run.stderr ?? '').split('\n')[0] ?? '';
|
|
318
|
+
if (run.status !== 2 || !firstLine.includes('DZ-DESTRUCTIVE:')) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
detail: `установленный хук НЕ отказал на контрольной команде (код выхода ${String(run.status)}; ${firstLine || 'пустой stderr'})`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, detail: 'живая проба: отказ на контрольной команде, код выхода 2' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The destructive-command guard's registry entry (feature `destructive-command-guard`, task T8).
|
|
329
|
+
*
|
|
330
|
+
* Emitted for EVERY backend, because the guard has nothing to do with where learning memory is
|
|
331
|
+
* stored. MEASURED 2026-09-05, before this existed: `dz setup --target claude-code` into a clean
|
|
332
|
+
* project wrote no `PreToolUse` key at all and created no `.claude/hooks/` — the guard we document
|
|
333
|
+
* protected only our own checkout.
|
|
334
|
+
*/
|
|
335
|
+
const CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS = 5000;
|
|
336
|
+
const LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
|
|
337
|
+
`node "\${CLAUDE_PROJECT_DIR:-.}/${CLAUDE_DESTRUCTIVE_HOOK_RELPATH}"`,
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
/** Attribute only commands dz actually emitted, never arbitrary text that mentions the path. */
|
|
341
|
+
function isManagedClaudeDestructiveHookCommand(command: string): boolean {
|
|
342
|
+
return command === CLAUDE_DESTRUCTIVE_HOOK_COMMAND
|
|
343
|
+
|| LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS.has(command);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function destructiveGuardHookEntry(): { matcher: string; hooks: { type: string; command: string; timeout: number }[] } {
|
|
347
|
+
return {
|
|
348
|
+
matcher: CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
|
|
349
|
+
hooks: [{
|
|
350
|
+
type: 'command',
|
|
351
|
+
command: CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
|
|
352
|
+
timeout: CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS,
|
|
353
|
+
}],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
254
357
|
export function generateHooksConfig(projectRoot: string, backend: MemoryBackend): string {
|
|
255
358
|
const dzDir = join(projectRoot, '.dz');
|
|
256
359
|
|
|
@@ -272,6 +375,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
|
|
|
272
375
|
SessionStart: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} start` }] }],
|
|
273
376
|
SessionEnd: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} end` }] }],
|
|
274
377
|
PreCompact: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} precompact`, runInBackground: true }] }],
|
|
378
|
+
PreToolUse: [destructiveGuardHookEntry()],
|
|
275
379
|
},
|
|
276
380
|
}, null, 2);
|
|
277
381
|
}
|
|
@@ -289,6 +393,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
|
|
|
289
393
|
SessionStart: [{ hooks: [{ type: 'command', command: jsonlCmd('start') }] }],
|
|
290
394
|
SessionEnd: [{ hooks: [{ type: 'command', command: jsonlCmd('end') }] }],
|
|
291
395
|
PreCompact: [{ hooks: [{ type: 'command', command: jsonlCmd('precompact'), runInBackground: true }] }],
|
|
396
|
+
PreToolUse: [destructiveGuardHookEntry()],
|
|
292
397
|
},
|
|
293
398
|
}, null, 2);
|
|
294
399
|
}
|
|
@@ -614,10 +719,125 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
614
719
|
if (!opts.noHooks) {
|
|
615
720
|
const settingsDir = join(opts.projectRoot, '.claude');
|
|
616
721
|
const settingsPath = join(settingsDir, 'settings.json');
|
|
722
|
+
|
|
723
|
+
// The BODY goes in first, and the ENTRY goes in only after a LIVE receipt that the body runs
|
|
724
|
+
// and refuses. Written from the INSTALLED package, never copied out of our repository — a
|
|
725
|
+
// consumer has no `packages/@dzhechkov/...` above their project.
|
|
726
|
+
//
|
|
727
|
+
// Round 4, P2: these two used to be independent. A failed write was recorded as an error and
|
|
728
|
+
// the entry was merged anyway, so a consumer whose install failed got a `PreToolUse` entry
|
|
729
|
+
// pointing at something that is not a runnable hook — and that breaks EVERY Bash call, not one.
|
|
730
|
+
//
|
|
731
|
+
// Round 5, P1: the write was also UNCONDITIONAL. Setup is additive everywhere else — the
|
|
732
|
+
// settings merge keeps the user's own hooks, `.gitignore` is appended to, an existing skill is
|
|
733
|
+
// skipped — and this path overwrote a well-known filename with no ownership check, so a
|
|
734
|
+
// consumer's hand-authored `.claude/hooks/destructive-guard.cjs` was destroyed by a routine
|
|
735
|
+
// run. A body we wrote carries a MARKER; a file without it is the consumer's, and only an
|
|
736
|
+
// explicit `--force` may replace it, after a timestamped backup.
|
|
737
|
+
const hookPath = join(opts.projectRoot, ...CLAUDE_DESTRUCTIVE_HOOK_RELPATH.split('/'));
|
|
738
|
+
let installError: string | null = null;
|
|
739
|
+
let preserved: string | null = null;
|
|
740
|
+
let backupPath: string | null = null;
|
|
741
|
+
|
|
742
|
+
// Unreadable (absent, or something that is not a file at all) is NOT a claim of ownership: it
|
|
743
|
+
// falls through to the write, whose failure the round-4 receipt below already reports.
|
|
744
|
+
let current: string | null = null;
|
|
745
|
+
try {
|
|
746
|
+
current = readFileSync(hookPath, 'utf-8');
|
|
747
|
+
} catch {
|
|
748
|
+
current = null;
|
|
749
|
+
}
|
|
750
|
+
const foreign = current !== null && !isDzManagedHookBody(current);
|
|
751
|
+
|
|
752
|
+
if (foreign && opts.force !== true) {
|
|
753
|
+
preserved =
|
|
754
|
+
'файл на этом пути не наш (нет маркера dz) — ОСТАВЛЕН нетронутым и НЕ ЗАПУСКАЛСЯ; запись в settings.json на этот путь тоже не трогаем (ни своей не добавляем, ни вашу не снимаем); заменить: dz setup --force';
|
|
755
|
+
} else {
|
|
756
|
+
try {
|
|
757
|
+
if (foreign && current !== null) {
|
|
758
|
+
// Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
|
|
759
|
+
// so `--force` is recoverable rather than merely loud.
|
|
760
|
+
backupPath = `${hookPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
761
|
+
writeFileSync(backupPath, current);
|
|
762
|
+
}
|
|
763
|
+
mkdirSync(dirname(hookPath), { recursive: true });
|
|
764
|
+
writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
|
|
765
|
+
} catch (err) {
|
|
766
|
+
installError = String((err as Error).message);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const foreignBodyKept = preserved !== null;
|
|
770
|
+
|
|
771
|
+
// The receipt SPAWNS the file, so it may only ever be taken on a body dz owns.
|
|
772
|
+
//
|
|
773
|
+
// Round 11, P1 SECURITY — correcting my own round-5 sentence, "the receipt is taken from the
|
|
774
|
+
// file that IS there". Combined with round 6, which preserves a body dz does not own, that made
|
|
775
|
+
// `dz setup` EXECUTE whatever a repository had committed at this path: clone a hostile repo,
|
|
776
|
+
// run the documented setup command, and its `.claude/hooks/destructive-guard.cjs` ran — with
|
|
777
|
+
// none of the host's hook-trust prompting in between. MEASURED: a foreign body writing a marker
|
|
778
|
+
// file had written it by the time setup returned.
|
|
779
|
+
//
|
|
780
|
+
// So a preserved foreign body is NOT probed, NOT registered, and NOT run. `--force` is consent
|
|
781
|
+
// to REPLACE it (our body is written above, before this line) — never consent to execute it.
|
|
782
|
+
// The receipt is still taken whether or not the write threw, because a failed write over an
|
|
783
|
+
// OLDER BODY OF OURS leaves something we may legitimately run.
|
|
784
|
+
const receipt: GuardInstallReceipt = foreignBodyKept
|
|
785
|
+
? { ok: false, detail: 'проба не проводилась — запускать чужой файл не наше право' }
|
|
786
|
+
: probeInstalledGuard(hookPath);
|
|
787
|
+
// Round 8, P2: ownership of the ENTRY follows ownership of the BODY, never the filename.
|
|
788
|
+
//
|
|
789
|
+
// Round 6 preserved a consumer's hook file; attribution of its registry entry stayed path-only,
|
|
790
|
+
// so a routine run deleted the registration of the very file it had just decided not to touch —
|
|
791
|
+
// their hook left on disk and switched off (MEASURED: their `PreToolUse` entry came back `[]`).
|
|
792
|
+
// The reverse was just as wrong: a foreign body that happened to refuse made dz ADD an entry
|
|
793
|
+
// for somebody else's file (MEASURED), taking responsibility for code it may neither read as
|
|
794
|
+
// its own nor replace.
|
|
795
|
+
//
|
|
796
|
+
// So when a foreign body is kept, dz stands down from the whole event: it adds nothing, and
|
|
797
|
+
// `isManaged` below stops claiming an entry that points at that path. Whether the foreign hook
|
|
798
|
+
// refuses is not merely the consumer's business — it is a question dz no longer ASKS, because
|
|
799
|
+
// asking meant running their file (round 11). All of it is said in one line rather than left
|
|
800
|
+
// for them to find by diffing settings.json.
|
|
801
|
+
const guardArmed = receipt.ok && !foreignBodyKept;
|
|
802
|
+
// The receipt is taken from the file that IS at the path — ours, or the one we preserved. A
|
|
803
|
+
// foreign hook that demonstrably refuses is registered on its own merits; a foreign hook that
|
|
804
|
+
// does not refuse gets no entry, exactly like a failed install (round 4).
|
|
805
|
+
const notes = [
|
|
806
|
+
preserved === null ? '' : `${preserved}; `,
|
|
807
|
+
backupPath === null ? '' : `прежний файл сохранён: ${basename(backupPath)}; `,
|
|
808
|
+
installError === null ? '' : `${installError}; `,
|
|
809
|
+
].join('');
|
|
810
|
+
steps.push(
|
|
811
|
+
guardArmed
|
|
812
|
+
? { name: 'Install destructive guard', status: 'done', detail: `${notes}${CLAUDE_DESTRUCTIVE_HOOK_RELPATH} — ${receipt.detail}` }
|
|
813
|
+
: {
|
|
814
|
+
name: 'Install destructive guard',
|
|
815
|
+
status: preserved === null ? 'error' : 'skipped',
|
|
816
|
+
detail: `${notes}${receipt.detail} — запись в settings.json НЕ добавлена`,
|
|
817
|
+
},
|
|
818
|
+
);
|
|
617
819
|
const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend)) as {
|
|
618
820
|
hooks: Record<string, { hooks: { type: string; command: string; runInBackground?: boolean }[] }[]>;
|
|
619
821
|
};
|
|
620
822
|
|
|
823
|
+
// No working body ⇒ no entry, and the EVENT KEY STAYS — as an empty managed list when nothing
|
|
824
|
+
// else of ours belongs there.
|
|
825
|
+
//
|
|
826
|
+
// CORRECTION OF RECORD (round 7, P1). The round-5 version DELETED the key and this comment
|
|
827
|
+
// claimed the merge would then also drop a guard entry left by an earlier setup. That was
|
|
828
|
+
// asserted without measuring and it is false: `mergeManagedHookEntries` iterates
|
|
829
|
+
// `Object.keys(managed)`, so an event absent from the managed input is copied through
|
|
830
|
+
// UNTOUCHED — a project whose guard used to be armed kept invoking it on every Bash call while
|
|
831
|
+
// the report said the entry was not added. Handing the event an EMPTY list is what makes the
|
|
832
|
+
// merge EXAMINE it: our entries are dropped by `isManaged`, the user's are preserved in order,
|
|
833
|
+
// and nothing is appended. The round-5 test passed for the wrong reason — its project had no
|
|
834
|
+
// pre-existing settings.json, so there was no stale entry for the claim to be wrong about.
|
|
835
|
+
if (!guardArmed) {
|
|
836
|
+
generated.hooks['PreToolUse'] = (generated.hooks['PreToolUse'] ?? []).filter(
|
|
837
|
+
(entry) => !entry.hooks.some((h) => isManagedClaudeDestructiveHookCommand(h.command)),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
621
841
|
if (!existsSync(settingsPath)) {
|
|
622
842
|
mkdirSync(settingsDir, { recursive: true });
|
|
623
843
|
writeFileSync(settingsPath, JSON.stringify({ hooks: generated.hooks }, null, 2));
|
|
@@ -625,9 +845,18 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
625
845
|
} else {
|
|
626
846
|
try {
|
|
627
847
|
const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
|
628
|
-
// ONE merge implementation, shared with the Codex target (AM-3 / G-E).
|
|
629
|
-
//
|
|
630
|
-
//
|
|
848
|
+
// ONE merge implementation, shared with the Codex target (AM-3 / G-E). Claude's exact
|
|
849
|
+
// command attribution is passed in rather than reimplemented, so emitted bytes, report
|
|
850
|
+
// tail text, and the no-write path stay on the shared merge contract (AM-37).
|
|
851
|
+
const isManagedCommand = (cmd: string): boolean =>
|
|
852
|
+
cmd.includes('agentdb add') ||
|
|
853
|
+
cmd.includes('agentdb-writer.mjs') ||
|
|
854
|
+
cmd.includes('sessions.jsonl') ||
|
|
855
|
+
// Ours ONLY while the body at that path is ours (round 8, P2). Without the
|
|
856
|
+
// path clause a second `dz setup` would append a duplicate guard entry instead of
|
|
857
|
+
// replacing the first; without the ownership clause it would delete the entry a
|
|
858
|
+
// consumer wrote for their own preserved hook.
|
|
859
|
+
(!foreignBodyKept && isManagedClaudeDestructiveHookCommand(cmd));
|
|
631
860
|
const plan = mergeManagedHookEntries(
|
|
632
861
|
(existing['hooks'] ?? {}) as Record<string, unknown[]>,
|
|
633
862
|
generated.hooks as unknown as Record<string, unknown[]>,
|
|
@@ -635,13 +864,21 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
635
864
|
// Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
|
|
636
865
|
// untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
|
|
637
866
|
// matcher-group shape appended below.
|
|
638
|
-
isManaged: (entry) =>
|
|
639
|
-
commandsOf(entry).some(
|
|
640
|
-
(cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'),
|
|
641
|
-
),
|
|
867
|
+
isManaged: (entry) => commandsOf(entry).some(isManagedCommand),
|
|
642
868
|
isLegacy: (entry) =>
|
|
643
869
|
!Array.isArray((entry as { hooks?: unknown[] })?.hooks) ||
|
|
644
870
|
commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
|
|
871
|
+
// Ownership is per HANDLER, not per matcher group. A user's handler may deliberately
|
|
872
|
+
// share the Bash group with dz's guard; replacing ours must retain their handler object
|
|
873
|
+
// and every surrounding group field byte-for-byte through JSON serialization.
|
|
874
|
+
retainForeign: (entry) => {
|
|
875
|
+
const grouped = entry as { hooks?: { command?: unknown }[] };
|
|
876
|
+
if (!Array.isArray(grouped?.hooks)) return null;
|
|
877
|
+
const kept = grouped.hooks.filter((hook) => !isManagedCommand(String(hook?.command ?? '')));
|
|
878
|
+
return kept.length === 0
|
|
879
|
+
? null
|
|
880
|
+
: { ...(entry as Record<string, unknown>), hooks: kept };
|
|
881
|
+
},
|
|
645
882
|
reportLabel: backend,
|
|
646
883
|
},
|
|
647
884
|
);
|
package/src/sign.ts
CHANGED
|
@@ -504,7 +504,24 @@ export function verifyManifest(
|
|
|
504
504
|
continue;
|
|
505
505
|
}
|
|
506
506
|
if (digest !== entry.sha256) {
|
|
507
|
-
|
|
507
|
+
// ПРИЧИНА НАЗЫВАЕТСЯ ТОЧНО, А НЕ ОБОБЩЁННО. Для файла, который переписывает упаковщик,
|
|
508
|
+
// расхождение чаще означает НЕ подделку, а сверку двух РАЗНЫХ объектов: манифест снят с
|
|
509
|
+
// байтов упакованного пакета, а здесь читается исходный каталог. ИЗМЕРЕНО 2026-09-03: так
|
|
510
|
+
// расходятся ВСЕ 56 подписанных пакетов, ни один не проверяется — на свежеподписанном
|
|
511
|
+
// манифест хранит cd7bd97a…, а хэш байтов с диска при любой версии канонизации даёт
|
|
512
|
+
// 40d926ff…, потому что pnpm вырезает из package.json блок scripts.
|
|
513
|
+
//
|
|
514
|
+
// Вердикт НЕ меняется: расхождение остаётся расхождением и выход остаётся ненулевым.
|
|
515
|
+
// Меняется только прочтение — «подделали» против «сверяете не то». Полное лечение (два
|
|
516
|
+
// режима проверки: исходный каталог упаковывать, установленный сверять напрямую) —
|
|
517
|
+
// запись бэклога 58dc4d58, решение владельца получено, работа не сделана.
|
|
518
|
+
const packerRewritten = CANONICALISED_PACK_FILES.has(entry.path);
|
|
519
|
+
failures.push({
|
|
520
|
+
path: entry.path,
|
|
521
|
+
reason: packerRewritten
|
|
522
|
+
? 'content does not match its signed hash — NOTE: this file is rewritten by the packer, so a mismatch here may mean the manifest was built from PACKED bytes while a SOURCE directory is being verified, not tampering (backlog 58dc4d58)'
|
|
523
|
+
: 'content does not match its signed hash',
|
|
524
|
+
});
|
|
508
525
|
}
|
|
509
526
|
}
|
|
510
527
|
|
package/src/skills-verify.ts
CHANGED
|
@@ -23,7 +23,9 @@ import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
|
23
23
|
export type SkillIssueKind =
|
|
24
24
|
| 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
|
|
25
25
|
| 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
|
|
26
|
-
| 'plugin-manifest-trap'
|
|
26
|
+
| 'plugin-manifest-trap' // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
|
|
27
|
+
| 'wildcard-allowed-tools' // `allowed-tools: *` → every tool granted; a FINDING (see below)
|
|
28
|
+
| 'empty-allowed-tools'; // `allowed-tools:` with no value → looks restrictive, restricts nothing
|
|
27
29
|
|
|
28
30
|
export interface SkillLayoutFinding {
|
|
29
31
|
readonly dir: string;
|
|
@@ -170,6 +172,98 @@ export function looksLikeSkillDir(dir: string): boolean {
|
|
|
170
172
|
return walk(dir, 1);
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* What a SKILL.md's front matter says about the tools it may use.
|
|
177
|
+
*
|
|
178
|
+
* Three states, and they are NOT the same thing:
|
|
179
|
+
* `absent` — no `allowed-tools` key. The skill honestly inherits; this is the ordinary case
|
|
180
|
+
* (7 170 SKILL.md files in this tree, MEASURED 2026-09-03).
|
|
181
|
+
* `empty` — the key is present with no value. It LOOKS like a restriction and restricts
|
|
182
|
+
* nothing. Reported, never failed: 70 files are in this state today, and a gate
|
|
183
|
+
* that goes red on the day it is introduced gets switched off — taking the
|
|
184
|
+
* wildcard check down with it.
|
|
185
|
+
* `listed` — actual values, which may include a wildcard.
|
|
186
|
+
*/
|
|
187
|
+
export type AllowedToolsState =
|
|
188
|
+
| { readonly state: 'absent' }
|
|
189
|
+
| { readonly state: 'empty' }
|
|
190
|
+
| { readonly state: 'listed'; readonly values: readonly string[]; readonly wildcard: boolean };
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read `allowed-tools` from the FRONT MATTER only.
|
|
194
|
+
*
|
|
195
|
+
* Scanning the whole file would turn `allowed-tools: *` written inside a skill's own documentation
|
|
196
|
+
* into a violation — and a gate that INVENTS violations is worse than no gate: it teaches people to
|
|
197
|
+
* ignore it. So the parse stops at the closing `---`.
|
|
198
|
+
*
|
|
199
|
+
* Both YAML spellings are accepted, because both appear in this tree: an inline list
|
|
200
|
+
* (`allowed-tools: Read, Write`) and a flow sequence (`allowed-tools: [Read, Write]`). Quotes are
|
|
201
|
+
* stripped before the wildcard test — `allowed-tools: "*"` grants exactly as much as a bare one.
|
|
202
|
+
*/
|
|
203
|
+
export function parseAllowedTools(markdown: string): AllowedToolsState {
|
|
204
|
+
const lines = markdown.split(/\r?\n/);
|
|
205
|
+
if (lines[0]?.trim() !== '---') return { state: 'absent' };
|
|
206
|
+
let raw: string | null = null;
|
|
207
|
+
for (let i = 1; i < lines.length; i++) {
|
|
208
|
+
const line = lines[i] ?? '';
|
|
209
|
+
if (line.trim() === '---') break; // конец заголовка
|
|
210
|
+
const m = /^allowed-tools:(.*)$/.exec(line);
|
|
211
|
+
if (m) { raw = m[1] ?? ''; break; }
|
|
212
|
+
}
|
|
213
|
+
if (raw === null) return { state: 'absent' };
|
|
214
|
+
|
|
215
|
+
// ЯВНО ПУСТОЙ МАССИВ — НЕ ТО ЖЕ, ЧТО ПУСТОЕ ПОЛЕ. `allowed-tools: []` — это утверждение
|
|
216
|
+
// («никаких инструментов»), а `allowed-tools:` — незаполненное поле, которое ВЫГЛЯДИТ
|
|
217
|
+
// ограничением и ничего не ограничивает. Свалить их в одно состояние значило бы сказать про
|
|
218
|
+
// первый случай неправду в тексте предупреждения.
|
|
219
|
+
const trimmed = raw.trim();
|
|
220
|
+
if (trimmed === '') return { state: 'empty' };
|
|
221
|
+
const bracketed = /^\[(.*)\]$/s.exec(trimmed);
|
|
222
|
+
const body = (bracketed ? bracketed[1] ?? '' : trimmed).trim();
|
|
223
|
+
if (body === '') return { state: 'listed', values: [], wildcard: false };
|
|
224
|
+
|
|
225
|
+
const values = body.split(',')
|
|
226
|
+
.map((v) => v.trim().replace(/^["']|["']$/g, '').trim())
|
|
227
|
+
.filter((v) => v !== '');
|
|
228
|
+
return { state: 'listed', values, wildcard: values.includes('*') };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Privilege findings for one skill directory.
|
|
233
|
+
*
|
|
234
|
+
* WHY THIS FAILS RATHER THAN WARNS. The advisory carve-out in this module exists for layout classes
|
|
235
|
+
* whose support this gate CANNOT observe (workspace trust). A wildcard grant is not that: the text
|
|
236
|
+
* is right there in the file, the reading is unambiguous, and nothing about the environment can make
|
|
237
|
+
* it narrow. It is also a pure regression guard — MEASURED 2026-09-03: zero wildcards exist in this
|
|
238
|
+
* tree, so introducing it breaks no one.
|
|
239
|
+
*/
|
|
240
|
+
function scanSkillPrivileges(
|
|
241
|
+
dir: string, label: string, findings: SkillLayoutFinding[], advisories: SkillLayoutFinding[],
|
|
242
|
+
): void {
|
|
243
|
+
let text: string;
|
|
244
|
+
try {
|
|
245
|
+
text = readFileSync(join(dir, 'SKILL.md'), 'utf8');
|
|
246
|
+
} catch {
|
|
247
|
+
return; // нечитаемый файл — забота других проверок этого же слоя, не этой
|
|
248
|
+
}
|
|
249
|
+
const tools = parseAllowedTools(text);
|
|
250
|
+
if (tools.state === 'empty') {
|
|
251
|
+
advisories.push({
|
|
252
|
+
dir: label,
|
|
253
|
+
kind: 'empty-allowed-tools',
|
|
254
|
+
detail: 'allowed-tools is present but empty — it looks like a restriction and grants everything; either list the tools or drop the key',
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (tools.state === 'listed' && tools.wildcard) {
|
|
259
|
+
findings.push({
|
|
260
|
+
dir: label,
|
|
261
|
+
kind: 'wildcard-allowed-tools',
|
|
262
|
+
detail: `allowed-tools grants every tool via "*" (${tools.values.join(', ')}) — name the tools this skill actually needs`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
173
267
|
/**
|
|
174
268
|
* A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
|
|
175
269
|
* DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
|
|
@@ -332,6 +426,10 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
332
426
|
const isPluginContainer = hasPluginManifest(dir);
|
|
333
427
|
const bucket = isPluginContainer ? advisories : findings;
|
|
334
428
|
|
|
429
|
+
// Права проверяются у КАЖДОГО навыка с читаемым SKILL.md, включая одно-навыковый плагин: щедрая
|
|
430
|
+
// выдача не становится безопаснее оттого, что навык лежит в контейнере.
|
|
431
|
+
if (registers) scanSkillPrivileges(dir, name, findings, advisories);
|
|
432
|
+
|
|
335
433
|
if (registers && !isPluginContainer) {
|
|
336
434
|
registrable.push(name);
|
|
337
435
|
} else if (registers && isPluginContainer) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stage-line-before-dispatch — the two-phase stage announcement renderer used by feature-adr
|
|
3
|
+
* dispatch points (historical feature name `stage-line-before-dispatch`, ADR-001). The workflow's
|
|
4
|
+
* sole dispatch seam prints an intent line before the runtime call and a distinct final outcome
|
|
5
|
+
* line after the same attempt settles.
|
|
6
|
+
*
|
|
7
|
+
* WHY this file is standalone and import-free: the workflow script runs in a sandbox with no
|
|
8
|
+
* imports, so this module is projected into `.claude/workflows/feature-adr.js` VERBATIM by
|
|
9
|
+
* `scripts/gen-loop-blobs.mjs` (blob `stage-line`) and byte-checked there by
|
|
10
|
+
* `loop-blobs-regen.test.ts`. The generator FAILS CLOSED on any cross-file import, so the decision
|
|
11
|
+
* shape below is declared STRUCTURALLY here rather than imported from `feature-adr-routing.ts`.
|
|
12
|
+
*
|
|
13
|
+
* The line is PURE TEXT ASSEMBLY: every field comes from the decision the resolver returned or from
|
|
14
|
+
* the constant label table below. No model ever writes a word of it (AC-6), it reads nothing, and it
|
|
15
|
+
* prints nothing — the caller does the printing (AC-3).
|
|
16
|
+
*
|
|
17
|
+
* DESIGN CONSTRAINT (shared with feature-adr-routing.ts): the Workflow parser is stricter than
|
|
18
|
+
* `node --check` — string `+` concatenation only, explicit `if`/`return`, object-literal tables.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Blob version stamp read by scripts/gen-loop-blobs.mjs; bump on any semantic change here. */
|
|
24
|
+
export const STAGE_LINE_BLOB_VERSION = '2.0.0';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The decision shape `renderStageLine` consumes — structurally identical to
|
|
28
|
+
* `StageDecision` from `feature-adr-routing.ts`, declared locally because a cross-file import
|
|
29
|
+
* would make this file unprojectable into the sandboxed workflow (see the file header).
|
|
30
|
+
*/
|
|
31
|
+
export interface StageLineDecision {
|
|
32
|
+
readonly opts: { readonly agentType?: string; readonly model?: string };
|
|
33
|
+
readonly spec: string | null;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StageLineOutcomeState = 'dispatched' | 'probe-failed' | 'refused-before-dispatch';
|
|
38
|
+
|
|
39
|
+
/** Final state of this exact rung. Selection reasons never travel through this shape. */
|
|
40
|
+
export interface StageLineOutcome {
|
|
41
|
+
readonly state: StageLineOutcomeState;
|
|
42
|
+
readonly reason: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Human signature per resolver branch — a CLOSED table over `StageDecisionReason`. A reason with no
|
|
47
|
+
* entry is a hole, and `stage-line.test.ts` proves the table has none: a newcomer reading `· ` and
|
|
48
|
+
* then a raw enum token learns nothing, which is the whole failure this feature exists to prevent.
|
|
49
|
+
*/
|
|
50
|
+
export const STAGE_LINE_REASON_LABELS: Record<string, string> = {
|
|
51
|
+
'usage-override': 'usage override (Claude limit pressure)',
|
|
52
|
+
'explicit-models': 'explicit args.models',
|
|
53
|
+
'routing-not-requested': 'routing not requested',
|
|
54
|
+
'coder-knob-codex': 'coder knob = codex',
|
|
55
|
+
'planner-knob-codex': 'planner knob = codex',
|
|
56
|
+
'qe-cross-family': 'cross-family QE (the coder never self-reviews)',
|
|
57
|
+
'budget-table-cell': 'budget table cell',
|
|
58
|
+
'default-models': 'default models table',
|
|
59
|
+
'codex-id-substituted': 'unknown codex id — substituted',
|
|
60
|
+
'spec-unrecognised': 'unrecognised spec — session-inherited',
|
|
61
|
+
// dispatch-level (11-20): what the runtime or current fallback selection did.
|
|
62
|
+
'coder-fallback': 'coder fallback ladder',
|
|
63
|
+
'codex-unsupported-at-dispatch': 'codex unsupported at this dispatch — Claude',
|
|
64
|
+
'fallback-after-no-deliverable': 'fallback — the previous rung delivered nothing',
|
|
65
|
+
'precision-second-pass': 'independent precision second pass',
|
|
66
|
+
'auto-cost': 'learned-cost routing',
|
|
67
|
+
'qe-same-family-degraded': 'same-family QE — cross-family review NOT obtained',
|
|
68
|
+
'challenge-panel': 'adversarial plan-gate panel',
|
|
69
|
+
'codex-probe-failed': 'codex probe found no usable id — Claude',
|
|
70
|
+
'codex-refused-before-dispatch': 'codex refused before dispatching — nothing ran',
|
|
71
|
+
'fallback-rung': 'fallback rung (prior outcome reported separately)',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Stages that write an artifact FILE out of band when they resolve to Codex, and therefore wait on
|
|
75
|
+
* the Codex-landed barrier before their consumer runs. Naming it in the line tells the reader why
|
|
76
|
+
* the run is about to sit still.
|
|
77
|
+
*
|
|
78
|
+
* The four DESIGN stages belong here too (R4-F5, cross-family review round 3): `designStage` runs an
|
|
79
|
+
* out-of-band artifact probe for any codex-resolved design stage, exactly as code and plan do, so a
|
|
80
|
+
* roster of only {code, plan} left the line silent about a barrier that was actually engaged. QE is
|
|
81
|
+
* deliberately absent — its codex path is a synchronous `codex exec`, not the wrapper — and so is
|
|
82
|
+
* delivery, whose planes never run on codex at all. */
|
|
83
|
+
export const STAGE_LINE_BARRIER_STAGES: Record<string, number> = {
|
|
84
|
+
code: 1,
|
|
85
|
+
plan: 1,
|
|
86
|
+
requirements: 1,
|
|
87
|
+
adr: 1,
|
|
88
|
+
ideation: 1,
|
|
89
|
+
architecture: 1,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One deterministic line: `▸ <stage> · <model or "session"> · <branch signature>`, plus
|
|
94
|
+
* ` · landed barrier` when this stage resolved to Codex AND uses the barrier.
|
|
95
|
+
*
|
|
96
|
+
* PURE: reads nothing, prints nothing, and depends on no state beyond its two arguments (AC-3).
|
|
97
|
+
*/
|
|
98
|
+
function renderStagePrefix(stage: string, decision: StageLineDecision): string {
|
|
99
|
+
let model = 'session';
|
|
100
|
+
if (decision && decision.spec) model = decision.spec;
|
|
101
|
+
return '▸ ' + stage + ' · ' + model;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Timely statement of what the workflow is about to try. It is explicitly not proof of dispatch. */
|
|
105
|
+
export function renderStageIntentLine(stage: string, decision: StageLineDecision): string {
|
|
106
|
+
let signature = '';
|
|
107
|
+
if (decision) {
|
|
108
|
+
const labelled = STAGE_LINE_REASON_LABELS[decision.reason];
|
|
109
|
+
if (labelled) signature = labelled;
|
|
110
|
+
}
|
|
111
|
+
// An unlabelled reason must be VISIBLE, never silently blank: print the raw token so the hole is
|
|
112
|
+
// reportable from a real run instead of reading as "no reason at all".
|
|
113
|
+
if (!signature) {
|
|
114
|
+
signature = 'unlabelled branch';
|
|
115
|
+
if (decision && decision.reason) signature = 'unlabelled branch: ' + decision.reason;
|
|
116
|
+
}
|
|
117
|
+
let line = renderStagePrefix(stage, decision) + ' · ' + signature;
|
|
118
|
+
if (STAGE_LINE_BARRIER_STAGES[stage] && decision && decision.opts && decision.opts.agentType === 'codex:codex-rescue') {
|
|
119
|
+
line = line + ' · landed barrier';
|
|
120
|
+
}
|
|
121
|
+
return line + ' · intent';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Final fact for one rung. It deliberately ignores the selection reason: the outcome owns only
|
|
126
|
+
* this rung's state and refusal detail, so another rung's reason cannot leak into it.
|
|
127
|
+
*/
|
|
128
|
+
export function renderStageOutcomeLine(
|
|
129
|
+
stage: string,
|
|
130
|
+
decision: StageLineDecision,
|
|
131
|
+
outcome: StageLineOutcome,
|
|
132
|
+
): string {
|
|
133
|
+
let model = 'session';
|
|
134
|
+
if (decision && decision.spec) model = decision.spec;
|
|
135
|
+
let label = 'dispatched';
|
|
136
|
+
if (outcome && outcome.state === 'probe-failed') label = 'probe found no usable model';
|
|
137
|
+
else if (outcome && outcome.state === 'refused-before-dispatch') label = 'refused before dispatch';
|
|
138
|
+
let line = '◆ ' + stage + ' · ' + model + ' · outcome: ' + label;
|
|
139
|
+
if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason === 'codex-unsupported-at-dispatch') {
|
|
140
|
+
line = line + ' · codex agent type unsupported';
|
|
141
|
+
} else if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason) {
|
|
142
|
+
line = line + ' · reason: ' + outcome.reason;
|
|
143
|
+
}
|
|
144
|
+
return line;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One-release compatibility alias: the historical renderer is now explicitly the intent half. */
|
|
148
|
+
export function renderStageLine(stage: string, decision: StageLineDecision): string {
|
|
149
|
+
const intent = renderStageIntentLine(stage, decision);
|
|
150
|
+
return intent.slice(0, intent.length - ' · intent'.length);
|
|
151
|
+
}
|