@dzhechkov/harness-core 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. package/src/targets.ts +189 -0
package/src/operations.ts CHANGED
@@ -6,9 +6,27 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
+ import { execFileSync, spawnSync } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
10
12
  import { dirname, join } from 'node:path';
11
13
 
14
+ import {
15
+ buildManagedEntries,
16
+ buildCodexHookManifest,
17
+ codexHooksPaths,
18
+ diffCodexHooks,
19
+ parseCodexHookManifest,
20
+ planCodexHooks,
21
+ removeCodexHooks,
22
+ selectOwnHookMetadata,
23
+ upsertTrustBlock,
24
+ type CodexHooksPaths,
25
+ type HooksListHookMetadata,
26
+ type ManagedEntry,
27
+ } from './codex-hooks.js';
28
+ import { generateCodexHelpers } from './codex-hooks-assets.js';
29
+
12
30
  import { AGENTS_MD_PATH } from '@dzhechkov/adapter-agents-md';
13
31
  import { claudeAdapter } from '@dzhechkov/adapter-claude';
14
32
  import { CODEX_SKILLS_ROOT } from '@dzhechkov/adapter-codex';
@@ -19,14 +37,27 @@ import { OPENCLAUDE_SKILLS_ROOT } from '@dzhechkov/adapter-openclaude';
19
37
  import { COPILOT_INSTRUCTIONS_ROOT } from '@dzhechkov/adapter-copilot';
20
38
  import { CURSOR_RULES_ROOT } from '@dzhechkov/adapter-cursor';
21
39
  import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
22
- import { mergeAgentsMd, mergeGeminiMd, renderAgentsMdSection } from '@dzhechkov/core';
40
+ import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
23
41
  import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
24
42
  import { computeRiskScore } from './risk-scoring.js';
25
43
 
26
44
  import { applyEmitResult } from './apply.js';
27
- import { discoverSkillIds, loadSkillFromDir } from './skills.js';
45
+ import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
46
+ import type { SkillApplyFailure, SkillLoadFailure } from './skills.js';
28
47
  import { TARGETS } from './targets.js';
29
48
  import type { TargetName } from './targets.js';
49
+ import {
50
+ AGENTS_MD_BUDGET_WARN_FRACTION,
51
+ CODEX_PROJECT_DOC_MAX_BYTES,
52
+ POLICY_SOURCES,
53
+ detectPolicyDrift,
54
+ extractPolicyBlocks,
55
+ measureAgentsMdBudget,
56
+ renderPolicySections,
57
+ type AgentsMdBudget,
58
+ type PolicyDriftFinding,
59
+ type PolicySource,
60
+ } from './agents-policy.js';
30
61
 
31
62
  // ---------------------------------------------------------------------------
32
63
  // init — compile a skills directory for a target and write it (additively)
@@ -67,6 +98,27 @@ export interface InitReport {
67
98
  * installing fewer skills than the selection asked for.
68
99
  */
69
100
  readonly missing: string[];
101
+ /**
102
+ * Skill directories that could not be LOADED (unparseable `SKILL.md`, unreadable
103
+ * file, schema rejection). Additive and always present — empty when nothing failed.
104
+ *
105
+ * Before feature dz-cli-defects a single unloadable skill threw out of the whole
106
+ * loop, so `dz init`/`dz install` reported nothing at all. Skipping without a record
107
+ * would only trade a loud failure for a silent one; this field is the record.
108
+ */
109
+ readonly failures: readonly SkillLoadFailure[];
110
+ /**
111
+ * Skills that LOADED cleanly but could not be compiled or written (fix round 1,
112
+ * QE F4). Kept separate from {@link InitReport.failures} because the two name
113
+ * different subjects: a load failure accuses the source `SKILL.md`, an apply
114
+ * failure accuses the target tree. Merging them made `EEXIST: mkdir …` print under
115
+ * the header "unparseable SKILL.md" against a perfectly valid file.
116
+ *
117
+ * Additive and always present — empty when nothing failed. Only `runInit` can
118
+ * populate it; the single-file targets render in memory and write once, outside
119
+ * any per-skill loop.
120
+ */
121
+ readonly applyFailures: readonly SkillApplyFailure[];
70
122
  }
71
123
 
72
124
  // ---------------------------------------------------------------------------
@@ -212,6 +264,40 @@ interface SingleFileMdConfig {
212
264
  readonly lossyWarning: string;
213
265
  }
214
266
 
