@event4u/agent-config 9.31.0 → 9.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +146 -0
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +17 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-PMQ6Z3T3.js} +1642 -165
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +308 -227
- package/dist/cli-delegate/cmd_doctor.js +4 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +160 -58
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +30 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1136 -690
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3688 -1159
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +44 -3
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/carrier_divergence.ts +98 -27
- package/src/scripts/_lib/envelope_grounding.ts +206 -0
- package/src/scripts/_lib/host_capability.ts +79 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +104 -29
- package/src/scripts/report_conformance_funnel.ts +51 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
|
@@ -90,6 +90,8 @@ import * as installed_tools from '../_lib/installed_tools.js';
|
|
|
90
90
|
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
91
91
|
import * as global_deploy_inventory from '../_lib/global_deploy_inventory.js';
|
|
92
92
|
import { censusDuplicateScope } from '../_lib/duplicate_scope_census.js';
|
|
93
|
+
import * as preamble_byte_census from '../preamble_byte_census.js';
|
|
94
|
+
import * as dispatch_economy_report from '../dispatch_economy_report.js';
|
|
93
95
|
import { git_common_dir } from '../_lib/git_common_dir.js';
|
|
94
96
|
import * as sync_gitattributes from '../sync_gitattributes.js';
|
|
95
97
|
import {
|
|
@@ -3244,6 +3246,7 @@ interface Options {
|
|
|
3244
3246
|
check: string | null;
|
|
3245
3247
|
trace_root: boolean;
|
|
3246
3248
|
context: boolean;
|
|
3249
|
+
anatomy: boolean;
|
|
3247
3250
|
repair: string | null;
|
|
3248
3251
|
}
|
|
3249
3252
|
|
|
@@ -3254,13 +3257,15 @@ const PROG = 'agent-config doctor';
|
|
|
3254
3257
|
// terminal width; golden tests assert the `usage:` token + exit code only.
|
|
3255
3258
|
const USAGE =
|
|
3256
3259
|
`usage: ${PROG} [-h] [--project PROJECT] [--json] [--ci] [--check ID]\n` +
|
|
3257
|
-
' [--trace-root] [--context] [--
|
|
3260
|
+
' [--trace-root] [--context] [--anatomy]\n' +
|
|
3261
|
+
' [--repair ID]\n';
|
|
3258
3262
|
|
|
3259
3263
|
const _STORE_TRUE_FLAGS: Record<string, keyof Options> = {
|
|
3260
3264
|
'--json': 'json',
|
|
3261
3265
|
'--ci': 'ci',
|
|
3262
3266
|
'--trace-root': 'trace_root',
|
|
3263
3267
|
'--context': 'context',
|
|
3268
|
+
'--anatomy': 'anatomy',
|
|
3264
3269
|
};
|
|
3265
3270
|
|
|
3266
3271
|
const _VALUE_FLAGS: Record<string, keyof Options> = {
|
|
@@ -3283,6 +3288,7 @@ function _parse(argv: string[]): Options {
|
|
|
3283
3288
|
check: null,
|
|
3284
3289
|
trace_root: false,
|
|
3285
3290
|
context: false,
|
|
3291
|
+
anatomy: false,
|
|
3286
3292
|
repair: null,
|
|
3287
3293
|
};
|
|
3288
3294
|
|
|
@@ -3408,6 +3414,65 @@ function _run_context(opts: Options): number {
|
|
|
3408
3414
|
return 0;
|
|
3409
3415
|
}
|
|
3410
3416
|
|
|
3417
|
+
/**
|
|
3418
|
+
* `--anatomy` — the injection anatomy, rendered from measurements that already
|
|
3419
|
+
* exist. Composes `preamble_byte_census` (what occupies the always-loaded
|
|
3420
|
+
* preamble) with `dispatch_economy_report` (what the dispatch legs cost). It
|
|
3421
|
+
* takes NO new measurement and adds no threshold: both sources are advisory
|
|
3422
|
+
* reports and stay advisory here.
|
|
3423
|
+
*
|
|
3424
|
+
* Neither module runs anything at import time — `preamble_byte_census` gained
|
|
3425
|
+
* the `__AGENT_CONFIG_BUNDLE__` guard for exactly this import, since inside the
|
|
3426
|
+
* `build:cli-delegate` bundle its old entry check would have matched whenever
|
|
3427
|
+
* this command was invoked directly.
|
|
3428
|
+
*/
|
|
3429
|
+
function _run_anatomy(opts: Options): number {
|
|
3430
|
+
const census = preamble_byte_census;
|
|
3431
|
+
const economy = dispatch_economy_report;
|
|
3432
|
+
|
|
3433
|
+
const byteCensus = census.buildReport(census.parseArgs([]));
|
|
3434
|
+
|
|
3435
|
+
// The economy half reads transcripts and a registration file; either can be
|
|
3436
|
+
// absent on a consumer machine. An absent half is reported as absent, never
|
|
3437
|
+
// as a zero — a fabricated zero is the failure both source reports already
|
|
3438
|
+
// refuse to make.
|
|
3439
|
+
let economyReport: ReturnType<typeof economy.buildReport> | null = null;
|
|
3440
|
+
let economyError: string | null = null;
|
|
3441
|
+
try {
|
|
3442
|
+
economyReport = economy.buildReport({
|
|
3443
|
+
root: economy.DEFAULT_PROJECTS_ROOT,
|
|
3444
|
+
auditDir: path.join(process.cwd(), economy.DEFAULT_AUDIT_DIR),
|
|
3445
|
+
maxAgeDays: 14,
|
|
3446
|
+
});
|
|
3447
|
+
} catch (err) {
|
|
3448
|
+
economyError = err instanceof Error ? err.message : String(err);
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
if (opts.json) {
|
|
3452
|
+
print(
|
|
3453
|
+
_jsonDumpsIndentAscii(
|
|
3454
|
+
{
|
|
3455
|
+
preamble_byte_census: byteCensus,
|
|
3456
|
+
dispatch_economy: economyReport,
|
|
3457
|
+
dispatch_economy_unavailable: economyError,
|
|
3458
|
+
} as unknown as Dict,
|
|
3459
|
+
2,
|
|
3460
|
+
),
|
|
3461
|
+
);
|
|
3462
|
+
return 0;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
print('── injection anatomy ─────────────────────────────────────────');
|
|
3466
|
+
print(census.renderText(byteCensus));
|
|
3467
|
+
print('');
|
|
3468
|
+
print(
|
|
3469
|
+
economyReport !== null
|
|
3470
|
+
? economy.renderText(economyReport)
|
|
3471
|
+
: `dispatch-economy report unavailable: ${economyError}`,
|
|
3472
|
+
);
|
|
3473
|
+
return 0;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3411
3476
|
function _run_repair(opts: Options): number {
|
|
3412
3477
|
const target = opts.repair;
|
|
3413
3478
|
if (target === 'wizard-state') {
|
|
@@ -3464,6 +3529,9 @@ function main(argv: string[] | null = null): number {
|
|
|
3464
3529
|
if (opts.context) {
|
|
3465
3530
|
return _run_context(opts);
|
|
3466
3531
|
}
|
|
3532
|
+
if (opts.anatomy) {
|
|
3533
|
+
return _run_anatomy(opts);
|
|
3534
|
+
}
|
|
3467
3535
|
let project_root: string;
|
|
3468
3536
|
let origin: string;
|
|
3469
3537
|
try {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
CAPSULE_SCHEMA_VERSION,
|
|
39
39
|
validateRecycleEnvelope,
|
|
40
40
|
} from '../_lib/subagent_capsule.js';
|
|
41
|
+
import { collectGrounding } from '../_lib/envelope_grounding.js';
|
|
41
42
|
import { atomic_write_json } from '../hooks/state_io.js';
|
|
42
43
|
|
|
43
44
|
export interface RecycleResult {
|
|
@@ -61,6 +62,9 @@ export function templateEnvelope(): Record<string, unknown> {
|
|
|
61
62
|
open_worker_envelopes: [],
|
|
62
63
|
artifact_paths: ['<path to a deliverable / note / evidence file>'],
|
|
63
64
|
assumptions: [],
|
|
65
|
+
next_task: '<the ONE task this envelope is written for — select content for it>',
|
|
66
|
+
suggested_skills: ['<skill the successor should invoke>'],
|
|
67
|
+
failed_approaches: ['<tried X, failed because Y — or the single entry "none">'],
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -69,6 +73,7 @@ interface ParsedArgv {
|
|
|
69
73
|
message?: string;
|
|
70
74
|
file?: string;
|
|
71
75
|
template?: boolean;
|
|
76
|
+
verify?: boolean;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
@@ -77,6 +82,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
|
77
82
|
const a = argv[i] as string;
|
|
78
83
|
if (a === '--template') {
|
|
79
84
|
parsed.template = true;
|
|
85
|
+
} else if (a === '--verify') {
|
|
86
|
+
parsed.verify = true;
|
|
80
87
|
} else if (a === '--file') {
|
|
81
88
|
const value = argv[i + 1];
|
|
82
89
|
if (!value) return { ok: false, message: '--file requires a path' };
|
|
@@ -98,7 +105,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
|
98
105
|
*/
|
|
99
106
|
export function runSessionRecycle(
|
|
100
107
|
input: string,
|
|
101
|
-
opts: { cwd: string; now?: Date },
|
|
108
|
+
opts: { cwd: string; now?: Date; verify?: boolean },
|
|
102
109
|
): RecycleResult {
|
|
103
110
|
const out: string[] = [];
|
|
104
111
|
const err: string[] = [];
|
|
@@ -123,6 +130,27 @@ export function runSessionRecycle(
|
|
|
123
130
|
}
|
|
124
131
|
if (envelope['workspace'] === undefined) envelope['workspace'] = projectRoot;
|
|
125
132
|
|
|
133
|
+
// Scripted grounding (Phase 3.3): the factual fields are READ, never
|
|
134
|
+
// composed. A model-written branch is a claim; this is a reading. The
|
|
135
|
+
// composer cannot override them — a "next_task" it got wrong is a
|
|
136
|
+
// proposal, a "head" it got wrong is a silent stale resume.
|
|
137
|
+
const grounding = collectGrounding(projectRoot);
|
|
138
|
+
// Drop the composer's factual keys UNCONDITIONALLY first. Guarding each
|
|
139
|
+
// assignment on `!== null` would leave a model-composed branch or head
|
|
140
|
+
// standing whenever the git read fails — the consumer would then compare
|
|
141
|
+
// a fabricated anchor against the real tree and produce either false
|
|
142
|
+
// drift or, worse, false silence. An unreadable fact is absent, never
|
|
143
|
+
// inherited from the composer.
|
|
144
|
+
for (const key of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify', 'uncommitted_paths']) {
|
|
145
|
+
delete envelope[key];
|
|
146
|
+
}
|
|
147
|
+
if (grounding.repo_identity !== null) envelope['repo_identity'] = grounding.repo_identity;
|
|
148
|
+
if (grounding.branch !== null) envelope['branch'] = grounding.branch;
|
|
149
|
+
if (grounding.head !== null) envelope['head'] = grounding.head;
|
|
150
|
+
if (grounding.status_summary !== null) envelope['status_summary'] = grounding.status_summary;
|
|
151
|
+
if (grounding.last_verify !== null) envelope['last_verify'] = grounding.last_verify;
|
|
152
|
+
envelope['uncommitted_paths'] = grounding.uncommitted_paths;
|
|
153
|
+
|
|
126
154
|
const violations = validateRecycleEnvelope(envelope);
|
|
127
155
|
if (violations.length > 0) {
|
|
128
156
|
return {
|
|
@@ -146,6 +174,15 @@ export function runSessionRecycle(
|
|
|
146
174
|
};
|
|
147
175
|
}
|
|
148
176
|
|
|
177
|
+
// `--verify` stops HERE: every rejection above has already run, and the
|
|
178
|
+
// only thing skipped is the write. Validating through a different path
|
|
179
|
+
// than the one that writes would make the check a second implementation
|
|
180
|
+
// to keep in sync — the failure this suite refuses elsewhere.
|
|
181
|
+
if (opts.verify === true) {
|
|
182
|
+
out.push(`recycle envelope VALID — ${bytes} bytes, not written (--verify).`);
|
|
183
|
+
return { code: 0, out, err };
|
|
184
|
+
}
|
|
185
|
+
|
|
149
186
|
const target = path.join(projectRoot, RECYCLE_ENVELOPE_REL);
|
|
150
187
|
try {
|
|
151
188
|
atomic_write_json(target, envelope);
|
|
@@ -168,9 +205,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
168
205
|
const parsed = parseArgv(argv);
|
|
169
206
|
if (!parsed.ok) {
|
|
170
207
|
const usage = [
|
|
171
|
-
'usage: agent-config session:recycle [--file <envelope.json>] [--template]',
|
|
208
|
+
'usage: agent-config session:recycle [--file <envelope.json>] [--template] [--verify]',
|
|
172
209
|
' --file <path> read the envelope JSON from a file (default: stdin)',
|
|
173
210
|
' --template print a skeleton envelope and exit',
|
|
211
|
+
' --verify validate only — same rejections, no write',
|
|
174
212
|
].join('\n');
|
|
175
213
|
if (parsed.message === 'usage') {
|
|
176
214
|
process.stdout.write(`${usage}\n`);
|
|
@@ -200,7 +238,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
200
238
|
return 2;
|
|
201
239
|
}
|
|
202
240
|
|
|
203
|
-
const result = runSessionRecycle(input, {
|
|
241
|
+
const result = runSessionRecycle(input, {
|
|
242
|
+
cwd: process.cwd(),
|
|
243
|
+
...(parsed.verify === true ? { verify: true } : {}),
|
|
244
|
+
});
|
|
204
245
|
for (const line of result.out) process.stdout.write(`${line}\n`);
|
|
205
246
|
for (const line of result.err) process.stderr.write(`${line}\n`);
|
|
206
247
|
return result.code;
|
|
@@ -35,6 +35,19 @@ export const WORD_CAP = 1200;
|
|
|
35
35
|
const SNIPPET_CHARS = 200;
|
|
36
36
|
const DONE_SNIPPET_CHARS = 400;
|
|
37
37
|
const MAX_ERRORS = 5;
|
|
38
|
+
/**
|
|
39
|
+
* Per-error cap. Above `SNIPPET_CHARS` because an error string is kept
|
|
40
|
+
* verbatim so it stays greppable — but deliberately not far above.
|
|
41
|
+
*
|
|
42
|
+
* The section competes for a fixed word cap whose trim pops the NEWEST entry
|
|
43
|
+
* first, and the newest failure is the one a successor needs. Enlarging each
|
|
44
|
+
* entry therefore makes that pre-existing trim order bite more often. 400 is
|
|
45
|
+
* twice the old truncation — enough to carry a real error line whole — while
|
|
46
|
+
* keeping the five-entry section within roughly a sixth of the cap. The
|
|
47
|
+
* pop()-newest ordering is pre-existing and is not this change's to alter;
|
|
48
|
+
* this bound is what keeps the change from amplifying it.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_ERROR_CHARS = 400;
|
|
38
51
|
const MAX_DECISIONS = 5;
|
|
39
52
|
const MAX_FILES = 15;
|
|
40
53
|
|
|
@@ -325,11 +338,23 @@ export function build_handoff(
|
|
|
325
338
|
);
|
|
326
339
|
|
|
327
340
|
// Errors + fixes — failure-pattern turns, oldest→newest, last 5.
|
|
341
|
+
//
|
|
342
|
+
// Precision rule (Phase 3.1): the error string is carried VERBATIM. It
|
|
343
|
+
// used to be `_snippet`-ed like every other section, which truncates
|
|
344
|
+
// mid-string — and a truncated error cannot be grepped, matched against a
|
|
345
|
+
// tracker, or recognised when it recurs, which is the only reason to
|
|
346
|
+
// carry it at all. So the FAILURE-matching line is taken whole (capped
|
|
347
|
+
// only against an absurd single line) instead of the turn being cut at an
|
|
348
|
+
// arbitrary offset.
|
|
328
349
|
const errors = _redact_items(
|
|
329
350
|
toolish
|
|
330
351
|
.filter((t) => FAILURE_RE.test(t.text))
|
|
331
352
|
.slice(-MAX_ERRORS)
|
|
332
|
-
.map((t) =>
|
|
353
|
+
.map((t) => {
|
|
354
|
+
const line = t.text.split('\n').find((l) => FAILURE_RE.test(l))?.trim() ?? t.text.trim();
|
|
355
|
+
const prefix = t.tool ? `${t.tool}: ` : '';
|
|
356
|
+
return `${prefix}${line}`.slice(0, MAX_ERROR_CHARS);
|
|
357
|
+
}),
|
|
333
358
|
);
|
|
334
359
|
|
|
335
360
|
// Key decisions — decision-pattern assistant turns, last 5.
|
|
@@ -396,6 +421,16 @@ export function build_handoff(
|
|
|
396
421
|
}
|
|
397
422
|
for (const s of sections) {
|
|
398
423
|
lines.push('', `## ${s.title}`, '');
|
|
424
|
+
if (s.title === 'Resume pointer') {
|
|
425
|
+
// Precision rules (Phase 3.1), rendered where the successor
|
|
426
|
+
// reads them rather than filed in a doc nobody opens.
|
|
427
|
+
lines.push(
|
|
428
|
+
'> Precision contract: identify code by signature or `path:line`, never by',
|
|
429
|
+
'> description; quote error strings verbatim; give every resume step its',
|
|
430
|
+
'> expected outcome, so "done" is checkable rather than felt.',
|
|
431
|
+
'',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
399
434
|
if (s.items.length === 0) {
|
|
400
435
|
if (s.verbatim && verbatim.withheldLines > 0) {
|
|
401
436
|
lines.push(`- [${verbatim.withheldLines} line(s) withheld by privacy floor]`);
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* De-dup: native-store sessions whose platform id maps (via
|
|
20
20
|
* `derive_session_tag`) to a chat-history bucket are dropped — the
|
|
21
21
|
* chat-history entry wins.
|
|
22
|
+
*
|
|
23
|
+
* Two exclusions then apply to the merged list (road-to-cost-parity-3
|
|
24
|
+
* Phase 1): the ISSUING session, by exact id and never heuristically, and
|
|
25
|
+
* every candidate holding nothing worth resuming (`is_substantive`). Both
|
|
26
|
+
* fail OPEN — an unknown lists.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import * as fs from 'node:fs';
|
|
@@ -26,10 +31,35 @@ import * as os from 'node:os';
|
|
|
26
31
|
import * as path from 'node:path';
|
|
27
32
|
|
|
28
33
|
import { derive_session_tag, list_sessions, read_entries } from '../chat_history.js';
|
|
34
|
+
import { eolSessionKey, readEolCounters, type StoredEolCounters } from '../_lib/session_eol.js';
|
|
29
35
|
|
|
30
36
|
export const DEFAULT_CAP = 15;
|
|
37
|
+
/**
|
|
38
|
+
* Per-source over-fetch factor. The exclusions run after the per-source cap,
|
|
39
|
+
* so fetching exactly `cap` lets a filtered candidate consume a slot no
|
|
40
|
+
* eligible session can reclaim. 3× is a backfill margin, not a measurement:
|
|
41
|
+
* on the observed store 10 of 217 sessions are filtered (~5%), so 3× covers
|
|
42
|
+
* that comfortably while keeping the per-file scan bounded.
|
|
43
|
+
*/
|
|
44
|
+
const FETCH_OVERSHOOT = 3;
|
|
31
45
|
const SUMMARY_SNIPPET_CHARS = 120;
|
|
32
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Committed substantive-content floor, in parsed transcript tokens (billable
|
|
49
|
+
* input of the last main-chain assistant record, per `cc_transcript.ts`) — the
|
|
50
|
+
* OR-arm for a session that produced real discussion without ever calling a
|
|
51
|
+
* tool.
|
|
52
|
+
*
|
|
53
|
+
* Derivation (`agents/evidence/analysis/handoff-substantive-threshold.md`,
|
|
54
|
+
* 217 sessions of the local Claude store, 2026-08-10): 206 of the 207 sessions
|
|
55
|
+
* carrying an assistant record also carry a `tool_use` block, so this arm
|
|
56
|
+
* serves the ~0.5 % tail and the hosts whose transcripts log no tool blocks at
|
|
57
|
+
* all. 10 000 sits **25× below** the p10 of real working sessions (254 939),
|
|
58
|
+
* so it cannot hide one, and above a single trivial exchange. Changing it is a
|
|
59
|
+
* PR citing evidence, never a drive-by edit.
|
|
60
|
+
*/
|
|
61
|
+
export const SUBSTANTIVE_TOKEN_FLOOR = 10_000;
|
|
62
|
+
|
|
33
63
|
export type HandoffSource = 'chat-history' | 'claude-transcript' | 'codex-session';
|
|
34
64
|
|
|
35
65
|
export interface HandoffSession {
|
|
@@ -55,6 +85,21 @@ export interface ListOptions {
|
|
|
55
85
|
codexSessionsRoot?: string;
|
|
56
86
|
/** Max sessions per source AND in the merged result. Default 15. */
|
|
57
87
|
cap?: number;
|
|
88
|
+
/**
|
|
89
|
+
* The ISSUING session's id — excluded from the result unconditionally.
|
|
90
|
+
*
|
|
91
|
+
* Default: `AGENT_SESSION_ID` (this package's own envelope convention),
|
|
92
|
+
* else `CLAUDE_CODE_SESSION_ID`. The second name is **measured, not
|
|
93
|
+
* guessed**: a live Claude Code session (host 2026-08-10) exports
|
|
94
|
+
* `CLAUDE_CODE_SESSION_ID` into every Bash tool call and its value is the
|
|
95
|
+
* session uuid that also names the transcript file — a plausible-looking
|
|
96
|
+
* `CLAUDE_SESSION_ID` is not exported and would leave this filter inert.
|
|
97
|
+
*
|
|
98
|
+
* Pass `null` to disable (a caller that genuinely is not a session).
|
|
99
|
+
*/
|
|
100
|
+
selfSessionId?: string | null;
|
|
101
|
+
/** Workspace root holding `agents/runtime/state/session-eol/`. Default: `cwd`. */
|
|
102
|
+
workspaceRoot?: string;
|
|
58
103
|
}
|
|
59
104
|
|
|
60
105
|
/** Mirrors the Claude Code project-dir naming: `/` and `.` → `-`. */
|
|
@@ -408,28 +453,114 @@ function _sort_key(s: HandoffSession): string {
|
|
|
408
453
|
return s.endedAt ?? s.startedAt ?? '';
|
|
409
454
|
}
|
|
410
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Does this session hold anything worth resuming?
|
|
458
|
+
*
|
|
459
|
+
* `≥ 1 assistant turn AND (≥ 1 tool call OR parsed tokens ≥ the committed
|
|
460
|
+
* floor)`, read from the counts-only session-eol state.
|
|
461
|
+
*
|
|
462
|
+
* **Fail-open, deliberately** (Phase 1.2): absent, unreadable or mis-shaped
|
|
463
|
+
* state LISTS the candidate rather than filtering it, and a `tool_calls` key
|
|
464
|
+
* missing from a state file written before that counter existed reads as
|
|
465
|
+
* *unknown*, never as zero. A wrongly listed candidate is noise the user
|
|
466
|
+
* scrolls past; a wrongly hidden one is data loss they cannot even see.
|
|
467
|
+
*/
|
|
468
|
+
export function is_substantive(counters: StoredEolCounters | null): boolean {
|
|
469
|
+
if (counters === null) return true;
|
|
470
|
+
// Every "unknown" below is a NUMBER test, not an `=== undefined` test.
|
|
471
|
+
// Absent, null and NaN are all unknown, and each one reaches this code by
|
|
472
|
+
// a real path: a state file written before the counter existed is absent,
|
|
473
|
+
// `JSON.stringify` turns NaN into null on the way to disk, and a
|
|
474
|
+
// half-written file yields a parseable object with missing keys. Reading
|
|
475
|
+
// any of them as "counted zero" hides a session that did real work.
|
|
476
|
+
if (!Number.isFinite(counters.assistant_records)) return true;
|
|
477
|
+
if ((counters.assistant_records as number) < 1) return false;
|
|
478
|
+
if (!Number.isFinite(counters.tool_calls)) return true;
|
|
479
|
+
if ((counters.tool_calls as number) >= 1) return true;
|
|
480
|
+
if (!Number.isFinite(counters.final_context_tokens)) return true;
|
|
481
|
+
return (counters.final_context_tokens as number) >= SUBSTANTIVE_TOKEN_FLOOR;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _derive_tag(id: string): string | null {
|
|
485
|
+
try {
|
|
486
|
+
return derive_session_tag(id);
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The state file is keyed on the RAW session id (or transcript path) the hook
|
|
494
|
+
* saw. A chat-history bucket carries only the derived tag, so the native scan
|
|
495
|
+
* — which holds both — supplies the raw identity for those buckets.
|
|
496
|
+
*/
|
|
497
|
+
function _counters_for(
|
|
498
|
+
session: HandoffSession,
|
|
499
|
+
byTag: Map<string, HandoffSession>,
|
|
500
|
+
workspaceRoot: string,
|
|
501
|
+
): StoredEolCounters | null {
|
|
502
|
+
const native = session.source === 'chat-history' ? byTag.get(session.id) : session;
|
|
503
|
+
const candidates = [
|
|
504
|
+
...(session.source === 'chat-history' ? [] : [session.id]),
|
|
505
|
+
native?.id,
|
|
506
|
+
native?.transcriptPath,
|
|
507
|
+
];
|
|
508
|
+
for (const candidate of candidates) {
|
|
509
|
+
if (!candidate) continue;
|
|
510
|
+
const counters = readEolCounters(workspaceRoot, eolSessionKey(candidate));
|
|
511
|
+
if (counters !== null) return counters;
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
411
516
|
/**
|
|
412
517
|
* Unified newest-first session list. Native-store sessions whose platform
|
|
413
518
|
* id maps to an existing chat-history bucket are dropped (chat-history is
|
|
414
519
|
* the primary source).
|
|
520
|
+
*
|
|
521
|
+
* Two exclusions apply to the merged list: the ISSUING session, by exact id
|
|
522
|
+
* (never heuristically — a heuristic here hides a session the user wanted),
|
|
523
|
+
* and every candidate `is_substantive` rejects.
|
|
415
524
|
*/
|
|
416
525
|
export function list_handoff_sessions(opts: ListOptions = {}): HandoffSession[] {
|
|
417
526
|
const cap = opts.cap ?? DEFAULT_CAP;
|
|
527
|
+
const workspaceRoot = opts.workspaceRoot ?? opts.cwd ?? process.cwd();
|
|
528
|
+
const selfId =
|
|
529
|
+
opts.selfSessionId === null
|
|
530
|
+
? null
|
|
531
|
+
: (opts.selfSessionId ??
|
|
532
|
+
process.env.AGENT_SESSION_ID ??
|
|
533
|
+
process.env.CLAUDE_CODE_SESSION_ID ??
|
|
534
|
+
null);
|
|
535
|
+
const selfTag = selfId ? _derive_tag(selfId) : null;
|
|
536
|
+
|
|
418
537
|
const primary = _chat_history_sessions(opts);
|
|
419
538
|
const knownTags = new Set(primary.map((s) => s.id));
|
|
420
539
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
540
|
+
// Over-fetch per source, because the two filters below run AFTER it and
|
|
541
|
+
// the issuing session is by construction among the newest — with a
|
|
542
|
+
// per-source cap of exactly `cap`, the caller always burns a slot and
|
|
543
|
+
// every filtered empty session burns another, so a substantive session
|
|
544
|
+
// just outside the window is silently dropped instead of backfilled.
|
|
545
|
+
// That is the hidden-session direction Phase 1.2 calls data loss.
|
|
546
|
+
const fetchCap = cap * FETCH_OVERSHOOT;
|
|
547
|
+
const nativeAll = [
|
|
548
|
+
..._claude_transcript_sessions(opts, fetchCap),
|
|
549
|
+
..._codex_sessions(opts, fetchCap),
|
|
550
|
+
];
|
|
551
|
+
const byTag = new Map<string, HandoffSession>();
|
|
552
|
+
for (const s of nativeAll) {
|
|
553
|
+
const tag = _derive_tag(s.id);
|
|
554
|
+
if (tag) byTag.set(tag, s);
|
|
555
|
+
}
|
|
556
|
+
const native = nativeAll.filter((s) => {
|
|
557
|
+
const tag = _derive_tag(s.id);
|
|
558
|
+
return tag === null || !knownTags.has(tag);
|
|
430
559
|
});
|
|
431
560
|
|
|
432
561
|
return [...primary, ...native]
|
|
562
|
+
.filter((s) => s.id !== selfId && (selfTag === null || s.id !== selfTag))
|
|
563
|
+
.filter((s) => is_substantive(_counters_for(s, byTag, workspaceRoot)))
|
|
433
564
|
.sort((a, b) => (_sort_key(a) < _sort_key(b) ? 1 : _sort_key(a) > _sort_key(b) ? -1 : 0))
|
|
434
565
|
.slice(0, cap);
|
|
435
566
|
}
|
|
@@ -130,7 +130,7 @@ Tier 1 — power-user (release shape, audit, migration):
|
|
|
130
130
|
user-authored files; persists standing consent
|
|
131
131
|
(install.auto_converge) on first use.
|
|
132
132
|
Flags: --dry-run | --yes
|
|
133
|
-
conformance:behavior Replay local transcripts through the mechanised conformance checks
|
|
133
|
+
conformance:behavior Replay local transcripts through the mechanised conformance checks (--why <id> traces one)
|
|
134
134
|
conformance Consumer conformance contract: doctor --ci +
|
|
135
135
|
installed-and-firing checks. Exits non-zero on
|
|
136
136
|
a failed check.
|
|
@@ -197,7 +197,7 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
197
197
|
session register). Flags: --json
|
|
198
198
|
sessions:claim Claim a roadmap for this session so other sessions skip
|
|
199
199
|
it; --release clears the claim
|
|
200
|
-
session:recycle Validate + write the main-session recycle envelope
|
|
200
|
+
session:recycle Validate + write the main-session recycle envelope (--verify validates only)
|
|
201
201
|
(deliberate recycle instead of auto-compact). Flags:
|
|
202
202
|
--file <json> | --template; default reads stdin
|
|
203
203
|
roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
|
|
@@ -207,7 +207,9 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
207
207
|
gates Open decisions that need you, rendered as actions —
|
|
208
208
|
roadmap blockers filtered by owner, most-unblocking
|
|
209
209
|
first. Flags: --all (include maintainer/external),
|
|
210
|
-
--json
|
|
210
|
+
--json, --reply (reply-close form: the one blocking
|
|
211
|
+
decision in full, the rest as a count; prints
|
|
212
|
+
nothing when none are yours)
|
|
211
213
|
capabilities:index Regenerate CAPABILITIES.yaml — the package coverage index
|
|
212
214
|
(capability area → coverage → backing skills/commands → gaps).
|
|
213
215
|
Pass --check to fail if stale (for CI). Reads src/ (package repo).
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
* provenance-only difference survives even a perfect version alignment. Keeping
|
|
23
23
|
* it apart from class 3 is what turns a bare "0 identical twins" into an answer:
|
|
24
24
|
* `provenanceOnly == shared` means aligning versions would buy nothing, and
|
|
25
|
-
* `bodyDiff > 0` means the two carriers are delivering different
|
|
26
|
-
*
|
|
25
|
+
* `bodyDiff > 0` means the two carriers are delivering different bytes.
|
|
26
|
+
*
|
|
27
|
+
* `bodyDiff > 0` is NOT the same as "a reader has to act", and an earlier version
|
|
28
|
+
* of this paragraph said it was. Measured 2026-08-10: all 109 shared rules
|
|
29
|
+
* classified `bodyDiff` and NONE of them differed in prose. The reader-facing
|
|
30
|
+
* split lives in `report_carrier_divergence`, which subdivides this class into
|
|
31
|
+
* prose divergence (act), a `paths:`-scope disagreement (act — it changes WHEN a
|
|
32
|
+
* rule loads), and a metadata-only difference (do not act). The three-way split
|
|
33
|
+
* below stays three-way on purpose: it is the DEDUP predicate, and every class
|
|
34
|
+
* here is a real byte difference.
|
|
27
35
|
*
|
|
28
36
|
* NOT A LICENCE TO DEDUP. Classifying a pair `provenance-only` says the bodies
|
|
29
37
|
* match; it does NOT say the rule is safe to drop from one carrier. Byte-identity
|
|
@@ -81,24 +89,47 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
|
|
|
81
89
|
return 'body-diff';
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
/** One rule file, split at its frontmatter fence. */
|
|
93
|
+
export interface FrontmatterSplit {
|
|
94
|
+
/** `true` only when a terminated `---` fence was actually found. */
|
|
95
|
+
hadFrontmatter: boolean;
|
|
96
|
+
/** The fence block WITHOUT its delimiters, or `''` when there is none. */
|
|
97
|
+
frontmatter: string;
|
|
98
|
+
/** Everything after the fence — the prose the host delivers. */
|
|
99
|
+
body: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
103
|
+
* THE fence parser. One definition, for the reason the header gives: a second
|
|
104
|
+
* copy is how two surfaces drift into disagreeing about what frontmatter is.
|
|
87
105
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
106
|
+
* An absent or UNTERMINATED fence yields `hadFrontmatter: false` and the whole
|
|
107
|
+
* text as body. That is deliberate and it is the safe direction: a malformed
|
|
108
|
+
* fence must not silently swallow a rule's governed text. Note this is the
|
|
109
|
+
* OPPOSITE of what a naive `type:` reader does — treating the whole file as
|
|
110
|
+
* frontmatter — and that asymmetry was a live inconsistency between this
|
|
111
|
+
* function and `report_carrier_divergence._ruleType` until the latter was moved
|
|
112
|
+
* onto this parser.
|
|
91
113
|
*/
|
|
92
|
-
export function
|
|
93
|
-
if (!text.startsWith('---')) return text;
|
|
114
|
+
export function splitFrontmatter(text: string): FrontmatterSplit {
|
|
115
|
+
if (!text.startsWith('---')) return { hadFrontmatter: false, frontmatter: '', body: text };
|
|
94
116
|
const end = text.indexOf('\n---', 3);
|
|
95
|
-
if (end === -1) return text;
|
|
117
|
+
if (end === -1) return { hadFrontmatter: false, frontmatter: '', body: text };
|
|
96
118
|
const afterFence = text.indexOf('\n', end + 1);
|
|
97
|
-
return
|
|
119
|
+
return {
|
|
120
|
+
hadFrontmatter: true,
|
|
121
|
+
frontmatter: text.slice(3, end),
|
|
122
|
+
body: afterFence === -1 ? '' : text.slice(afterFence + 1),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The prose a rule file delivers, with any frontmatter block removed. */
|
|
127
|
+
export function stripFrontmatter(text: string): string {
|
|
128
|
+
return splitFrontmatter(text).body;
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
/**
|
|
101
|
-
*
|
|
132
|
+
* Is the ONLY difference between these two copies inside the frontmatter block?
|
|
102
133
|
*
|
|
103
134
|
* WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
|
|
104
135
|
* --------------------------------------------------------------
|
|
@@ -109,23 +140,63 @@ export function stripFrontmatter(text: string): string {
|
|
|
109
140
|
* strict. `measure_scope_dedup` asks exactly that question and its arithmetic
|
|
110
141
|
* stays untouched.
|
|
111
142
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
143
|
+
* WHAT IT PROVES, EXACTLY — AND WHY NOT `trim()`
|
|
144
|
+
* ---------------------------------------------
|
|
145
|
+
* Two conditions, both required:
|
|
146
|
+
*
|
|
147
|
+
* 1. at least one side actually HAD a frontmatter block. Without this the
|
|
148
|
+
* predicate would answer "the prose matches" for a pair with no frontmatter
|
|
149
|
+
* anywhere, and the caller would print "differs only in frontmatter" about
|
|
150
|
+
* a difference that is not in any frontmatter.
|
|
151
|
+
* 2. the bodies match after removing LEADING newlines only. The fence
|
|
152
|
+
* necessarily leaves the stripped side one newline ahead, so that one
|
|
153
|
+
* character is a parsing artefact. A `trim()` would additionally absorb a
|
|
154
|
+
* TRAILING difference, which is a real byte difference no fence explains —
|
|
155
|
+
* and it was measured to be unnecessary: all 109 live pairs on 2026-08-10
|
|
156
|
+
* matched on leading-only, 0 needed a trailing trim.
|
|
157
|
+
*
|
|
158
|
+
* Nothing else is normalized. Internal whitespace and line endings stay
|
|
159
|
+
* significant, so a CRLF-vs-LF body is still a prose difference and this stays
|
|
160
|
+
* no softer than the dedup predicate on the text the host reads.
|
|
126
161
|
*/
|
|
127
162
|
export function proseEqual(a: string, b: string): boolean {
|
|
128
|
-
|
|
163
|
+
const sa = splitFrontmatter(a);
|
|
164
|
+
const sb = splitFrontmatter(b);
|
|
165
|
+
if (!sa.hadFrontmatter && !sb.hadFrontmatter) return false;
|
|
166
|
+
const leading = /^\n+/;
|
|
167
|
+
return sa.body.replace(leading, '') === sb.body.replace(leading, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The `paths:` block of a rule's frontmatter, normalized, or `null` when absent.
|
|
172
|
+
*
|
|
173
|
+
* `paths` is the ONE frontmatter key this host reads
|
|
174
|
+
* (`agents/evidence/analysis/claude-code-rules-dir-contract.md`), and it decides
|
|
175
|
+
* WHEN a rule loads: with it, the rule fires when a matching file is read and is
|
|
176
|
+
* not re-injected after `/compact`; without it, the rule loads unconditionally
|
|
177
|
+
* at launch. So two carriers that disagree here deliver the same text on
|
|
178
|
+
* different schedules, which a reader must act on even though the prose is
|
|
179
|
+
* identical. Measured 2026-08-10: 24 of the 109 shared rules carry `paths:` in
|
|
180
|
+
* the project copy and NONE in the global copy, so on a machine with both the
|
|
181
|
+
* always-on global copy defeats the project copy's scoping for all 24.
|
|
182
|
+
*
|
|
183
|
+
* Normalization is deliberately shallow — the key line plus its indented
|
|
184
|
+
* continuation, whitespace-collapsed. It answers "do these two agree" and is not
|
|
185
|
+
* a YAML parser; a reordered list reads as a difference, which is the
|
|
186
|
+
* conservative direction for a report that asks a human to look.
|
|
187
|
+
*/
|
|
188
|
+
export function frontmatterPaths(text: string): string | null {
|
|
189
|
+
const { hadFrontmatter, frontmatter } = splitFrontmatter(text);
|
|
190
|
+
if (!hadFrontmatter) return null;
|
|
191
|
+
const lines = frontmatter.split('\n');
|
|
192
|
+
const start = lines.findIndex((l) => /^paths:/.test(l));
|
|
193
|
+
if (start === -1) return null;
|
|
194
|
+
const block = [lines[start] as string];
|
|
195
|
+
for (const line of lines.slice(start + 1)) {
|
|
196
|
+
if (/^\s/.test(line) && line.trim() !== '') block.push(line);
|
|
197
|
+
else break;
|
|
198
|
+
}
|
|
199
|
+
return block.map((l) => l.trim()).join(' ');
|
|
129
200
|
}
|
|
130
201
|
|
|
131
202
|
export interface RuleDirCensus {
|