@event4u/agent-config 9.31.0 → 9.32.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 +51 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/rules/delegation-policy.md +11 -1
- package/dist/agent-src/templates/agents/agent-project-settings.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-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-542DZR4P.js} +117 -147
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/cmd_conformance.js +81 -30
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_handoff.js +166 -57
- package/dist/cli-delegate/cmd_session_recycle.js +17 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- 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 +1118 -690
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +79 -29
- package/package.json +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/scripts/_cli/cmd_session_recycle.ts +25 -0
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- 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/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/report_carrier_divergence.ts +94 -29
- package/src/scripts/report_conformance_funnel.ts +11 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic environment grounding for the handoff envelope
|
|
3
|
+
* (road-to-cost-parity-3 Phase 3.2–3.3).
|
|
4
|
+
*
|
|
5
|
+
* The factual half of an envelope — where the work was, on what branch, at
|
|
6
|
+
* which commit, what was uncommitted, what was last verified — is collected
|
|
7
|
+
* by THIS module, not composed by a model. Scripted facts are free and
|
|
8
|
+
* verifiable; deriving them through a model is the right idea at the wrong
|
|
9
|
+
* price, and a model-composed "branch" is a claim rather than a reading.
|
|
10
|
+
*
|
|
11
|
+
* Every git read goes through `gitEnv()`. An inherited `GIT_DIR` (which any
|
|
12
|
+
* hook exports) silently redirects `git` at the wrong repository, which would
|
|
13
|
+
* make the drift anchor confidently wrong — the one failure mode worse than
|
|
14
|
+
* having no anchor.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { git_common_dir } from './git_common_dir.js';
|
|
22
|
+
import { gitEnv } from './git_env.js';
|
|
23
|
+
|
|
24
|
+
/** Longest ref/line this module will emit — mirrors the envelope's own caps. */
|
|
25
|
+
const MAX_FIELD_CHARS = 200;
|
|
26
|
+
/** Uncommitted paths are a pointer list, not a diff. */
|
|
27
|
+
const MAX_PATHS = 40;
|
|
28
|
+
|
|
29
|
+
function git(root: string, args: string[]): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', args, {
|
|
32
|
+
cwd: root,
|
|
33
|
+
env: gitEnv(),
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Canonical form of a repo identity, so the SAME repository never reads as
|
|
44
|
+
* two different ones. Committed rules, in order:
|
|
45
|
+
*
|
|
46
|
+
* 1. scheme stripped (`https://`, `ssh://`, `git://`)
|
|
47
|
+
* 2. credentials stripped (`user:pass@`, `git@`)
|
|
48
|
+
* 3. scp-style `host:org/repo` → `host/org/repo`
|
|
49
|
+
* 4. host segment lower-cased
|
|
50
|
+
* 5. trailing `.git` and trailing `/` removed
|
|
51
|
+
*
|
|
52
|
+
* Without these, one checkout's `git@github.com:Org/Repo.git` and another's
|
|
53
|
+
* `https://github.com/org/repo` compare as drift while being the same repo —
|
|
54
|
+
* a false alarm every time, which trains the reader to ignore the real one.
|
|
55
|
+
*/
|
|
56
|
+
export function canonicalizeRepoIdentity(raw: string): string {
|
|
57
|
+
let s = raw.trim();
|
|
58
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // scheme
|
|
59
|
+
s = s.replace(/^[^/@]+@/, ''); // credentials / ssh user
|
|
60
|
+
s = s.replace(/^([^/:]+):(?!\/)/, '$1/'); // scp-style separator
|
|
61
|
+
s = s.replace(/\/+$/, '');
|
|
62
|
+
s = s.replace(/\.git$/i, '');
|
|
63
|
+
const slash = s.indexOf('/');
|
|
64
|
+
if (slash > 0) {
|
|
65
|
+
s = s.slice(0, slash).toLowerCase() + s.slice(slash);
|
|
66
|
+
} else {
|
|
67
|
+
s = s.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The three fields that together identify a working tree at a point in time. */
|
|
73
|
+
export interface RepoAnchor {
|
|
74
|
+
repo_identity: string | null;
|
|
75
|
+
branch: string | null;
|
|
76
|
+
head: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read the anchor for `root`.
|
|
81
|
+
*
|
|
82
|
+
* Identity is the canonicalized `origin` remote, or — when the repo has no
|
|
83
|
+
* remote — the realpath of the common git dir. Branch and HEAD alone are NOT
|
|
84
|
+
* an anchor: this repo routinely runs many worktrees, a branch name is unique
|
|
85
|
+
* in neither, and two checkouts sitting at the same commit on a same-named
|
|
86
|
+
* branch would compare as "no drift" while being different working trees.
|
|
87
|
+
*/
|
|
88
|
+
export function collectRepoAnchor(root: string): RepoAnchor {
|
|
89
|
+
const remote = git(root, ['config', '--get', 'remote.origin.url']);
|
|
90
|
+
let identity: string | null = null;
|
|
91
|
+
if (remote) {
|
|
92
|
+
identity = canonicalizeRepoIdentity(remote);
|
|
93
|
+
} else {
|
|
94
|
+
const common = git_common_dir(root);
|
|
95
|
+
if (common !== null) {
|
|
96
|
+
try {
|
|
97
|
+
identity = fs.realpathSync(common);
|
|
98
|
+
} catch {
|
|
99
|
+
identity = common;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
repo_identity: identity ? identity.slice(0, MAX_FIELD_CHARS) : null,
|
|
105
|
+
branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
106
|
+
head: git(root, ['rev-parse', 'HEAD']),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The scripted fields the model never composes. */
|
|
111
|
+
export interface Grounding extends RepoAnchor {
|
|
112
|
+
uncommitted_paths: string[];
|
|
113
|
+
status_summary: string | null;
|
|
114
|
+
last_verify: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const VERIFY_STATE_REL = path.join('agents', 'runtime', 'state', 'verify-before-complete.json');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The last verification this workspace recorded, as one line.
|
|
121
|
+
*
|
|
122
|
+
* **What this is not:** an exit code. The roadmap step asked for "last verify
|
|
123
|
+
* exit", and nothing in the tree records one — `before_complete_hook` stores
|
|
124
|
+
* `{command, tool, at, …}` and no status. Emitting a fabricated exit would be
|
|
125
|
+
* worse than emitting less, so the field carries the command and its
|
|
126
|
+
* timestamp and says so, rather than implying a pass/fail the source never
|
|
127
|
+
* held.
|
|
128
|
+
*/
|
|
129
|
+
export function readLastVerify(root: string): string | null {
|
|
130
|
+
try {
|
|
131
|
+
const raw = fs.readFileSync(path.join(root, VERIFY_STATE_REL), 'utf-8');
|
|
132
|
+
const state = JSON.parse(raw) as Record<string, unknown>;
|
|
133
|
+
const lv = state['last_verification'];
|
|
134
|
+
if (typeof lv !== 'object' || lv === null || Array.isArray(lv)) return null;
|
|
135
|
+
const rec = lv as Record<string, unknown>;
|
|
136
|
+
const command = typeof rec['command'] === 'string' ? rec['command'] : null;
|
|
137
|
+
if (!command) return null;
|
|
138
|
+
const at = typeof rec['at'] === 'string' ? rec['at'] : 'unknown time';
|
|
139
|
+
return `${command} @ ${at} (no exit status is recorded anywhere)`.slice(0, MAX_FIELD_CHARS);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Collect every scripted field. Never throws — an unreadable fact is `null`. */
|
|
146
|
+
export function collectGrounding(root: string): Grounding {
|
|
147
|
+
const anchor = collectRepoAnchor(root);
|
|
148
|
+
const porcelain = git(root, ['status', '--porcelain']);
|
|
149
|
+
if (porcelain === null) {
|
|
150
|
+
// A FAILED read is not a clean tree. Collapsing the two would make
|
|
151
|
+
// this the one field in the module that asserts something it never
|
|
152
|
+
// read — and it would assert it exactly when every sibling field is
|
|
153
|
+
// null, so `describeDrift` is silent and nothing challenges it.
|
|
154
|
+
return { ...anchor, uncommitted_paths: [], status_summary: null, last_verify: readLastVerify(root) };
|
|
155
|
+
}
|
|
156
|
+
const lines = porcelain.split('\n').filter((l) => l.trim().length > 0);
|
|
157
|
+
const paths = lines
|
|
158
|
+
.map((l) => l.slice(3).trim())
|
|
159
|
+
.filter((p) => p.length > 0 && p.length <= MAX_FIELD_CHARS)
|
|
160
|
+
.slice(0, MAX_PATHS);
|
|
161
|
+
return {
|
|
162
|
+
...anchor,
|
|
163
|
+
uncommitted_paths: paths,
|
|
164
|
+
status_summary:
|
|
165
|
+
lines.length === 0
|
|
166
|
+
? 'clean working tree'
|
|
167
|
+
: `${lines.length} uncommitted path(s)${lines.length > MAX_PATHS ? `, first ${MAX_PATHS} listed` : ''}`,
|
|
168
|
+
last_verify: readLastVerify(root),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compare an envelope's recorded anchor against the tree it is being injected
|
|
174
|
+
* into. Returns one line per mismatch — the consumer LEADS the injected block
|
|
175
|
+
* with them, so a stale resume can never be silent.
|
|
176
|
+
*
|
|
177
|
+
* A field the envelope never recorded produces no line: an absent anchor is
|
|
178
|
+
* an older envelope, not evidence of drift.
|
|
179
|
+
*/
|
|
180
|
+
export function describeDrift(envelope: unknown, actual: RepoAnchor): string[] {
|
|
181
|
+
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) return [];
|
|
182
|
+
const e = envelope as Record<string, unknown>;
|
|
183
|
+
const drift: string[] = [];
|
|
184
|
+
const recordedIdentity = typeof e['repo_identity'] === 'string' ? e['repo_identity'] : null;
|
|
185
|
+
if (recordedIdentity && actual.repo_identity && recordedIdentity !== actual.repo_identity) {
|
|
186
|
+
drift.push(
|
|
187
|
+
`IDENTITY DRIFT: this envelope was written in "${recordedIdentity}", you are in ` +
|
|
188
|
+
`"${actual.repo_identity}". Re-verify every path before acting — same branch name ` +
|
|
189
|
+
`does not mean same working tree.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const recordedBranch = typeof e['branch'] === 'string' ? e['branch'] : null;
|
|
193
|
+
if (recordedBranch && actual.branch && recordedBranch !== actual.branch) {
|
|
194
|
+
drift.push(
|
|
195
|
+
`BRANCH DRIFT: envelope written on "${recordedBranch}", checkout is on "${actual.branch}".`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const recordedHead = typeof e['head'] === 'string' ? e['head'] : null;
|
|
199
|
+
if (recordedHead && actual.head && recordedHead !== actual.head) {
|
|
200
|
+
drift.push(
|
|
201
|
+
`COMMIT DRIFT: envelope written at ${recordedHead.slice(0, 12)}, HEAD is now ` +
|
|
202
|
+
`${actual.head.slice(0, 12)} — re-read anything the envelope quotes.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return drift;
|
|
206
|
+
}
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
* parameter stays for back-compat (tests, and any caller that genuinely has a
|
|
32
32
|
* host-knowledge object of its own) — no production caller passes a
|
|
33
33
|
* settings-derived value into it any more.
|
|
34
|
+
*
|
|
35
|
+
* `describeHostCapabilities` (road-to-capability-answerability Phase 1.2) is
|
|
36
|
+
* the same resolution plus per-field provenance, because five of the six
|
|
37
|
+
* fields come from a committed table rather than from any live check and the
|
|
38
|
+
* `probe*` name says otherwise.
|
|
34
39
|
*/
|
|
35
40
|
import process from 'node:process';
|
|
36
41
|
|
|
@@ -197,3 +202,77 @@ export function probeHostCapabilities(hostId: string | null | undefined): HostCa
|
|
|
197
202
|
const probedAgentTeams = typeof flag === 'string' && flag !== '';
|
|
198
203
|
return { ...base, agent_teams: base.agent_teams || probedAgentTeams };
|
|
199
204
|
}
|
|
205
|
+
|
|
206
|
+
/** Every manifest field that carries a capability answer (i.e. not `schema_version`). */
|
|
207
|
+
export type CapabilityField = Exclude<keyof HostCapabilityManifest, 'schema_version'>;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Where one field's resolved value came from.
|
|
211
|
+
*
|
|
212
|
+
* `registry` is deliberately NOT called "detected": a registry row is a
|
|
213
|
+
* capability this repo OBSERVED on that host once and then committed to a
|
|
214
|
+
* hardcoded table. It is an assertion about the host, checked at authoring
|
|
215
|
+
* time and never re-checked at run time. `live-probe` is the only value that
|
|
216
|
+
* was established in THIS process, from the environment. `default` means
|
|
217
|
+
* nothing answered and the all-false safe default applied.
|
|
218
|
+
*/
|
|
219
|
+
export type CapabilitySource = 'registry' | 'live-probe' | 'default';
|
|
220
|
+
|
|
221
|
+
/** Per-field provenance for a resolved manifest. */
|
|
222
|
+
export type HostCapabilitySources = Record<CapabilityField, CapabilitySource>;
|
|
223
|
+
|
|
224
|
+
export interface HostCapabilityDescription {
|
|
225
|
+
manifest: HostCapabilityManifest;
|
|
226
|
+
sources: HostCapabilitySources;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const CAPABILITY_FIELDS: readonly CapabilityField[] = [
|
|
230
|
+
'subagent_spawn',
|
|
231
|
+
'parallel_spawn',
|
|
232
|
+
'status_polling',
|
|
233
|
+
'separate_quota_pool',
|
|
234
|
+
'agent_teams',
|
|
235
|
+
'worker_respawn',
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the manifest AND say, per field, what answered it.
|
|
240
|
+
*
|
|
241
|
+
* `probeHostCapabilities` returns six booleans that look alike and are not:
|
|
242
|
+
* on the one host with a registry row, two of them come from a committed
|
|
243
|
+
* table, one can come from a live environment read, and the rest are the
|
|
244
|
+
* safe default — i.e. "we have no answer", rendered as `false`, which is
|
|
245
|
+
* indistinguishable from "we checked and it is absent". A caller deciding
|
|
246
|
+
* whether to trust `subagent_spawn: false` needs that difference, and the
|
|
247
|
+
* function name `probe*` actively suggests the wrong one.
|
|
248
|
+
*
|
|
249
|
+
* The manifest half delegates to {@link probeHostCapabilities} rather than
|
|
250
|
+
* re-deriving it, so a provenance readout can never disagree with the value
|
|
251
|
+
* the delegation layer actually gated on — the same
|
|
252
|
+
* two-readers-of-one-fact failure `routing_doctor` already records against
|
|
253
|
+
* its own pre-registry bug.
|
|
254
|
+
*/
|
|
255
|
+
export function describeHostCapabilities(
|
|
256
|
+
hostId: string | null | undefined,
|
|
257
|
+
): HostCapabilityDescription {
|
|
258
|
+
const manifest = probeHostCapabilities(hostId);
|
|
259
|
+
const row =
|
|
260
|
+
hostId !== null && hostId !== undefined ? HOST_CAPABILITY_REGISTRY[hostId] : undefined;
|
|
261
|
+
const flag = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
262
|
+
const probedAgentTeams = typeof flag === 'string' && flag !== '';
|
|
263
|
+
|
|
264
|
+
const sources = {} as HostCapabilitySources;
|
|
265
|
+
for (const field of CAPABILITY_FIELDS) {
|
|
266
|
+
// A row entry claims the field whatever its value: an explicit
|
|
267
|
+
// `false` in the table is an observation ("checked, absent"), which is
|
|
268
|
+
// a different fact from the default's "nobody answered".
|
|
269
|
+
if (row !== undefined && typeof row[field] === 'boolean') {
|
|
270
|
+
sources[field] = 'registry';
|
|
271
|
+
} else if (field === 'agent_teams' && probedAgentTeams) {
|
|
272
|
+
sources[field] = 'live-probe';
|
|
273
|
+
} else {
|
|
274
|
+
sources[field] = 'default';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { manifest, sources };
|
|
278
|
+
}
|