267
+ export interface ManagedMarkdownWriteReport {
268
+ readonly filePath: string;
269
+ readonly changed: boolean;
270
+ readonly bytes: number;
271
+ /** The complete candidate bytes, returned so callers can run pure post-merge checks. */
272
+ readonly content: string;
273
+ }
274
+
275
+ /**
276
+ * The one read/merge/write path for every root managed-Markdown projection.
277
+ * `write:false` is check-only and never creates a directory or target file.
278
+ * A max-byte refusal happens before mkdir/write, leaving the target byte-identical.
279
+ */
280
+ export function writeManagedMarkdown(
281
+ projectRoot: string,
282
+ sections: readonly string[],
283
+ config: Pick<SingleFileMdConfig, 'filePath' | 'merge'>,
284
+ options: { readonly write?: boolean; readonly maxBytes?: number } = {},
285
+ ): ManagedMarkdownWriteReport {
286
+ const fileAbs = join(projectRoot, config.filePath);
287
+ const existing = existsSync(fileAbs) ? readFileSync(fileAbs, 'utf-8') : null;
288
+ const merged = config.merge(existing, sections);
289
+ const bytes = Buffer.byteLength(merged, 'utf8');
290
+ if (options.maxBytes !== undefined && bytes > options.maxBytes) {
291
+ throw new Error(`${config.filePath} policy emit exceeds the ${options.maxBytes.toLocaleString('en-US')} byte Codex project-document budget (${bytes.toLocaleString('en-US')} bytes); target left unchanged`);
292
+ }
293
+ const changed = merged !== existing;
294
+ if (options.write !== false && changed) {
295
+ mkdirSync(dirname(fileAbs), { recursive: true });
296
+ writeFileSync(fileAbs, merged, 'utf-8');
297
+ }
298
+ return { filePath: config.filePath, changed, bytes, content: merged };
299
+ }
300
+
215
301
  /**
216
302
  * The lossy warning surfaced ONCE per agents-md install (not once per skill):
217
303
  * agents-md flattens every selected skill into plain-Markdown sections of a
@@ -265,24 +351,25 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
265
351
  }
266
352
  const missing = selection === undefined ? [] : selection.filter((id) => !discovered.has(id));
267
353
 
268
- const sections = picked.map(({ id, skillsDir }) =>
269
- renderAgentsMdSection(loadSkillFromDir(skillsDir, id)),
270
- );
271
-
272
- const fileAbs = join(options.projectRoot, config.filePath);
273
- const existing = existsSync(fileAbs) ? readFileSync(fileAbs, 'utf-8') : null;
274
- const merged = config.merge(existing, sections);
275
- const changed = merged !== existing;
276
-
277
- if (picked.length > 0 && changed) {
278
- mkdirSync(dirname(fileAbs), { recursive: true });
279
- writeFileSync(fileAbs, merged, 'utf-8');
354
+ // Skip-and-collect: one unloadable skill must not discard the whole aggregation.
355
+ const failures: SkillLoadFailure[] = [];
356
+ const loaded: { id: string; section: string }[] = [];
357
+ for (const { id, skillsDir } of picked) {
358
+ try {
359
+ loaded.push({ id, section: renderAgentsMdSection(loadSkillFromDir(skillsDir, id)) });
360
+ } catch (error) {
361
+ failures.push(describeSkillLoadFailure(skillsDir, id, error));
362
+ }
280
363
  }
364
+ const sections = loaded.map((entry) => entry.section);
365
+
366
+ const managed = writeManagedMarkdown(options.projectRoot, sections, config, { write: loaded.length > 0 });
367
+ const changed = managed.changed;
281
368
 
282
369
  // Report the shared root file as written/skipped on the FIRST contributing
283
370
  // skill only, so callers that SUM per-skill file counts don't count the one
284
371
  // shared file N times. The lossy warning is surfaced ONCE, on that same skill.
285
- const skills: InitSkillResult[] = picked.map(({ id }, index) => {
372
+ const skills: InitSkillResult[] = loaded.map(({ id }, index) => {
286
373
  const owns = index === 0;
287
374
  return {
288
375
  id,
@@ -292,7 +379,9 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
292
379
  };
293
380
  });
294
381
 
295
- return { target: config.target, skillsDir: joinedDir, projectRoot: options.projectRoot, skills, missing };
382
+ // No apply failures are possible here: the single write is outside every per-skill
383
+ // loop, so a write error propagates as itself rather than being attributed to a skill.
384
+ return { target: config.target, skillsDir: joinedDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures: [] };
296
385
  }
297
386
 
298
387
  /**
@@ -324,6 +413,114 @@ export function runInitGeminiMd(options: AgentsMdInitOptions): InitReport {
324
413
  });
325
414
  }
326
415
 
416
+ export interface AgentsPolicySyncOptions {
417
+ readonly projectRoot: string;
418
+ /** Verify only. No directory or file is created, even when AGENTS.md is absent. */
419
+ readonly check?: boolean;
420
+ /** Test/extension seam; production uses the fixed ordered registry. */
421
+ readonly sources?: readonly PolicySource[];
422
+ }
423
+
424
+ export interface AgentsPolicySyncReport {
425
+ readonly filePath: 'AGENTS.md';
426
+ /** In check mode: the target would change. In write mode: the target did change. */
427
+ readonly changed: boolean;
428
+ readonly written: boolean;
429
+ readonly inSync: boolean;
430
+ readonly blocks: readonly { readonly id: string; readonly sha: string }[];
431
+ readonly missing: readonly string[];
432
+ readonly drift: readonly PolicyDriftFinding[];
433
+ readonly budget: AgentsMdBudget;
434
+ readonly warnings: readonly string[];
435
+ }
436
+
437
+ /**
438
+ * Refresh or verify the policy fence in root AGENTS.md. Source reads are an
439
+ * explicit I/O shell around the pure agents-policy module; missing/unreadable
440
+ * input returns inconclusive evidence and never writes a partial projection.
441
+ */
442
+ export function runSyncAgentsPolicy(options: AgentsPolicySyncOptions): AgentsPolicySyncReport {
443
+ const sources = options.sources ?? POLICY_SOURCES;
444
+ const sourceFiles = new Map<string, string | null>();
445
+ for (const file of new Set(sources.map((source) => source.file))) {
446
+ try {
447
+ sourceFiles.set(file, readFileSync(join(options.projectRoot, file), 'utf8'));
448
+ } catch {
449
+ sourceFiles.set(file, null);
450
+ }
451
+ }
452
+ const agentsPath = join(options.projectRoot, AGENTS_MD_PATH);
453
+ let currentAgents: string | null = null;
454
+ try { currentAgents = readFileSync(agentsPath, 'utf8'); } catch { /* absent target is a normal first sync */ }
455
+
456
+ const extracted = extractPolicyBlocks(sourceFiles, sources);
457
+ if (extracted.missing.length > 0) {
458
+ const drift = detectPolicyDrift(sourceFiles, currentAgents, sources);
459
+ return {
460
+ filePath: AGENTS_MD_PATH,
461
+ changed: true,
462
+ written: false,
463
+ inSync: false,
464
+ blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
465
+ missing: extracted.missing,
466
+ drift: drift.findings,
467
+ budget: measureAgentsMdBudget(currentAgents ?? ''),
468
+ warnings: [`policy source evidence is incomplete: ${extracted.missing.join(', ')}`],
469
+ };
470
+ }
471
+
472
+ const currentDrift = detectPolicyDrift(sourceFiles, currentAgents, sources);
473
+ const malformedOuterFence = currentDrift.findings.some((finding) => finding.id === 'dz:policies');
474
+ if (options.check !== true && malformedOuterFence) {
475
+ return {
476
+ filePath: AGENTS_MD_PATH,
477
+ changed: true,
478
+ written: false,
479
+ inSync: false,
480
+ blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
481
+ missing: [],
482
+ drift: currentDrift.findings,
483
+ budget: measureAgentsMdBudget(currentAgents ?? ''),
484
+ warnings: ['refusing to rewrite malformed or duplicate dz:policies fence markers; repair their cardinality first'],
485
+ };
486
+ }
487
+
488
+ const sections = renderPolicySections(extracted.blocks, sources);
489
+ const rendered = sections.join('\n\n');
490
+ const claudeOnlyTokens = ['Workflow({', 'subagent_type', 'mcp__', '.claude/agents/v3/'];
491
+ const leaked = claudeOnlyTokens.find((token) => rendered.includes(token));
492
+ if (leaked !== undefined) throw new Error(`AGENTS.md policy block contains Claude-only token ${JSON.stringify(leaked)}; target left unchanged`);
493
+
494
+ const managed = writeManagedMarkdown(
495
+ options.projectRoot,
496
+ sections,
497
+ { filePath: AGENTS_MD_PATH, merge: mergePolicyBlock },
498
+ { write: options.check !== true, maxBytes: CODEX_PROJECT_DOC_MAX_BYTES },
499
+ );
500
+ const budget = measureAgentsMdBudget(managed.content);
501
+ const drift = detectPolicyDrift(sourceFiles, options.check === true ? currentAgents : managed.content, sources);
502
+ const warnings: string[] = [];
503
+ if (budget.bytes >= budget.cap * AGENTS_MD_BUDGET_WARN_FRACTION) {
504
+ warnings.push(`AGENTS.md uses ${budget.pct}% of the measured ${budget.cap.toLocaleString('en-US')} byte Codex project-document budget`);
505
+ }
506
+ const skillsBegin = managed.content.indexOf(AGENTS_MD_BLOCK_BEGIN);
507
+ if (skillsBegin !== -1 && budget.policyBlockEndsAtByte > Buffer.byteLength(managed.content.slice(0, skillsBegin), 'utf8')) {
508
+ warnings.push('policy fence must end before the skills fence because Codex truncates the tail');
509
+ }
510
+ const nonOk = drift.findings.filter((finding) => finding.status !== 'ok');
511
+ return {
512
+ filePath: AGENTS_MD_PATH,
513
+ changed: managed.changed,
514
+ written: options.check !== true && managed.changed,
515
+ inSync: options.check === true ? !managed.changed && nonOk.length === 0 : nonOk.length === 0,
516
+ blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
517
+ missing: [],
518
+ drift: drift.findings,
519
+ budget,
520
+ warnings,
521
+ };
522
+ }
523
+
327
524
  /** Compile every skill in `skillsDir` for `target` and apply it under `projectRoot`. */
