@dzhechkov/harness-core 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +241 -81
- package/README.md +73 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +287 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +74 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +140 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +258 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +391 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +196 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +538 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/managed-hooks.d.ts +76 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +89 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +153 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +560 -24
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +38 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +78 -5
- package/dist/parity.js.map +1 -1
- package/dist/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- package/package.json +6 -5
- package/sbom.json +486 -86
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +291 -0
- package/src/codex-hooks-verify.ts +184 -0
- package/src/codex-hooks.ts +571 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +642 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +118 -2
- package/src/loop-blobs.generated.ts +2 -2
- package/src/managed-hooks.ts +129 -0
- package/src/mutation-gate.ts +24 -2
- package/src/operations.ts +719 -28
- package/src/parity.ts +120 -6
- package/src/recall-usage.ts +184 -1
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
package/dist/operations.js
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
9
11
|
import { dirname, join } from 'node:path';
|
|
12
|
+
import { buildManagedEntries, buildCodexHookManifest, codexHooksPaths, diffCodexHooks, parseCodexHookManifest, planCodexHooks, removeCodexHooks, selectOwnHookMetadata, upsertTrustBlock, } from './codex-hooks.js';
|
|
13
|
+
import { generateCodexHelpers } from './codex-hooks-assets.js';
|
|
10
14
|
import { AGENTS_MD_PATH } from '@dzhechkov/adapter-agents-md';
|
|
11
15
|
import { claudeAdapter } from '@dzhechkov/adapter-claude';
|
|
12
16
|
import { CODEX_SKILLS_ROOT } from '@dzhechkov/adapter-codex';
|
|
@@ -17,11 +21,12 @@ import { OPENCLAUDE_SKILLS_ROOT } from '@dzhechkov/adapter-openclaude';
|
|
|
17
21
|
import { COPILOT_INSTRUCTIONS_ROOT } from '@dzhechkov/adapter-copilot';
|
|
18
22
|
import { CURSOR_RULES_ROOT } from '@dzhechkov/adapter-cursor';
|
|
19
23
|
import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
|
|
20
|
-
import { mergeAgentsMd, mergeGeminiMd, renderAgentsMdSection } from '@dzhechkov/core';
|
|
24
|
+
import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
|
|
21
25
|
import { computeRiskScore } from './risk-scoring.js';
|
|
22
26
|
import { applyEmitResult } from './apply.js';
|
|
23
|
-
import { discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
27
|
+
import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
24
28
|
import { TARGETS } from './targets.js';
|
|
29
|
+
import { AGENTS_MD_BUDGET_WARN_FRACTION, CODEX_PROJECT_DOC_MAX_BYTES, POLICY_SOURCES, detectPolicyDrift, extractPolicyBlocks, measureAgentsMdBudget, renderPolicySections, } from './agents-policy.js';
|
|
25
30
|
// ---------------------------------------------------------------------------
|
|
26
31
|
// Platform enrichment — optional extras beyond SKILL.md
|
|
27
32
|
// ---------------------------------------------------------------------------
|
|
@@ -121,6 +126,26 @@ function enrichEmitForTarget(emit, target, skillId, skill) {
|
|
|
121
126
|
warnings: [...emit.warnings, `enriched: ${extra.length} platform-specific file(s) for ${target}`],
|
|
122
127
|
};
|
|
123
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The one read/merge/write path for every root managed-Markdown projection.
|
|
131
|
+
* `write:false` is check-only and never creates a directory or target file.
|
|
132
|
+
* A max-byte refusal happens before mkdir/write, leaving the target byte-identical.
|
|
133
|
+
*/
|
|
134
|
+
export function writeManagedMarkdown(projectRoot, sections, config, options = {}) {
|
|
135
|
+
const fileAbs = join(projectRoot, config.filePath);
|
|
136
|
+
const existing = existsSync(fileAbs) ? readFileSync(fileAbs, 'utf-8') : null;
|
|
137
|
+
const merged = config.merge(existing, sections);
|
|
138
|
+
const bytes = Buffer.byteLength(merged, 'utf8');
|
|
139
|
+
if (options.maxBytes !== undefined && bytes > options.maxBytes) {
|
|
140
|
+
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`);
|
|
141
|
+
}
|
|
142
|
+
const changed = merged !== existing;
|
|
143
|
+
if (options.write !== false && changed) {
|
|
144
|
+
mkdirSync(dirname(fileAbs), { recursive: true });
|
|
145
|
+
writeFileSync(fileAbs, merged, 'utf-8');
|
|
146
|
+
}
|
|
147
|
+
return { filePath: config.filePath, changed, bytes, content: merged };
|
|
148
|
+
}
|
|
124
149
|
/**
|
|
125
150
|
* The lossy warning surfaced ONCE per agents-md install (not once per skill):
|
|
126
151
|
* agents-md flattens every selected skill into plain-Markdown sections of a
|
|
@@ -170,19 +195,24 @@ function runInitSingleFileMd(options, config) {
|
|
|
170
195
|
}
|
|
171
196
|
}
|
|
172
197
|
const missing = selection === undefined ? [] : selection.filter((id) => !discovered.has(id));
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
// Skip-and-collect: one unloadable skill must not discard the whole aggregation.
|
|
199
|
+
const failures = [];
|
|
200
|
+
const loaded = [];
|
|
201
|
+
for (const { id, skillsDir } of picked) {
|
|
202
|
+
try {
|
|
203
|
+
loaded.push({ id, section: renderAgentsMdSection(loadSkillFromDir(skillsDir, id)) });
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
failures.push(describeSkillLoadFailure(skillsDir, id, error));
|
|
207
|
+
}
|
|
181
208
|
}
|
|
209
|
+
const sections = loaded.map((entry) => entry.section);
|
|
210
|
+
const managed = writeManagedMarkdown(options.projectRoot, sections, config, { write: loaded.length > 0 });
|
|
211
|
+
const changed = managed.changed;
|
|
182
212
|
// Report the shared root file as written/skipped on the FIRST contributing
|
|
183
213
|
// skill only, so callers that SUM per-skill file counts don't count the one
|
|
184
214
|
// shared file N times. The lossy warning is surfaced ONCE, on that same skill.
|
|
185
|
-
const skills =
|
|
215
|
+
const skills = loaded.map(({ id }, index) => {
|
|
186
216
|
const owns = index === 0;
|
|
187
217
|
return {
|
|
188
218
|
id,
|
|
@@ -191,7 +221,9 @@ function runInitSingleFileMd(options, config) {
|
|
|
191
221
|
warnings: owns ? [config.lossyWarning] : [],
|
|
192
222
|
};
|
|
193
223
|
});
|
|
194
|
-
|
|
224
|
+
// No apply failures are possible here: the single write is outside every per-skill
|
|
225
|
+
// loop, so a write error propagates as itself rather than being attributed to a skill.
|
|
226
|
+
return { target: config.target, skillsDir: joinedDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures: [] };
|
|
195
227
|
}
|
|
196
228
|
/**
|
|
197
229
|
* Aggregate every selected skill into ONE root `AGENTS.md`, merging into any
|
|
@@ -220,6 +252,88 @@ export function runInitGeminiMd(options) {
|
|
|
220
252
|
lossyWarning: GEMINI_MD_LOSSY_WARNING,
|
|
221
253
|
});
|
|
222
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Refresh or verify the policy fence in root AGENTS.md. Source reads are an
|
|
257
|
+
* explicit I/O shell around the pure agents-policy module; missing/unreadable
|
|
258
|
+
* input returns inconclusive evidence and never writes a partial projection.
|
|
259
|
+
*/
|
|
260
|
+
export function runSyncAgentsPolicy(options) {
|
|
261
|
+
const sources = options.sources ?? POLICY_SOURCES;
|
|
262
|
+
const sourceFiles = new Map();
|
|
263
|
+
for (const file of new Set(sources.map((source) => source.file))) {
|
|
264
|
+
try {
|
|
265
|
+
sourceFiles.set(file, readFileSync(join(options.projectRoot, file), 'utf8'));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
sourceFiles.set(file, null);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const agentsPath = join(options.projectRoot, AGENTS_MD_PATH);
|
|
272
|
+
let currentAgents = null;
|
|
273
|
+
try {
|
|
274
|
+
currentAgents = readFileSync(agentsPath, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
catch { /* absent target is a normal first sync */ }
|
|
277
|
+
const extracted = extractPolicyBlocks(sourceFiles, sources);
|
|
278
|
+
if (extracted.missing.length > 0) {
|
|
279
|
+
const drift = detectPolicyDrift(sourceFiles, currentAgents, sources);
|
|
280
|
+
return {
|
|
281
|
+
filePath: AGENTS_MD_PATH,
|
|
282
|
+
changed: true,
|
|
283
|
+
written: false,
|
|
284
|
+
inSync: false,
|
|
285
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
286
|
+
missing: extracted.missing,
|
|
287
|
+
drift: drift.findings,
|
|
288
|
+
budget: measureAgentsMdBudget(currentAgents ?? ''),
|
|
289
|
+
warnings: [`policy source evidence is incomplete: ${extracted.missing.join(', ')}`],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const currentDrift = detectPolicyDrift(sourceFiles, currentAgents, sources);
|
|
293
|
+
const malformedOuterFence = currentDrift.findings.some((finding) => finding.id === 'dz:policies');
|
|
294
|
+
if (options.check !== true && malformedOuterFence) {
|
|
295
|
+
return {
|
|
296
|
+
filePath: AGENTS_MD_PATH,
|
|
297
|
+
changed: true,
|
|
298
|
+
written: false,
|
|
299
|
+
inSync: false,
|
|
300
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
301
|
+
missing: [],
|
|
302
|
+
drift: currentDrift.findings,
|
|
303
|
+
budget: measureAgentsMdBudget(currentAgents ?? ''),
|
|
304
|
+
warnings: ['refusing to rewrite malformed or duplicate dz:policies fence markers; repair their cardinality first'],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const sections = renderPolicySections(extracted.blocks, sources);
|
|
308
|
+
const rendered = sections.join('\n\n');
|
|
309
|
+
const claudeOnlyTokens = ['Workflow({', 'subagent_type', 'mcp__', '.claude/agents/v3/'];
|
|
310
|
+
const leaked = claudeOnlyTokens.find((token) => rendered.includes(token));
|
|
311
|
+
if (leaked !== undefined)
|
|
312
|
+
throw new Error(`AGENTS.md policy block contains Claude-only token ${JSON.stringify(leaked)}; target left unchanged`);
|
|
313
|
+
const managed = writeManagedMarkdown(options.projectRoot, sections, { filePath: AGENTS_MD_PATH, merge: mergePolicyBlock }, { write: options.check !== true, maxBytes: CODEX_PROJECT_DOC_MAX_BYTES });
|
|
314
|
+
const budget = measureAgentsMdBudget(managed.content);
|
|
315
|
+
const drift = detectPolicyDrift(sourceFiles, options.check === true ? currentAgents : managed.content, sources);
|
|
316
|
+
const warnings = [];
|
|
317
|
+
if (budget.bytes >= budget.cap * AGENTS_MD_BUDGET_WARN_FRACTION) {
|
|
318
|
+
warnings.push(`AGENTS.md uses ${budget.pct}% of the measured ${budget.cap.toLocaleString('en-US')} byte Codex project-document budget`);
|
|
319
|
+
}
|
|
320
|
+
const skillsBegin = managed.content.indexOf(AGENTS_MD_BLOCK_BEGIN);
|
|
321
|
+
if (skillsBegin !== -1 && budget.policyBlockEndsAtByte > Buffer.byteLength(managed.content.slice(0, skillsBegin), 'utf8')) {
|
|
322
|
+
warnings.push('policy fence must end before the skills fence because Codex truncates the tail');
|
|
323
|
+
}
|
|
324
|
+
const nonOk = drift.findings.filter((finding) => finding.status !== 'ok');
|
|
325
|
+
return {
|
|
326
|
+
filePath: AGENTS_MD_PATH,
|
|
327
|
+
changed: managed.changed,
|
|
328
|
+
written: options.check !== true && managed.changed,
|
|
329
|
+
inSync: options.check === true ? !managed.changed && nonOk.length === 0 : nonOk.length === 0,
|
|
330
|
+
blocks: extracted.blocks.map(({ id, sha }) => ({ id, sha })),
|
|
331
|
+
missing: [],
|
|
332
|
+
drift: drift.findings,
|
|
333
|
+
budget,
|
|
334
|
+
warnings,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
223
337
|
/** Compile every skill in `skillsDir` for `target` and apply it under `projectRoot`. */
|
|
224
338
|
export async function runInit(options) {
|
|
225
339
|
// agents-md is a flattening single-file target — aggregate all selected skills
|
|
@@ -248,19 +362,43 @@ export async function runInit(options) {
|
|
|
248
362
|
const missing = selection === undefined
|
|
249
363
|
? []
|
|
250
364
|
: selection.filter((id) => !discovered.includes(id));
|
|
365
|
+
// Skip-and-collect (D1): one unparseable SKILL.md must not discard the whole install.
|
|
366
|
+
// Same shape as `runVerify`'s long-standing per-id try/catch below.
|
|
367
|
+
//
|
|
368
|
+
// The `try` is scoped to `loadSkillFromDir` ALONE (ADR-001 §Decision part 2, restored
|
|
369
|
+
// in fix round 1 / QE F4). It used to wrap compile + apply too, so an EEXIST from
|
|
370
|
+
// `mkdir` was described by `describeSkillLoadFailure` and printed under the
|
|
371
|
+
// "unparseable SKILL.md" header, naming the source file — which was valid. Compile
|
|
372
|
+
// and write failures are a SECOND kind with their own subject and their own header;
|
|
373
|
+
// they are collected, not thrown, so one unwritable target directory still cannot
|
|
374
|
+
// discard the rest of the install.
|
|
375
|
+
const failures = [];
|
|
376
|
+
const applyFailures = [];
|
|
251
377
|
for (const id of ids) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
378
|
+
let skill;
|
|
379
|
+
try {
|
|
380
|
+
skill = loadSkillFromDir(options.skillsDir, id);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
failures.push(describeSkillLoadFailure(options.skillsDir, id, error));
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
let emit = await adapter.compile(skill, { targetRoot: options.projectRoot });
|
|
388
|
+
if (options.enrich === true) {
|
|
389
|
+
emit = enrichEmitForTarget(emit, options.target, id, skill);
|
|
390
|
+
}
|
|
391
|
+
const applied = applyEmitResult(emit, {
|
|
392
|
+
targetRoot: options.projectRoot,
|
|
393
|
+
force: options.force === true,
|
|
394
|
+
});
|
|
395
|
+
skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
applyFailures.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
256
399
|
}
|
|
257
|
-
const applied = applyEmitResult(emit, {
|
|
258
|
-
targetRoot: options.projectRoot,
|
|
259
|
-
force: options.force === true,
|
|
260
|
-
});
|
|
261
|
-
skills.push({ id, written: applied.written, skipped: applied.skipped, warnings: [...emit.warnings] });
|
|
262
400
|
}
|
|
263
|
-
return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing };
|
|
401
|
+
return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures };
|
|
264
402
|
}
|
|
265
403
|
/** Compile every skill for `target` and report whether each verifies. */
|
|
266
404
|
export async function runVerify(options) {
|
|
@@ -295,9 +433,18 @@ export async function runVerify(options) {
|
|
|
295
433
|
export async function runSync(options) {
|
|
296
434
|
const dirs = options.canonicalDirs ?? (options.canonicalDir ? [options.canonicalDir] : []);
|
|
297
435
|
const skills = [];
|
|
436
|
+
// Skip-and-collect (D1): a broken canonical skill must not hide every other pack.
|
|
437
|
+
const failures = [];
|
|
298
438
|
for (const canonicalDir of dirs) {
|
|
299
439
|
for (const id of discoverSkillIds(canonicalDir)) {
|
|
300
|
-
|
|
440
|
+
let skill;
|
|
441
|
+
try {
|
|
442
|
+
skill = loadSkillFromDir(canonicalDir, id);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
failures.push(describeSkillLoadFailure(canonicalDir, id, error));
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
301
448
|
const emit = await claudeAdapter.compile(skill, { targetRoot: options.projectRoot });
|
|
302
449
|
let differ = false;
|
|
303
450
|
let missing = false;
|
|
@@ -323,6 +470,7 @@ export async function runSync(options) {
|
|
|
323
470
|
return {
|
|
324
471
|
dryRun: options.dryRun === true,
|
|
325
472
|
skills,
|
|
473
|
+
failures,
|
|
326
474
|
summary: {
|
|
327
475
|
total: skills.length,
|
|
328
476
|
inSync: skills.filter((skill) => skill.status === 'in-sync').length,
|
|
@@ -526,6 +674,38 @@ export async function runDoctor(options) {
|
|
|
526
674
|
}
|
|
527
675
|
}
|
|
528
676
|
catch { /* settings absent/unreadable — covered by other checks */ }
|
|
677
|
+
// CODEX APPLY-LEG (AM-19, fact CORRECTED by AM-30). This block is deliberately OUTSIDE the
|
|
678
|
+
// `existsSync(writerPath)` guard below. MEASURED: the Claude apply-leg check has THREE
|
|
679
|
+
// preconditions, not two — the `.claude/settings.json` text check is NESTED inside
|
|
680
|
+
// `if (existsSync(join(root,'.dz','agentdb-writer.mjs')))`. A Codex-only machine has no writer
|
|
681
|
+
// file and no `.claude/settings.json`, so a Codex branch added *in place* would report nothing at
|
|
682
|
+
// all — reproducing the exact 19-day dark-leg shape this check exists to kill.
|
|
683
|
+
//
|
|
684
|
+
// A WIRED-BUT-SILENT leg is an explicit non-OK row, never an absent one.
|
|
685
|
+
try {
|
|
686
|
+
const { codexHooksPaths: codexPaths, parseCodexHookManifest: parseCodexManifest } = await import('./codex-hooks.js');
|
|
687
|
+
const codexHome = process.env['CODEX_HOME'] ?? join(homedir(), '.codex');
|
|
688
|
+
const manifestPath = codexPaths(codexHome).manifest;
|
|
689
|
+
if (existsSync(manifestPath)) {
|
|
690
|
+
const manifest = parseCodexManifest(readFileSync(manifestPath, 'utf-8'));
|
|
691
|
+
const claimsRecall = manifest?.entries.some((e) => e.id === 'codex-recall') === true;
|
|
692
|
+
if (claimsRecall) {
|
|
693
|
+
const usageLog = join(root, '.dz', 'recall-usage.jsonl');
|
|
694
|
+
const { newest, hasCodexRow } = newestRecallUsageRuntime(usageLog);
|
|
695
|
+
const fresh = newest !== undefined && Date.now() - Date.parse(newest) < 14 * 24 * 60 * 60 * 1000;
|
|
696
|
+
checks.push({
|
|
697
|
+
name: 'codex apply-leg (recall hook)',
|
|
698
|
+
ok: hasCodexRow && fresh,
|
|
699
|
+
detail: hasCodexRow
|
|
700
|
+
? fresh
|
|
701
|
+
? `codex recall rows present, newest ${newest}`
|
|
702
|
+
: `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)`
|
|
703
|
+
: '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',
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch { /* doctor never throws on a diagnostic */ }
|
|
529
709
|
const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
|
|
530
710
|
if (existsSync(writerPath)) {
|
|
531
711
|
const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
|
|
@@ -621,4 +801,360 @@ export async function runDoctor(options) {
|
|
|
621
801
|
catch { /* advisory only — the vector tier must never fail doctor */ }
|
|
622
802
|
return { node: process.version, checks, ok: checks.every((check) => check.ok) };
|
|
623
803
|
}
|
|
804
|
+
function resolveCodexHome(explicit) {
|
|
805
|
+
if (typeof explicit === 'string' && explicit !== '')
|
|
806
|
+
return explicit;
|
|
807
|
+
const env = process.env['CODEX_HOME'];
|
|
808
|
+
if (typeof env === 'string' && env !== '')
|
|
809
|
+
return env;
|
|
810
|
+
return join(homedir(), '.codex');
|
|
811
|
+
}
|
|
812
|
+
/** `codex --version` → `codex-cli 0.147.0`. `null` when the binary is absent or silent. */
|
|
813
|
+
function probeCodexVersion() {
|
|
814
|
+
try {
|
|
815
|
+
const out = execFileSync('codex', ['--version'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
816
|
+
const trimmed = out.trim();
|
|
817
|
+
return trimmed === '' ? null : trimmed;
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Run the emitted command THE WAY THE RUNTIME WILL — through `$SHELL -lc` (AM-32 / G-L).
|
|
825
|
+
*
|
|
826
|
+
* MEASURED: the codex hook runner spawns via `$SHELL -lc`, and under nvm/asdf/volta a
|
|
827
|
+
* non-interactive login shell frequently lacks `node`. The helper then exits **127**, which the
|
|
828
|
+
* runtime reads as **ALLOW** — a blocking guard that is silently dead in the fail-open direction,
|
|
829
|
+
* with the helper's own self-failure note unable to fire because the process never started.
|
|
830
|
+
* Grading on file presence would call that "installed".
|
|
831
|
+
*/
|
|
832
|
+
export function probeHookLiveness(command, payload) {
|
|
833
|
+
const shell = process.env['SHELL'] ?? '/bin/sh';
|
|
834
|
+
try {
|
|
835
|
+
const res = spawnSync(shell, ['-lc', command], {
|
|
836
|
+
input: payload,
|
|
837
|
+
encoding: 'utf8',
|
|
838
|
+
timeout: 20_000,
|
|
839
|
+
env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
|
|
840
|
+
});
|
|
841
|
+
return { status: res.status, stderr: res.stderr ?? '' };
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
return { status: null, stderr: String(err?.message ?? err) };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Keep the newest N backups; older ones are litter in the user's home (AM-35b). */
|
|
848
|
+
function pruneBackups(dir, prefix, keep) {
|
|
849
|
+
try {
|
|
850
|
+
const found = readdirSync(dir).filter((f) => f.startsWith(prefix)).sort();
|
|
851
|
+
for (const stale of found.slice(0, Math.max(0, found.length - keep))) {
|
|
852
|
+
rmSync(join(dir, stale), { force: true });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch { /* pruning is hygiene, never a failure */ }
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Install, verify or remove the dz Codex hooks.
|
|
859
|
+
*
|
|
860
|
+
* Order is load-bearing: every REFUSAL happens before any `mkdir` or write, so a machine without
|
|
861
|
+
* codex, or with an unparseable registry, is left byte-untouched (AM-35a).
|
|
862
|
+
*/
|
|
863
|
+
export function runSyncCodexHooks(options = {}) {
|
|
864
|
+
const codexHome = resolveCodexHome(options.codexHome);
|
|
865
|
+
const paths = codexHooksPaths(codexHome);
|
|
866
|
+
const warnings = [];
|
|
867
|
+
const errors = [];
|
|
868
|
+
const writes = [];
|
|
869
|
+
const now = options.now ?? new Date().toISOString();
|
|
870
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
871
|
+
const base = {
|
|
872
|
+
codexHome,
|
|
873
|
+
registryPath: paths.registry,
|
|
874
|
+
installed: false,
|
|
875
|
+
executable: false,
|
|
876
|
+
written: false,
|
|
877
|
+
removed: 0,
|
|
878
|
+
foreignPreserved: 0,
|
|
879
|
+
unattributable: 0,
|
|
880
|
+
drift: [],
|
|
881
|
+
trust: 'unknown',
|
|
882
|
+
codexVersion: null,
|
|
883
|
+
writes: [],
|
|
884
|
+
};
|
|
885
|
+
// (1) REFUSE when codex is not installed — dz does not create user-global config for a runtime
|
|
886
|
+
// that is not there (AM-35a). `--check` is allowed to answer, but it writes nothing anyway.
|
|
887
|
+
const codexVersion = options.codexVersion === undefined ? probeCodexVersion() : options.codexVersion;
|
|
888
|
+
if (codexVersion === null) {
|
|
889
|
+
return { ...base, exitCode: 3, warnings, errors: ['no `codex` binary on PATH — nothing was written'] };
|
|
890
|
+
}
|
|
891
|
+
// (2) REFUSE an unquotable CODEX_HOME rather than emit a broken `$SHELL -lc` string (AM-35d).
|
|
892
|
+
let entries;
|
|
893
|
+
try {
|
|
894
|
+
entries = buildManagedEntries({ nodePath, paths });
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
return { ...base, codexVersion, exitCode: 1, warnings, errors: [String(err.message)] };
|
|
898
|
+
}
|
|
899
|
+
const currentText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
|
|
900
|
+
const manifest = existsSync(paths.manifest) ? parseCodexHookManifest(readFileSync(paths.manifest, 'utf8')) : undefined;
|
|
901
|
+
// (3) --remove: delete only what the manifest proves is ours.
|
|
902
|
+
if (options.remove === true) {
|
|
903
|
+
const removal = removeCodexHooks(currentText, manifest);
|
|
904
|
+
if (!removal.ok)
|
|
905
|
+
return { ...base, codexVersion, exitCode: 1, warnings, errors: [removal.error] };
|
|
906
|
+
if (removal.result.changed) {
|
|
907
|
+
backupRegistry(paths, currentText, now, writes);
|
|
908
|
+
atomicWrite(paths.registry, removal.result.text);
|
|
909
|
+
writes.push(paths.registry);
|
|
910
|
+
}
|
|
911
|
+
for (const p of [paths.vetoHelper, paths.recallHelper, paths.manifest])
|
|
912
|
+
rmSync(p, { force: true });
|
|
913
|
+
return {
|
|
914
|
+
...base,
|
|
915
|
+
codexVersion,
|
|
916
|
+
written: removal.result.changed,
|
|
917
|
+
removed: removal.result.removed,
|
|
918
|
+
unattributable: removal.result.unattributable,
|
|
919
|
+
exitCode: 0,
|
|
920
|
+
warnings: removal.result.unattributable > 0
|
|
921
|
+
? [`${removal.result.unattributable} entr(ies) resemble dz hooks but are not manifest-attributed — KEPT, remove them by hand if you want them gone`]
|
|
922
|
+
: warnings,
|
|
923
|
+
errors,
|
|
924
|
+
writes,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
// (4) --check: recompute from the FILE and FIRE the helper. Writes nothing, and stays SILENT in
|
|
928
|
+
// a home that never opted in (the leg-1 F12 lesson).
|
|
929
|
+
if (options.check === true) {
|
|
930
|
+
const drift = diffCodexHooks(currentText, entries, manifest);
|
|
931
|
+
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0].command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
|
|
932
|
+
const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
|
|
933
|
+
// `--check` must report the TRUST axis too. Without it the report said `installed && executable`
|
|
934
|
+
// with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
|
|
935
|
+
// failure ("no success word without armed AND trusted"), reached through the read-only path.
|
|
936
|
+
const listed = drift.installed && options.liveness !== false ? listCodexHooks(codexHome) : null;
|
|
937
|
+
const own = listed === null ? [] : selectOwnHookMetadata(listed, entries);
|
|
938
|
+
const checkTrust = listed === null
|
|
939
|
+
? 'unknown'
|
|
940
|
+
: own.length === entries.length && own.every((o) => o.meta.trustStatus === 'trusted' || o.meta.trustStatus === 'managed')
|
|
941
|
+
? 'trusted'
|
|
942
|
+
: 'trust-pending';
|
|
943
|
+
return {
|
|
944
|
+
...base,
|
|
945
|
+
codexVersion,
|
|
946
|
+
installed: drift.installed,
|
|
947
|
+
executable,
|
|
948
|
+
trust: checkTrust,
|
|
949
|
+
foreignPreserved: drift.foreignPreserved,
|
|
950
|
+
unattributable: drift.unattributable,
|
|
951
|
+
drift: drift.drifted,
|
|
952
|
+
// A home that never opted in is NOT a failure: `--check` is silent and exits 0 there (the
|
|
953
|
+
// leg-1 F12 lesson — a check that chatters in every unrelated project trains its reader to
|
|
954
|
+
// ignore it). Installed-but-broken is exit 1; installed-and-live is exit 0.
|
|
955
|
+
exitCode: drift.installed ? (executable && checkTrust === 'trusted' ? 0 : 1) : manifest === undefined ? 0 : 1,
|
|
956
|
+
warnings: drift.installed && !executable
|
|
957
|
+
? [`the registry entry exists but exits ${String(live.status)} through \`$SHELL -lc\` — a hook that cannot execute is NOT armed`]
|
|
958
|
+
: warnings,
|
|
959
|
+
errors,
|
|
960
|
+
writes,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
// (5) Install. Parse-refuse BEFORE any mkdir (I1: an unparseable file must not be overwritten).
|
|
964
|
+
const planned = planCodexHooks({ currentText, entries, manifest });
|
|
965
|
+
if (!planned.ok) {
|
|
966
|
+
return {
|
|
967
|
+
...base,
|
|
968
|
+
codexVersion,
|
|
969
|
+
exitCode: 1,
|
|
970
|
+
warnings,
|
|
971
|
+
errors: [
|
|
972
|
+
planned.error,
|
|
973
|
+
`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`,
|
|
974
|
+
],
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
|
|
978
|
+
const helpers = generateCodexHelpers();
|
|
979
|
+
writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
|
|
980
|
+
writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
|
|
981
|
+
if (planned.plan.changed) {
|
|
982
|
+
backupRegistry(paths, currentText, now, writes);
|
|
983
|
+
atomicWrite(paths.registry, planned.plan.text);
|
|
984
|
+
writes.push(paths.registry);
|
|
985
|
+
}
|
|
986
|
+
// (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
|
|
987
|
+
const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0].command, ALLOWED_PROBE_PAYLOAD);
|
|
988
|
+
const executable = live.status === 0 || live.status === 2;
|
|
989
|
+
if (!executable) {
|
|
990
|
+
warnings.push(`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}`);
|
|
991
|
+
}
|
|
992
|
+
// (7) TRUST: arm the entry unattended by writing codex's own key+hash into config.toml.
|
|
993
|
+
// Both values are READ from `hooks/list` — `currentHash`'s preimage is internal to codex, so
|
|
994
|
+
// a computed hash would arm nothing while reading like success (M0 spike §4).
|
|
995
|
+
const trustResult = options.liveness === false
|
|
996
|
+
? { trust: 'unknown', rows: [], warnings: [] }
|
|
997
|
+
: armCodexHookTrust(paths, entries, writes);
|
|
998
|
+
for (const w of trustResult.warnings)
|
|
999
|
+
warnings.push(w);
|
|
1000
|
+
const trustKeys = {};
|
|
1001
|
+
for (const row of trustResult.rows)
|
|
1002
|
+
trustKeys[row.id] = row.key;
|
|
1003
|
+
const manifestText = `${JSON.stringify(buildCodexHookManifest({ entries, paths, codexVersion, writtenAt: now, nodePath, trustKeys }), null, 2)}\n`;
|
|
1004
|
+
atomicWrite(paths.manifest, manifestText);
|
|
1005
|
+
writes.push(paths.manifest);
|
|
1006
|
+
const drift = diffCodexHooks(readFileSync(paths.registry, 'utf8'), entries, undefined);
|
|
1007
|
+
const installed = drift.installed;
|
|
1008
|
+
const exitCode = installed && executable && trustResult.trust === 'trusted' ? 0 : 1;
|
|
1009
|
+
return {
|
|
1010
|
+
codexHome,
|
|
1011
|
+
registryPath: paths.registry,
|
|
1012
|
+
installed,
|
|
1013
|
+
executable,
|
|
1014
|
+
written: planned.plan.changed || writes.length > 0,
|
|
1015
|
+
removed: 0,
|
|
1016
|
+
foreignPreserved: planned.plan.foreignPreserved,
|
|
1017
|
+
unattributable: planned.plan.unattributable,
|
|
1018
|
+
drift: drift.drifted,
|
|
1019
|
+
trust: trustResult.trust,
|
|
1020
|
+
codexVersion,
|
|
1021
|
+
exitCode,
|
|
1022
|
+
warnings,
|
|
1023
|
+
errors,
|
|
1024
|
+
writes,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/** A synthetic ALLOWED PreToolUse payload — the liveness probe must never trigger a real policy hit. */
|
|
1028
|
+
const ALLOWED_PROBE_PAYLOAD = JSON.stringify({
|
|
1029
|
+
hook_event_name: 'PreToolUse',
|
|
1030
|
+
tool_name: 'Bash',
|
|
1031
|
+
tool_input: { command: 'true' },
|
|
1032
|
+
cwd: '/nonexistent-dz-liveness-probe',
|
|
1033
|
+
});
|
|
1034
|
+
function atomicWrite(path, text) {
|
|
1035
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
1036
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
1037
|
+
renameSync(tmp, path);
|
|
1038
|
+
}
|
|
1039
|
+
/** 0600: the helpers are invoked as `node <path>`, never executed directly (AM-35c). */
|
|
1040
|
+
function writeHelperIfChanged(path, body, writes) {
|
|
1041
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
1042
|
+
if (current === body)
|
|
1043
|
+
return; // byte-idempotence: no change ⇒ no write ⇒ trust survives (FR-2)
|
|
1044
|
+
atomicWrite(path, body);
|
|
1045
|
+
writes.push(path);
|
|
1046
|
+
}
|
|
1047
|
+
function backupRegistry(paths, currentText, now, writes) {
|
|
1048
|
+
if (currentText === undefined)
|
|
1049
|
+
return;
|
|
1050
|
+
const backup = `${paths.registry}.bak-${now.replace(/[:.]/g, '-')}`;
|
|
1051
|
+
writeFileSync(backup, currentText, { mode: 0o600 });
|
|
1052
|
+
writes.push(backup);
|
|
1053
|
+
pruneBackups(paths.codexHome, 'hooks.json.bak-', 3);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Ask the runtime which of OUR entries it sees, then persist trust for exactly those.
|
|
1057
|
+
*
|
|
1058
|
+
* Matching is on the exact emitted command string, so a third party's entry can never inherit dz's
|
|
1059
|
+
* trust write. A failure here is a WARNING, never an install failure: the registry is written and
|
|
1060
|
+
* the user can approve interactively (the `ARMED = NO` branch, AM-17 — both branches ship).
|
|
1061
|
+
*/
|
|
1062
|
+
function armCodexHookTrust(paths, entries, writes) {
|
|
1063
|
+
const listed = listCodexHooks(paths.codexHome);
|
|
1064
|
+
if (listed === null) {
|
|
1065
|
+
return {
|
|
1066
|
+
trust: 'unknown',
|
|
1067
|
+
rows: [],
|
|
1068
|
+
warnings: [
|
|
1069
|
+
'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`.',
|
|
1070
|
+
],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const own = selectOwnHookMetadata(listed, entries);
|
|
1074
|
+
if (own.length !== entries.length) {
|
|
1075
|
+
return {
|
|
1076
|
+
trust: 'trust-pending',
|
|
1077
|
+
rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
|
|
1078
|
+
warnings: [`codex reports ${own.length} of ${entries.length} dz entries — trust recorded only for the ones it sees`],
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
const configText = existsSync(paths.configToml) ? readFileSync(paths.configToml, 'utf8') : '';
|
|
1082
|
+
const next = upsertTrustBlock(configText, own.map((o) => ({ key: o.meta.key, trustedHash: o.meta.currentHash })));
|
|
1083
|
+
if (next !== configText) {
|
|
1084
|
+
atomicWrite(paths.configToml, next);
|
|
1085
|
+
writes.push(paths.configToml);
|
|
1086
|
+
}
|
|
1087
|
+
// Re-read: the only honest confirmation that the write armed anything is the runtime's own answer.
|
|
1088
|
+
const after = listCodexHooks(paths.codexHome);
|
|
1089
|
+
const confirmed = after === null ? [] : selectOwnHookMetadata(after, entries);
|
|
1090
|
+
const allTrusted = confirmed.length === entries.length && confirmed.every((c) => c.meta.trustStatus === 'trusted' || c.meta.trustStatus === 'managed');
|
|
1091
|
+
return {
|
|
1092
|
+
trust: allTrusted ? 'trusted' : 'trust-pending',
|
|
1093
|
+
rows: own.map((o) => ({ id: o.id, key: o.meta.key })),
|
|
1094
|
+
warnings: allTrusted
|
|
1095
|
+
? []
|
|
1096
|
+
: ['dz wrote the hook trust rows but codex still reports the entries as untrusted — approve them in an interactive session'],
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Drive `codex app-server` over stdio for one `hooks/list` call.
|
|
1101
|
+
*
|
|
1102
|
+
* MEASURED headless (M0 spike, probe 1): `initialize` → `initialized` → `hooks/list` answers with
|
|
1103
|
+
* `key`, `currentHash`, `trustStatus` and `sourcePath` for every discovered entry, no TUI involved.
|
|
1104
|
+
*/
|
|
1105
|
+
export function listCodexHooks(codexHome, cwd = process.cwd()) {
|
|
1106
|
+
const script = [
|
|
1107
|
+
"const{spawn}=require('node:child_process');",
|
|
1108
|
+
"const p=spawn('codex',['app-server'],{env:{...process.env,CODEX_HOME:process.argv[1]},stdio:['pipe','pipe','ignore']});",
|
|
1109
|
+
"let b='';const send=o=>p.stdin.write(JSON.stringify(o)+'\\n');",
|
|
1110
|
+
"const bail=()=>{try{p.kill()}catch(e){};process.stdout.write('null');process.exit(0)};",
|
|
1111
|
+
"const t=setTimeout(bail,45000);",
|
|
1112
|
+
"p.on('error',bail);",
|
|
1113
|
+
"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}",
|
|
1114
|
+
"if(m.id===1){send({jsonrpc:'2.0',method:'initialized'});send({jsonrpc:'2.0',id:2,method:'hooks/list',params:{cwds:[process.argv[2]]}})}",
|
|
1115
|
+
"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)}}});",
|
|
1116
|
+
"send({jsonrpc:'2.0',id:1,method:'initialize',params:{clientInfo:{name:'dz',version:'1.0.0'}}});",
|
|
1117
|
+
].join('');
|
|
1118
|
+
try {
|
|
1119
|
+
const out = execFileSync(process.execPath, ['-e', script, codexHome, cwd], {
|
|
1120
|
+
encoding: 'utf8',
|
|
1121
|
+
timeout: 60_000,
|
|
1122
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1123
|
+
});
|
|
1124
|
+
const data = JSON.parse(out);
|
|
1125
|
+
if (data === null || !Array.isArray(data))
|
|
1126
|
+
return null;
|
|
1127
|
+
return data.flatMap((entry) => entry.hooks ?? []);
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Newest recall-usage row timestamp and whether any row carries `runtime: 'codex'`. Never throws. */
|
|
1134
|
+
function newestRecallUsageRuntime(logPath) {
|
|
1135
|
+
try {
|
|
1136
|
+
if (!existsSync(logPath))
|
|
1137
|
+
return { hasCodexRow: false };
|
|
1138
|
+
const lines = readFileSync(logPath, 'utf-8').split('\n');
|
|
1139
|
+
let newest;
|
|
1140
|
+
let hasCodexRow = false;
|
|
1141
|
+
for (const line of lines) {
|
|
1142
|
+
if (line.trim() === '')
|
|
1143
|
+
continue;
|
|
1144
|
+
try {
|
|
1145
|
+
const rec = JSON.parse(line);
|
|
1146
|
+
if (rec.runtime === 'codex' && typeof rec.ts === 'string') {
|
|
1147
|
+
hasCodexRow = true;
|
|
1148
|
+
if (newest === undefined || rec.ts > newest)
|
|
1149
|
+
newest = rec.ts;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
catch { /* a malformed line is counted elsewhere */ }
|
|
1153
|
+
}
|
|
1154
|
+
return newest === undefined ? { hasCodexRow } : { newest, hasCodexRow };
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
return { hasCodexRow: false };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
624
1160
|
//# sourceMappingURL=operations.js.map
|