328
525
  export async function runInit(options: InitOptions): Promise<InitReport> {
329
526
  // agents-md is a flattening single-file target — aggregate all selected skills
@@ -355,19 +552,41 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
355
552
  const missing = selection === undefined
356
553
  ? []
357
554
  : selection.filter((id) => !discovered.includes(id));
555
+ // Skip-and-collect (D1): one unparseable SKILL.md must not discard the whole install.
556
+ // Same shape as `runVerify`'s long-standing per-id try/catch below.
557
+ //
558
+ // The `try` is scoped to `loadSkillFromDir` ALONE (ADR-001 §Decision part 2, restored
559
+ // in fix round 1 / QE F4). It used to wrap compile + apply too, so an EEXIST from
560
+ // `mkdir` was described by `describeSkillLoadFailure` and printed under the
561
+ // "unparseable SKILL.md" header, naming the source file — which was valid. Compile
562
+ // and write failures are a SECOND kind with their own subject and their own header;
563
+ // they are collected, not thrown, so one unwritable target directory still cannot
564
+ // discard the rest of the install.
565
+ const failures: SkillLoadFailure[] = [];
566
+ const applyFailures: SkillApplyFailure[] = [];
358
567
  for (const id of ids) {
359
- const skill = loadSkillFromDir(options.skillsDir, id);
360
- let emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
361
- if (options.enrich === true) {
362
- emit = enrichEmitForTarget(emit, options.target, id, skill);
568
+ let skill;
569
+ try {
570
+ skill = loadSkillFromDir(options.skillsDir, id);
571
+ } catch (error) {
572
+ failures.push(describeSkillLoadFailure(options.skillsDir, id, error));
573
+ continue;
574
+ }
575
+ try {
576
+ let emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
577
+ if (options.enrich === true) {
578
+ emit = enrichEmitForTarget(emit, options.target, id, skill);
579
+ }
580
+ const applied = applyEmitResult(emit, {
581
+ targetRoot: options.projectRoot,
582
+ force: options.force === true,
583
+ });
584
+ skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
585
+ } catch (error) {
586
+ applyFailures.push({ id, reason: error instanceof Error ? error.message : String(error) });
363
587
  }
364
- const applied = applyEmitResult(emit, {
365
- targetRoot: options.projectRoot,
366
- force: options.force === true,
367
- });
368
- skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
369
588
  }
370
- return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing };
589
+ return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures };
371
590
  }
372
591
 
373
592
  // ---------------------------------------------------------------------------
@@ -457,15 +676,28 @@ export interface SyncReport {
457
676
  readonly dryRun: boolean;
458
677
  readonly skills: SyncSkillResult[];
459
678
  readonly summary: { total: number; inSync: number; missing: number; drift: number };
679
+ /**
680
+ * Canonical skill directories that could not be LOADED. Additive, always present.
681
+ * See {@link InitReport.failures} — same contract, same reason.
682
+ */
683
+ readonly failures: readonly SkillLoadFailure[];
460
684
  }
461
685
 
462
686
  /** Compare each canonical skill (compiled for Claude Code) to the legacy tree. */
463
687
  export async function runSync(options: SyncOptions): Promise<SyncReport> {
464
688
  const dirs = options.canonicalDirs ?? (options.canonicalDir ? [options.canonicalDir] : []);
465
689
  const skills: SyncSkillResult[] = [];
690
+ // Skip-and-collect (D1): a broken canonical skill must not hide every other pack.
691
+ const failures: SkillLoadFailure[] = [];
466
692
  for (const canonicalDir of dirs) {
467
693
  for (const id of discoverSkillIds(canonicalDir)) {
468
- const skill = loadSkillFromDir(canonicalDir, id);
694
+ let skill;
695
+ try {
696
+ skill = loadSkillFromDir(canonicalDir, id);
697
+ } catch (error) {
698
+ failures.push(describeSkillLoadFailure(canonicalDir, id, error));
699
+ continue;
700
+ }
469
701
  const emit = await claudeAdapter.compile(skill, { targetRoot: options.projectRoot });
470
702
 
471
703
  let differ = false;
@@ -490,6 +722,7 @@ export async function runSync(options: SyncOptions): Promise<SyncReport> {
490
722
  return {
491
723
  dryRun: options.dryRun === true,
492
724
  skills,
725
+ failures,
493
726
  summary: {
494
727
  total: skills.length,
495
728
  inSync: skills.filter((skill) => skill.status === 'in-sync').length,
@@ -738,6 +971,38 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
738
971
  });
739
972
  }
740
973
  } catch { /* settings absent/unreadable — covered by other checks */ }
974
+ // CODEX APPLY-LEG (AM-19, fact CORRECTED by AM-30). This block is deliberately OUTSIDE the
975
+ // `existsSync(writerPath)` guard below. MEASURED: the Claude apply-leg check has THREE
976
+ // preconditions, not two — the `.claude/settings.json` text check is NESTED inside
977
+ // `if (existsSync(join(root,'.dz','agentdb-writer.mjs')))`. A Codex-only machine has no writer
978
+ // file and no `.claude/settings.json`, so a Codex branch added *in place* would report nothing at
979
+ // all — reproducing the exact 19-day dark-leg shape this check exists to kill.
980
+ //
981
+ // A WIRED-BUT-SILENT leg is an explicit non-OK row, never an absent one.
982
+ try {
983
+ const { codexHooksPaths: codexPaths, parseCodexHookManifest: parseCodexManifest } = await import('./codex-hooks.js');
984
+ const codexHome = process.env['CODEX_HOME'] ?? join(homedir(), '.codex');
985
+ const manifestPath = codexPaths(codexHome).manifest;
986
+ if (existsSync(manifestPath)) {
987
+ const manifest = parseCodexManifest(readFileSync(manifestPath, 'utf-8'));
988
+ const claimsRecall = manifest?.entries.some((e) => e.id === 'codex-recall') === true;
989
+ if (claimsRecall) {
990
+ const usageLog = join(root, '.dz', 'recall-usage.jsonl');
991
+ const { newest, hasCodexRow } = newestRecallUsageRuntime(usageLog);
992
+ const fresh = newest !== undefined && Date.now() - Date.parse(newest) < 14 * 24 * 60 * 60 * 1000;
993
+ checks.push({
994
+ name: 'codex apply-leg (recall hook)',
995
+ ok: hasCodexRow && fresh,
996
+ detail: hasCodexRow
997
+ ? fresh
998
+ ? `codex recall rows present, newest ${newest}`
999
+ : `codex recall hook is WIRED but SILENT: newest recall-usage row is ${String(newest)} — the entry may have lost hook trust (re-run dz hooks-sync --target codex --verify)`
1000
+ : 'codex recall hook is WIRED but has NEVER written a row — a dead leg looks exactly like a correctly-silent one, so this is reported non-OK until one lands',
1001
+ });
1002
+ }
1003
+ }
1004
+ } catch { /* doctor never throws on a diagnostic */ }
1005
+
741
1006
  const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
742
1007
  if (existsSync(writerPath)) {
743
1008
  const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
@@ -832,3 +1097,429 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
832
1097
 
833
1098
  return { node: process.version, checks, ok: checks.every((check) => check.ok) };
834
1099
  }
1100
+
1101
+ /* ========================================================================== */
1102
+ /* Codex hook install (`crossrt-2-codex-hooks`, M7 · ADR-001/ADR-002) */
1103
+ /* ========================================================================== */
1104
+
1105
+ export interface CodexHooksSyncOptions {
1106
+ /** Defaults to `$CODEX_HOME`, then `~/.codex`. Every automated probe passes a temp dir (AM-11). */
1107
+ readonly codexHome?: string;
1108
+ /** Verify only. NOTHING is created — not even the helper directory. */
1109
+ readonly check?: boolean;
1110
+ /** Remove manifest-attributed entries and stop. */
1111
+ readonly remove?: boolean;
1112
+ /** Absolute interpreter for the emitted command. Defaults to `process.execPath` (AM-32). */
1113
+ readonly nodePath?: string;
1114
+ /**
1115
+ * Test seam: `false` skips EVERY live runtime call — the `$SHELL -lc` liveness self-probe AND the
1116
+ * `hooks/list` trust query. Both spawn processes (the RPC drives a real `codex app-server`), which
1117
+ * is seconds per install; a test that installs six times to check backup pruning is measuring
1118
+ * pruning, not the runtime. With it off, `trust` is reported `'unknown'` rather than assumed.
1119
+ */
1120
+ readonly liveness?: boolean;
1121
+ /** Test seam. Production probes the real binary. */
1122
+ readonly codexVersion?: string | null;
1123
+ readonly now?: string;
1124
+ }
1125
+
1126
+ export interface CodexHooksSyncReport {
1127
+ readonly codexHome: string;
1128
+ readonly registryPath: string;
1129
+ /** Both managed entries are present in the file (recomputed from the FILE, not the manifest). */
1130
+ readonly installed: boolean;
1131
+ /** The emitted command actually RUNS through `$SHELL -lc`. A hook that cannot execute is not armed. */
1132
+ readonly executable: boolean;
1133
+ readonly written: boolean;
1134
+ readonly removed: number;
1135
+ readonly foreignPreserved: number;
1136
+ readonly unattributable: number;
1137
+ readonly drift: readonly string[];
1138
+ readonly trust: 'trusted' | 'trust-pending' | 'unknown';
1139
+ readonly codexVersion: string | null;
1140
+ /** 0 = armed+trusted · 1 = not armed / drift / refusal · 3 = inconclusive. */
1141
+ readonly exitCode: 0 | 1 | 3;
1142
+ readonly warnings: readonly string[];
1143
+ readonly errors: readonly string[];
1144
+ /** Written paths, for the declared-write-set test. */
1145
+ readonly writes: readonly string[];
1146
+ }
1147
+
1148
+ function resolveCodexHome(explicit: string | undefined): string {
1149
+ if (typeof explicit === 'string' && explicit !== '') return explicit;
1150
+ const env = process.env['CODEX_HOME'];
1151
+ if (typeof env === 'string' && env !== '') return env;
1152
+ return join(homedir(), '.codex');
1153
+ }
1154
+
1155
+ /** `codex --version` → `codex-cli 0.147.0`. `null` when the binary is absent or silent. */
1156
+ function probeCodexVersion(): string | null {
1157
+ try {
1158
+ const out = execFileSync('codex', ['--version'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'] });
1159
+ const trimmed = out.trim();
1160
+ return trimmed === '' ? null : trimmed;
1161
+ } catch {
1162
+ return null;
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Run the emitted command THE WAY THE RUNTIME WILL — through `$SHELL -lc` (AM-32 / G-L).
1168
+ *
1169
+ * MEASURED: the codex hook runner spawns via `$SHELL -lc`, and under nvm/asdf/volta a
1170
+ * non-interactive login shell frequently lacks `node`. The helper then exits **127**, which the
1171
+ * runtime reads as **ALLOW** — a blocking guard that is silently dead in the fail-open direction,
1172
+ * with the helper's own self-failure note unable to fire because the process never started.
1173
+ * Grading on file presence would call that "installed".
1174
+ */
1175
+ export function probeHookLiveness(command: string, payload: string): { readonly status: number | null; readonly stderr: string } {
1176
+ const shell = process.env['SHELL'] ?? '/bin/sh';
1177
+ try {
1178
+ const res = spawnSync(shell, ['-lc', command], {
1179
+ input: payload,
1180
+ encoding: 'utf8',
1181
+ timeout: 20_000,
1182
+ env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
1183
+ });
1184
+ return { status: res.status, stderr: res.stderr ?? '' };
1185
+ } catch (err) {
1186
+ return { status: null, stderr: String((err as Error)?.message ?? err) };
1187
+ }
1188
+ }
1189
+
1190
+ /** Keep the newest N backups; older ones are litter in the user's home (AM-35b). */
1191
+ function pruneBackups(dir: string, prefix: string, keep: number): void {
1192
+ try {
1193
+ const found = readdirSync(dir).filter((f) => f.startsWith(prefix)).sort();
1194
+ for (const stale of found.slice(0, Math.max(0, found.length - keep))) {
1195
+ rmSync(join(dir, stale), { force: true });
1196
+ }
1197
+ } catch { /* pruning is hygiene, never a failure */ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Install, verify or remove the dz Codex hooks.
1202
+ *
1203
+ * Order is load-bearing: every REFUSAL happens before any `mkdir` or write, so a machine without
1204
+ * codex, or with an unparseable registry, is left byte-untouched (AM-35a).
1205
+ */
1206
+ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHooksSyncReport {
1207
+ const codexHome = resolveCodexHome(options.codexHome);
1208
+ const paths = codexHooksPaths(codexHome);
1209
+ const warnings: string[] = [];
1210
+ const errors: string[] = [];
1211
+ const writes: string[] = [];
1212
+ const now = options.now ?? new Date().toISOString();
1213
+ const nodePath = options.nodePath ?? process.execPath;
1214
+
1215
+ const base = {
1216
+ codexHome,
1217
+ registryPath: paths.registry,
1218
+ installed: false,
1219
+ executable: false,
1220
+ written: false,
1221
+ removed: 0,
1222
+ foreignPreserved: 0,
1223
+ unattributable: 0,
1224
+ drift: [] as string[],
1225
+ trust: 'unknown' as const,
1226
+ codexVersion: null as string | null,
1227
+ writes: [] as string[],
1228
+ };
1229
+
1230
+ // (1) REFUSE when codex is not installed — dz does not create user-global config for a runtime
1231
+ // that is not there (AM-35a). `--check` is allowed to answer, but it writes nothing anyway.
1232
+ const codexVersion = options.codexVersion === undefined ? probeCodexVersion() : options.codexVersion;
1233
+ if (codexVersion === null) {
1234
+ return { ...base, exitCode: 3, warnings, errors: ['no `codex` binary on PATH — nothing was written'] };
1235
+ }
1236
+
1237
+ // (2) REFUSE an unquotable CODEX_HOME rather than emit a broken `$SHELL -lc` string (AM-35d).
1238
+ let entries;
1239
+ try {
1240
+ entries = buildManagedEntries({ nodePath, paths });
1241
+ } catch (err) {
1242
+ return { ...base, codexVersion, exitCode: 1, warnings, errors: [String((err as Error).message)] };
1243
+ }
1244
+
1245
+ const currentText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
1246
+ const manifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
1247
+
1248
+ // (3) --remove: delete only what the manifest proves is ours.
1249
+ if (options.remove === true) {
1250
+ const removal = removeCodexHooks(currentText, manifest);
1251
+ if (!removal.ok) return { ...base, codexVersion, exitCode: 1, warnings, errors: [removal.error] };
1252
+ if (removal.result.changed) {
1253
+ backupRegistry(paths, currentText, now, writes);
1254
+ atomicWrite(paths.registry, removal.result.text);
1255
+ writes.push(paths.registry);
1256
+ }
1257
+ for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest]) rmSync(p, { force: true });
1258
+ return {
1259
+ ...base,
1260
+ codexVersion,
1261
+ written: removal.result.changed,
1262
+ removed: removal.result.removed,
1263
+ unattributable: removal.result.unattributable,
1264
+ exitCode: 0,
1265
+ warnings:
1266
+ removal.result.unattributable > 0
1267
+ ? [`${removal.result.unattributable} entr(ies) resemble dz hooks but are not manifest-attributed — KEPT, remove them by hand if you want them gone`]
1268
+ : warnings,
1269
+ errors,
1270
+ writes,
1271
+ };
1272
+ }
1273
+
1274
+ // (4) --check: recompute from the FILE and FIRE the helper. Writes nothing, and stays SILENT in
1275
+ // a home that never opted in (the leg-1 F12 lesson).
1276
+ if (options.check === true) {
1277
+ const drift = diffCodexHooks(currentText, entries, manifest);
1278
+ const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
1279
+ const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
1280
+ // `--check` must report the TRUST axis too. Without it the report said `installed && executable`
1281
+ // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
1282
+ // failure ("no success word without armed AND trusted"), reached through the read-only path.
1283
+ const listed = drift.installed && options.liveness !== false ? listCodexHooks(codexHome) : null;
1284
+ const own = listed === null ? [] : selectOwnHookMetadata(listed, entries);
1285
+ const checkTrust: 'trusted' | 'trust-pending' | 'unknown' =
1286
+ listed === null
1287
+ ? 'unknown'
1288
+ : own.length === entries.length && own.every((o) => o.meta.trustStatus === 'trusted' || o.meta.trustStatus === 'managed')
1289
+ ? 'trusted'
1290
+ : 'trust-pending';
1291
+ return {
1292
+ ...base,
1293
+ codexVersion,
1294
+ installed: drift.installed,
1295
+ executable,
1296
+ trust: checkTrust,
1297
+ foreignPreserved: drift.foreignPreserved,
1298
+ unattributable: drift.unattributable,
1299
+ drift: drift.drifted,
1300
+ // A home that never opted in is NOT a failure: `--check` is silent and exits 0 there (the
1301
+ // leg-1 F12 lesson — a check that chatters in every unrelated project trains its reader to
1302
+ // ignore it). Installed-but-broken is exit 1; installed-and-live is exit 0.
1303
+ exitCode: drift.installed ? (executable && checkTrust === 'trusted' ? 0 : 1) : manifest === undefined ? 0 : 1,
1304
+ warnings:
1305
+ drift.installed && !executable
1306
+ ? [`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`]
1307
+ : warnings,
1308
+ errors,
1309
+ writes,
1310
+ };
1311
+ }
1312
+
1313
+ // (5) Install. Parse-refuse BEFORE any mkdir (I1: an unparseable file must not be overwritten).
1314
+ const planned = planCodexHooks({ currentText, entries, manifest });
1315
+ if (!planned.ok) {
1316
+ return {
1317
+ ...base,
1318
+ codexVersion,
1319
+ exitCode: 1,
1320
+ warnings,
1321
+ errors: [
1322
+ planned.error,
1323
+ `heal by hand: fix ${paths.registry} (or move it aside) and re-run — dz refuses to overwrite a registry it cannot read, because foreign entries would be lost`,
1324
+ ],
1325
+ };
1326
+ }
1327
+
1328
+ mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
1329
+ const helpers = generateCodexHelpers();
1330
+ writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
1331
+ writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
1332
+
1333
+ if (planned.plan.changed) {
1334
+ backupRegistry(paths, currentText, now, writes);
1335
+ atomicWrite(paths.registry, planned.plan.text);
1336
+ writes.push(paths.registry);
1337
+ }
1338
+
1339
+ // (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
1340
+ const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
1341
+ const executable = live.status === 0 || live.status === 2;
1342
+ if (!executable) {
1343
+ warnings.push(
1344
+ `install liveness probe FAILED: the emitted command exited ${String(live.status)} through \`${process.env['SHELL'] ?? '/bin/sh'} -lc\` (127 = interpreter not found). The entry is registered but NOT armed; resolved interpreter: ${nodePath}`,
1345
+ );
1346
+ }
1347
+
1348
+ // (7) TRUST: arm the entry unattended by writing codex's own key+hash into config.toml.
1349
+ // Both values are READ from `hooks/list` — `currentHash`'s preimage is internal to codex, so
1350
+ // a computed hash would arm nothing while reading like success (M0 spike §4).
1351
+ const trustResult =
1352
+ options.liveness === false
1353
+ ? { trust: 'unknown' as const, rows: [] as { id: string; key: string }[], warnings: [] as string[] }
1354
+ : armCodexHookTrust(paths, entries, writes);
1355
+ for (const w of trustResult.warnings) warnings.push(w);
1356
+
1357
+ const trustKeys: Record<string, string> = {};
1358
+ for (const row of trustResult.rows) trustKeys[row.id] = row.key;
1359
+ const manifestText = `${JSON.stringify(
1360
+ buildCodexHookManifest({ entries, paths, codexVersion, writtenAt: now, nodePath, trustKeys }),
1361
+ null,
1362
+ 2,
1363
+ )}\n`;
1364
+ atomicWrite(paths.manifest, manifestText);
1365
+ writes.push(paths.manifest);
1366
+
1367
+ const drift = diffCodexHooks(readFileSync(paths.registry, 'utf8'), entries, undefined);
1368
+ const installed = drift.installed;
1369
+ const exitCode: 0 | 1 | 3 = installed && executable && trustResult.trust === 'trusted' ? 0 : 1;
1370
+ return {
1371
+ codexHome,
1372
+ registryPath: paths.registry,
1373
+ installed,
1374
+ executable,
1375
+ written: planned.plan.changed || writes.length > 0,
1376
+ removed: 0,
1377
+ foreignPreserved: planned.plan.foreignPreserved,
1378
+ unattributable: planned.plan.unattributable,
1379
+ drift: drift.drifted,
1380
+ trust: trustResult.trust,
1381
+ codexVersion,
1382
+ exitCode,
1383
+ warnings,
1384
+ errors,
1385
+ writes,
1386
+ };
1387
+ }
1388
+
1389
+ /** A synthetic ALLOWED PreToolUse payload — the liveness probe must never trigger a real policy hit. */
1390
+ const ALLOWED_PROBE_PAYLOAD = JSON.stringify({
1391
+ hook_event_name: 'PreToolUse',
1392
+ tool_name: 'Bash',
1393
+ tool_input: { command: 'true' },
1394
+ cwd: '/nonexistent-dz-liveness-probe',
1395
+ });
1396
+
1397
+ function atomicWrite(path: string, text: string): void {
1398
+ const tmp = `${path}.tmp-${process.pid}`;
1399
+ writeFileSync(tmp, text, { mode: 0o600 });
1400
+ renameSync(tmp, path);
1401
+ }
1402
+
1403
+ /** 0600: the helpers are invoked as `node <path>`, never executed directly (AM-35c). */
1404
+ function writeHelperIfChanged(path: string, body: string, writes: string[]): void {
1405
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
1406
+ if (current === body) return; // byte-idempotence: no change ⇒ no write ⇒ trust survives (FR-2)
1407
+ atomicWrite(path, body);
1408
+ writes.push(path);
1409
+ }
1410
+
1411
+ function backupRegistry(paths: CodexHooksPaths, currentText: string | undefined, now: string, writes: string[]): void {
1412
+ if (currentText === undefined) return;
1413
+ const backup = `${paths.registry}.bak-${now.replace(/[:.]/g, '-')}`;
1414
+ writeFileSync(backup, currentText, { mode: 0o600 });
1415
+ writes.push(backup);
1416
+ pruneBackups(paths.codexHome, 'hooks.json.bak-', 3);
1417
+ }
1418
+
1419
+ /**
1420
+ * Ask the runtime which of OUR entries it sees, then persist trust for exactly those.
1421
+ *
1422
+ * Matching is on the exact emitted command string, so a third party's entry can never inherit dz's
1423
+ * trust write. A failure here is a WARNING, never an install failure: the registry is written and
1424
+ * the user can approve interactively (the `ARMED = NO` branch, AM-17 — both branches ship).
1425
+ */
1426
+ function armCodexHookTrust(
1427
+ paths: CodexHooksPaths,
1428
+ entries: readonly ManagedEntry[],
1429
+ writes: string[],
1430
+ ): { readonly trust: 'trusted' | 'trust-pending' | 'unknown'; readonly rows: readonly { id: string; key: string }[]; readonly warnings: readonly string[] } {
1431
+ const listed = listCodexHooks(paths.codexHome);
1432
+ if (listed === null) {
1433
+ return {
1434
+ trust: 'unknown',
1435
+ rows: [],
1436
+ warnings: [
1437
+ 'could not read the codex `hooks/list` RPC — hook trust could not be recorded. ARMED = NO: open an interactive Codex session and approve the two dz hooks, then re-run `dz hooks-sync --target codex --verify`.',
1438
+ ],
1439
+ };
1440
+ }
1441
+ const own = selectOwnHookMetadata(listed, entries);
1442
+ if (own.length !== entries.length) {
1443
+ return {
1444
+ trust: 'trust-pending',
1445
+ rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1446
+ warnings: [`codex reports ${own.length} of ${entries.length} dz entries — trust recorded only for the ones it sees`],
1447
+ };
1448
+ }
1449
+ const configText = existsSync(paths.configToml) ? readFileSync(paths.configToml, 'utf8') : '';
1450
+ const next = upsertTrustBlock(
1451
+ configText,
1452
+ own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })),
1453
+ );
1454
+ if (next !== configText) {
1455
+ atomicWrite(paths.configToml, next);
1456
+ writes.push(paths.configToml);
1457
+ }
1458
+ // Re-read: the only honest confirmation that the write armed anything is the runtime's own answer.
1459
+ const after = listCodexHooks(paths.codexHome);
1460
+ const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries);
1461
+ const allTrusted = confirmed.length === entries.length && confirmed.every((c) => c.meta.trustStatus === 'trusted' || c.meta.trustStatus === 'managed');
1462
+ return {
1463
+ trust: allTrusted ? 'trusted' : 'trust-pending',
1464
+ rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
1465
+ warnings: allTrusted
1466
+ ? []
1467
+ : ['dz wrote the hook trust rows but codex still reports the entries as untrusted — approve them in an interactive session'],
1468
+ };
1469
+ }
1470
+
1471
+ /**
1472
+ * Drive `codex app-server` over stdio for one `hooks/list` call.
1473
+ *
1474
+ * MEASURED headless (M0 spike, probe 1): `initialize` → `initialized` → `hooks/list` answers with
1475
+ * `key`, `currentHash`, `trustStatus` and `sourcePath` for every discovered entry, no TUI involved.
1476
+ */
1477
+ export function listCodexHooks(codexHome: string, cwd: string = process.cwd()): readonly HooksListHookMetadata[] | null {
1478
+ const script = [
1479
+ "const{spawn}=require('node:child_process');",
1480
+ "const p=spawn('codex',['app-server'],{env:{...process.env,CODEX_HOME:process.argv[1]},stdio:['pipe','pipe','ignore']});",
1481
+ "let b='';const send=o=>p.stdin.write(JSON.stringify(o)+'\\n');",
1482
+ "const bail=()=>{try{p.kill()}catch(e){};process.stdout.write('null');process.exit(0)};",
1483
+ "const t=setTimeout(bail,45000);",
1484
+ "p.on('error',bail);",
1485
+ "p.stdout.on('data',d=>{b+=d.toString();let i;while((i=b.indexOf('\\n'))>=0){const l=b.slice(0,i);b=b.slice(i+1);if(!l.trim())continue;let m;try{m=JSON.parse(l)}catch(e){continue}",
1486
+ "if(m.id===1){send({jsonrpc:'2.0',method:'initialized'});send({jsonrpc:'2.0',id:2,method:'hooks/list',params:{cwds:[process.argv[2]]}})}",
1487
+ "else if(m.id===2){clearTimeout(t);process.stdout.write(JSON.stringify(m.result&&m.result.data?m.result.data:null));try{p.kill()}catch(e){};process.exit(0)}}});",
1488
+ "send({jsonrpc:'2.0',id:1,method:'initialize',params:{clientInfo:{name:'dz',version:'1.0.0'}}});",
1489
+ ].join('');
1490
+ try {
1491
+ const out = execFileSync(process.execPath, ['-e', script, codexHome, cwd], {
1492
+ encoding: 'utf8',
1493
+ timeout: 60_000,
1494
+ stdio: ['ignore', 'pipe', 'ignore'],
1495
+ });
1496
+ const data = JSON.parse(out) as { hooks?: HooksListHookMetadata[] }[] | null;
1497
+ if (data === null || !Array.isArray(data)) return null;
1498
+ return data.flatMap((entry) => entry.hooks ?? []);
1499
+ } catch {
1500
+ return null;
1501
+ }
1502
+ }
1503
+
1504
+ /** Newest recall-usage row timestamp and whether any row carries `runtime: 'codex'`. Never throws. */
1505
+ function newestRecallUsageRuntime(logPath: string): { readonly newest?: string; readonly hasCodexRow: boolean } {
1506
+ try {
1507
+ if (!existsSync(logPath)) return { hasCodexRow: false };
1508
+ const lines = readFileSync(logPath, 'utf-8').split('\n');
1509
+ let newest: string | undefined;
1510
+ let hasCodexRow = false;
1511
+ for (const line of lines) {
1512
+ if (line.trim() === '') continue;
1513
+ try {
1514
+ const rec = JSON.parse(line) as { ts?: unknown; runtime?: unknown };
1515
+ if (rec.runtime === 'codex' && typeof rec.ts === 'string') {
1516
+ hasCodexRow = true;
1517
+ if (newest === undefined || rec.ts > newest) newest = rec.ts;
1518
+ }
1519
+ } catch { /* a malformed line is counted elsewhere */ }
1520
+ }
1521
+ return newest === undefined ? { hasCodexRow } : { newest, hasCodexRow };
1522
+ } catch {
1523
+ return { hasCodexRow: false };
1524
+ }
1525
+ }