@event4u/agent-config 9.16.0 → 9.17.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/AGENTS.md +1 -1
- package/CHANGELOG.md +53 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/agent-handoff.md +100 -48
- package/dist/agent-src/commands/commit.md +1 -1
- package/dist/agent-src/commands/condense.md +19 -64
- package/dist/agent-src/commands/evals/agent-handoff.json +13 -1
- package/dist/agent-src/commands/memory/promote.md +14 -1
- package/dist/agent-src/commands/mode.md +9 -5
- package/dist/agent-src/commands/optimize/deep.md +72 -11
- package/dist/agent-src/contexts/authority/commit-mechanics.md +5 -5
- package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
- package/dist/agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +1 -1
- package/dist/agent-src/contexts/communication/rules-auto/think-before-action-mechanics.md +6 -1
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +4 -1
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +27 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +5 -2
- package/dist/agent-src/profiles/agency.yml +3 -3
- package/dist/agent-src/profiles/content_creator.yml +2 -2
- package/dist/agent-src/profiles/developer.yml +2 -2
- package/dist/agent-src/profiles/finance.yml +1 -1
- package/dist/agent-src/profiles/founder.yml +2 -2
- package/dist/agent-src/profiles/ops.yml +2 -2
- package/dist/agent-src/rules/architecture.md +2 -2
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/context-hygiene.md +3 -2
- package/dist/agent-src/rules/domain-safety-pii.md +1 -1
- package/dist/agent-src/rules/output-discipline.md +9 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/rules/user-interaction.md +3 -4
- package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -1
- package/dist/agent-src/skills/copilot-agents-optimization/SKILL.md +4 -4
- package/dist/agent-src/skills/copilot-config/SKILL.md +1 -1
- package/dist/agent-src/skills/deep-reading-analyst/SKILL.md +7 -4
- package/dist/agent-src/skills/description-assist/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
- package/dist/agent-src/skills/market-entry-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-writing/SKILL.md +2 -2
- package/dist/agent-src/skills/readme-reviewer/SKILL.md +2 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +4 -2
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +2 -2
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +3 -1
- package/dist/agent-src/skills/token-optimizer/SKILL.md +2 -2
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +6 -3
- package/dist/agent-src/templates/agent-settings.md +13 -15
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +6 -2
- package/dist/agent-src/templates/persona.md +2 -2
- package/dist/agent-src/templates/roadmaps.md +1 -1
- package/dist/agent-src/templates/rule.md +1 -1
- package/dist/agent-src/templates/skill.md +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-ZXXJLL5K.js → chunk-BSLUHV2F.js} +127 -1487
- package/dist/cli-delegate/{chunk-H574KOUH.js → chunk-IMSXPRVH.js} +24 -1
- package/dist/cli-delegate/chunk-VSYT7OHO.js +1382 -0
- package/dist/cli-delegate/chunk-XGA2NTU4.js +3727 -0
- package/dist/cli-delegate/cmd_conformance.js +4 -3
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +3194 -0
- package/dist/cli-delegate/cmd_memory_get.js +35 -3754
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +31 -31
- 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 +602 -507
- package/dist/install/install.mjs +23 -0
- package/dist/mcp/awesome-mcp-servers.row.md +1 -1
- package/dist/mcp/mcp-cloudflare-catalogue.json +2 -2
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +25 -3
- package/dist/scripts/_lib/agent_src.js +39 -6
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/server/schemas/settings.js +13 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/guidelines/abstraction-thresholds.md +53 -0
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -1
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +7 -0
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +2 -2
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +5 -3
- package/docs/guidelines/agent-infra/size-and-scope.md +8 -2
- package/docs/guidelines/component-oriented-and-oop-development.md +11 -6
- package/docs/guidelines/docs/readme-size-and-splitting.md +5 -2
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/config/agent-settings.template.yml +22 -0
- package/src/config/evaluator-budgets.json +8 -11
- package/src/config/gate-coverage.yml +30 -0
- package/src/scripts/_cli/cmd_handoff.ts +405 -0
- package/src/scripts/_cli/handoff_generate.ts +453 -0
- package/src/scripts/_cli/handoff_sessions.ts +435 -0
- package/src/scripts/_dispatch.bash +9 -0
- package/src/scripts/_lib/agent_src.ts +36 -6
- package/src/scripts/_lib/release_material.ts +156 -0
- package/src/scripts/check_condensation.ts +2 -1
- package/src/scripts/check_finding_dispositions.ts +278 -0
- package/src/scripts/check_pack_size.ts +4 -3
- package/src/scripts/check_references.ts +1 -0
- package/src/scripts/check_release_highlights.ts +300 -0
- package/src/scripts/check_release_pr_shape.ts +4 -0
- package/src/scripts/check_release_surface_equality.ts +132 -0
- package/src/scripts/check_reply_consistency.ts +187 -138
- package/src/scripts/handoff_context_hook.ts +148 -0
- package/src/scripts/hook_manifest.yaml +17 -7
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/lint_abstraction_thresholds.ts +325 -0
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_profile_personas.ts +153 -0
- package/src/scripts/lint_token_budget_discipline.ts +148 -0
- package/src/scripts/release.ts +156 -32
- package/src/scripts/release_drill.ts +491 -0
- package/src/scripts/schemas/command.schema.json +33 -0
- package/src/scripts/self_review_gate.ts +41 -3
- package/src/scripts/skill_linter.ts +3 -1
- package/src/scripts/smoke/router.sh +1 -1
- package/src/scripts/update_counts.ts +60 -12
- package/src/server/schemas/settings.ts +23 -0
- package/dist/cli-delegate/{chunk-XX6TOPD7.js → chunk-WR6GTRHJ.js} +3 -3
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session enumeration for `agent-config handoff` (road-to-agent-handoff-
|
|
3
|
+
* resume Phase 1). Pure library — no CLI entry, no prompts, no writes.
|
|
4
|
+
*
|
|
5
|
+
* Sources, merged newest-first into one list:
|
|
6
|
+
* - `chat-history` — the cross-host JSONL log (primary; works for
|
|
7
|
+
* every hook-capable host). Reuses `chat_history.list_sessions()`.
|
|
8
|
+
* - `claude-transcript` — native Claude Code transcripts under
|
|
9
|
+
* `~/.claude/projects/<slug>/*.jsonl` (fallback for sessions the log
|
|
10
|
+
* missed; slug = cwd with `/` and `.` mapped to `-`).
|
|
11
|
+
* - `codex-session` — `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
|
|
12
|
+
* (Codex's ONLY source — a bundle host without hooks never writes
|
|
13
|
+
* chat-history). Format verified 2026-08-04 against the live store:
|
|
14
|
+
* line 0 is `{type:'session_meta', payload:{id, timestamp, cwd,
|
|
15
|
+
* git:{branch}}}`; user prompts arrive as `event_msg` records with
|
|
16
|
+
* `payload:{type:'user_message', message}` (fixture:
|
|
17
|
+
* tests/scripts/fixtures/handoff/codex-session.jsonl).
|
|
18
|
+
*
|
|
19
|
+
* De-dup: native-store sessions whose platform id maps (via
|
|
20
|
+
* `derive_session_tag`) to a chat-history bucket are dropped — the
|
|
21
|
+
* chat-history entry wins.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from 'node:fs';
|
|
25
|
+
import * as os from 'node:os';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { derive_session_tag, list_sessions, read_entries } from '../chat_history.js';
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_CAP = 15;
|
|
31
|
+
const SUMMARY_SNIPPET_CHARS = 120;
|
|
32
|
+
|
|
33
|
+
export type HandoffSource = 'chat-history' | 'claude-transcript' | 'codex-session';
|
|
34
|
+
|
|
35
|
+
export interface HandoffSession {
|
|
36
|
+
id: string;
|
|
37
|
+
source: HandoffSource;
|
|
38
|
+
startedAt: string | null;
|
|
39
|
+
endedAt: string | null;
|
|
40
|
+
branch?: string;
|
|
41
|
+
entryCount: number;
|
|
42
|
+
summary: string;
|
|
43
|
+
/** Absolute transcript path — set for the native-store sources. */
|
|
44
|
+
transcriptPath?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ListOptions {
|
|
48
|
+
/** Repo root the sessions must belong to. Default: process.cwd(). */
|
|
49
|
+
cwd?: string;
|
|
50
|
+
/** Chat-history JSONL override (else AGENT_CHAT_HISTORY_FILE / default). */
|
|
51
|
+
chatHistoryPath?: string | null;
|
|
52
|
+
/** Claude Code projects root. Default: ~/.claude/projects. */
|
|
53
|
+
claudeProjectsRoot?: string;
|
|
54
|
+
/** Codex sessions root. Default: ~/.codex/sessions. */
|
|
55
|
+
codexSessionsRoot?: string;
|
|
56
|
+
/** Max sessions per source AND in the merged result. Default 15. */
|
|
57
|
+
cap?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mirrors the Claude Code project-dir naming: `/` and `.` → `-`. */
|
|
61
|
+
export function encode_project_path(cwd: string): string {
|
|
62
|
+
return cwd.replace(/[/.]/g, '-');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _snippet(text: string, max: number = SUMMARY_SNIPPET_CHARS): string {
|
|
66
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
67
|
+
return oneLine.length <= max ? oneLine : oneLine.slice(0, max - 1) + '…';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** first-prompt → last-prompt summary (Step 3 contract). */
|
|
71
|
+
function _summarize(first: string | null, last: string | null): string {
|
|
72
|
+
const a = first ? _snippet(first) : '';
|
|
73
|
+
const b = last ? _snippet(last) : '';
|
|
74
|
+
if (a && b && a !== b) return `${a} → ${b}`;
|
|
75
|
+
return a || b || '(no user prompts)';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _read_lines(file: string): string[] {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(file, 'utf-8').split('\n');
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _parse_json(line: string): Record<string, unknown> | null {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed) return null;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
91
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
92
|
+
return parsed as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// fail-open — skip malformed lines
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------
|
|
101
|
+
// chat-history (primary)
|
|
102
|
+
// ---------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const CHAT_HISTORY_REL = path.join('agents', 'runtime', '.agent-chat-history');
|
|
105
|
+
|
|
106
|
+
/** Explicit option > AGENT_CHAT_HISTORY_FILE env > repo-root-relative default. */
|
|
107
|
+
function _chat_history_path(opts: ListOptions): string {
|
|
108
|
+
if (opts.chatHistoryPath) return opts.chatHistoryPath;
|
|
109
|
+
return (
|
|
110
|
+
process.env.AGENT_CHAT_HISTORY_FILE ||
|
|
111
|
+
path.join(opts.cwd ?? process.cwd(), CHAT_HISTORY_REL)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Real-prompt summaries per session: hosts log system/task notifications as
|
|
117
|
+
* `t:'user'` entries, so the raw bucket summary can read like
|
|
118
|
+
* `<task-notification>…` — filtered here (council review 2026-08-04,
|
|
119
|
+
* claude-sonnet-4-5 + gpt-4o: picker noise is a UX defect; the log itself
|
|
120
|
+
* stays unfiltered).
|
|
121
|
+
*/
|
|
122
|
+
function _chat_history_summaries(histPath: string): Map<string, { first: string | null; last: string | null }> {
|
|
123
|
+
const bySession = new Map<string, { first: string | null; last: string | null }>();
|
|
124
|
+
let entries: Array<Record<string, unknown>>;
|
|
125
|
+
try {
|
|
126
|
+
entries = read_entries({ path: histPath, last: null });
|
|
127
|
+
} catch {
|
|
128
|
+
return bySession;
|
|
129
|
+
}
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.t !== 'user' || typeof e.text !== 'string') continue;
|
|
132
|
+
const text = e.text.trim();
|
|
133
|
+
if (!text || text.startsWith('<') || text.startsWith('Caveat:')) continue;
|
|
134
|
+
const sid = String(e.s ?? '<legacy>');
|
|
135
|
+
const slot = bySession.get(sid) ?? { first: null, last: null };
|
|
136
|
+
if (!slot.first) slot.first = text;
|
|
137
|
+
slot.last = text;
|
|
138
|
+
bySession.set(sid, slot);
|
|
139
|
+
}
|
|
140
|
+
return bySession;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _chat_history_sessions(opts: ListOptions): HandoffSession[] {
|
|
144
|
+
const histPath = _chat_history_path(opts);
|
|
145
|
+
let buckets: Array<Record<string, unknown>>;
|
|
146
|
+
try {
|
|
147
|
+
buckets = list_sessions({ path: histPath, summary: true });
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const summaries = _chat_history_summaries(histPath);
|
|
152
|
+
return buckets
|
|
153
|
+
.filter((b) => Number(b.count ?? 0) > 0)
|
|
154
|
+
.map((b) => {
|
|
155
|
+
const id = String(b.id ?? '');
|
|
156
|
+
const prompts = summaries.get(id);
|
|
157
|
+
const summary = prompts
|
|
158
|
+
? _summarize(prompts.first, prompts.last)
|
|
159
|
+
: '(no user prompts)';
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
source: 'chat-history' as const,
|
|
163
|
+
startedAt: b.first_ts ? String(b.first_ts) : null,
|
|
164
|
+
endedAt: b.last_ts ? String(b.last_ts) : null,
|
|
165
|
+
entryCount: Number(b.count ?? 0),
|
|
166
|
+
summary,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------
|
|
172
|
+
// claude-transcript (native fallback)
|
|
173
|
+
// ---------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
interface TranscriptScan {
|
|
176
|
+
entryCount: number;
|
|
177
|
+
firstTs: string | null;
|
|
178
|
+
lastTs: string | null;
|
|
179
|
+
branch: string | null;
|
|
180
|
+
firstPrompt: string | null;
|
|
181
|
+
lastPrompt: string | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A real prompt has string content; tool_results arrive as block lists. */
|
|
185
|
+
function _cc_prompt_text(rec: Record<string, unknown>): string | null {
|
|
186
|
+
if (rec.type !== 'user' || rec.isSidechain === true) return null;
|
|
187
|
+
const msg = rec.message;
|
|
188
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null;
|
|
189
|
+
const content = (msg as Record<string, unknown>).content;
|
|
190
|
+
if (typeof content !== 'string') return null;
|
|
191
|
+
const trimmed = content.trim();
|
|
192
|
+
// skip host-injected meta blocks (caveats, command wrappers)
|
|
193
|
+
if (!trimmed || trimmed.startsWith('<') || trimmed.startsWith('Caveat:')) return null;
|
|
194
|
+
return trimmed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _scan_claude_transcript(file: string): TranscriptScan {
|
|
198
|
+
const scan: TranscriptScan = {
|
|
199
|
+
entryCount: 0,
|
|
200
|
+
firstTs: null,
|
|
201
|
+
lastTs: null,
|
|
202
|
+
branch: null,
|
|
203
|
+
firstPrompt: null,
|
|
204
|
+
lastPrompt: null,
|
|
205
|
+
};
|
|
206
|
+
for (const line of _read_lines(file)) {
|
|
207
|
+
const rec = _parse_json(line);
|
|
208
|
+
if (!rec) continue;
|
|
209
|
+
scan.entryCount += 1;
|
|
210
|
+
const ts = typeof rec.timestamp === 'string' ? rec.timestamp : null;
|
|
211
|
+
if (ts) {
|
|
212
|
+
if (!scan.firstTs) scan.firstTs = ts;
|
|
213
|
+
scan.lastTs = ts;
|
|
214
|
+
}
|
|
215
|
+
if (!scan.branch && typeof rec.gitBranch === 'string' && rec.gitBranch) {
|
|
216
|
+
scan.branch = rec.gitBranch;
|
|
217
|
+
}
|
|
218
|
+
const prompt = _cc_prompt_text(rec);
|
|
219
|
+
if (prompt) {
|
|
220
|
+
if (!scan.firstPrompt) scan.firstPrompt = prompt;
|
|
221
|
+
scan.lastPrompt = prompt;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return scan;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _claude_transcript_sessions(opts: ListOptions, cap: number): HandoffSession[] {
|
|
228
|
+
const root = opts.claudeProjectsRoot ?? path.join(os.homedir(), '.claude', 'projects');
|
|
229
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
230
|
+
const projectDir = path.join(root, encode_project_path(cwd));
|
|
231
|
+
let names: string[];
|
|
232
|
+
try {
|
|
233
|
+
names = fs.readdirSync(projectDir).filter((n) => n.endsWith('.jsonl'));
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const files = names
|
|
238
|
+
.map((n) => {
|
|
239
|
+
const full = path.join(projectDir, n);
|
|
240
|
+
try {
|
|
241
|
+
return { full, id: path.basename(n, '.jsonl'), mtime: fs.statSync(full).mtimeMs };
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
.filter((f): f is { full: string; id: string; mtime: number } => f !== null)
|
|
247
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
248
|
+
.slice(0, cap);
|
|
249
|
+
|
|
250
|
+
const sessions: HandoffSession[] = [];
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
const scan = _scan_claude_transcript(f.full);
|
|
253
|
+
if (scan.entryCount === 0) continue;
|
|
254
|
+
sessions.push({
|
|
255
|
+
id: f.id,
|
|
256
|
+
source: 'claude-transcript',
|
|
257
|
+
startedAt: scan.firstTs,
|
|
258
|
+
endedAt: scan.lastTs,
|
|
259
|
+
...(scan.branch ? { branch: scan.branch } : {}),
|
|
260
|
+
entryCount: scan.entryCount,
|
|
261
|
+
summary: _summarize(scan.firstPrompt, scan.lastPrompt),
|
|
262
|
+
transcriptPath: f.full,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return sessions;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------
|
|
269
|
+
// codex-session (native adapter — Codex's only source)
|
|
270
|
+
// ---------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
interface CodexMeta {
|
|
273
|
+
id: string;
|
|
274
|
+
timestamp: string | null;
|
|
275
|
+
cwd: string;
|
|
276
|
+
branch: string | null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _codex_meta(file: string): CodexMeta | null {
|
|
280
|
+
let firstLine = '';
|
|
281
|
+
try {
|
|
282
|
+
// metadata is always line 0 — never read the whole file here
|
|
283
|
+
const fd = fs.openSync(file, 'r');
|
|
284
|
+
try {
|
|
285
|
+
const buf = Buffer.alloc(8192);
|
|
286
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
287
|
+
firstLine = buf.toString('utf-8', 0, n).split('\n')[0] ?? '';
|
|
288
|
+
} finally {
|
|
289
|
+
fs.closeSync(fd);
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const rec = _parse_json(firstLine);
|
|
295
|
+
if (!rec || rec.type !== 'session_meta') return null;
|
|
296
|
+
const payload = rec.payload;
|
|
297
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
298
|
+
const p = payload as Record<string, unknown>;
|
|
299
|
+
const git =
|
|
300
|
+
p.git && typeof p.git === 'object' && !Array.isArray(p.git)
|
|
301
|
+
? (p.git as Record<string, unknown>)
|
|
302
|
+
: {};
|
|
303
|
+
return {
|
|
304
|
+
id: String(p.id ?? ''),
|
|
305
|
+
timestamp: typeof p.timestamp === 'string' ? p.timestamp : null,
|
|
306
|
+
cwd: String(p.cwd ?? ''),
|
|
307
|
+
branch: typeof git.branch === 'string' && git.branch ? git.branch : null,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _codex_files(root: string): string[] {
|
|
312
|
+
const out: string[] = [];
|
|
313
|
+
const stack = [root];
|
|
314
|
+
while (stack.length > 0) {
|
|
315
|
+
const dir = stack.pop() as string;
|
|
316
|
+
let entries: fs.Dirent[];
|
|
317
|
+
try {
|
|
318
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
for (const e of entries) {
|
|
323
|
+
const full = path.join(dir, e.name);
|
|
324
|
+
if (e.isDirectory()) stack.push(full);
|
|
325
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// rollout filenames embed the timestamp — lexicographic basename sort
|
|
329
|
+
// is chronological; newest first
|
|
330
|
+
return out.sort((a, b) => (path.basename(a) < path.basename(b) ? 1 : -1));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface CodexScan {
|
|
334
|
+
entryCount: number;
|
|
335
|
+
lastTs: string | null;
|
|
336
|
+
firstPrompt: string | null;
|
|
337
|
+
lastPrompt: string | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function _codex_prompt_text(rec: Record<string, unknown>): string | null {
|
|
341
|
+
if (rec.type !== 'event_msg') return null;
|
|
342
|
+
const p = rec.payload;
|
|
343
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
|
|
344
|
+
const payload = p as Record<string, unknown>;
|
|
345
|
+
if (payload.type !== 'user_message') return null;
|
|
346
|
+
const msg = typeof payload.message === 'string' ? payload.message.trim() : '';
|
|
347
|
+
if (!msg || msg.startsWith('<')) return null; // skip injected meta blocks
|
|
348
|
+
return msg;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _scan_codex_session(file: string): CodexScan {
|
|
352
|
+
const scan: CodexScan = { entryCount: 0, lastTs: null, firstPrompt: null, lastPrompt: null };
|
|
353
|
+
for (const line of _read_lines(file)) {
|
|
354
|
+
const rec = _parse_json(line);
|
|
355
|
+
if (!rec) continue;
|
|
356
|
+
scan.entryCount += 1;
|
|
357
|
+
if (typeof rec.timestamp === 'string') scan.lastTs = rec.timestamp;
|
|
358
|
+
const prompt = _codex_prompt_text(rec);
|
|
359
|
+
if (prompt) {
|
|
360
|
+
if (!scan.firstPrompt) scan.firstPrompt = prompt;
|
|
361
|
+
scan.lastPrompt = prompt;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return scan;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function _matches_repo(sessionCwd: string, repoCwd: string): boolean {
|
|
368
|
+
const norm = (p: string): string => path.resolve(p).replace(/\/+$/, '');
|
|
369
|
+
const a = norm(sessionCwd);
|
|
370
|
+
const b = norm(repoCwd);
|
|
371
|
+
return a === b || a.startsWith(b + path.sep);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function _codex_sessions(opts: ListOptions, cap: number): HandoffSession[] {
|
|
375
|
+
const root = opts.codexSessionsRoot ?? path.join(os.homedir(), '.codex', 'sessions');
|
|
376
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
377
|
+
const matching: Array<{ file: string; meta: CodexMeta }> = [];
|
|
378
|
+
for (const file of _codex_files(root)) {
|
|
379
|
+
if (matching.length >= cap) break;
|
|
380
|
+
const meta = _codex_meta(file); // line-0 read only — cheap per file
|
|
381
|
+
if (!meta || !meta.id) continue;
|
|
382
|
+
if (!_matches_repo(meta.cwd, cwd)) continue;
|
|
383
|
+
matching.push({ file, meta });
|
|
384
|
+
}
|
|
385
|
+
const sessions: HandoffSession[] = [];
|
|
386
|
+
for (const { file, meta } of matching) {
|
|
387
|
+
const scan = _scan_codex_session(file);
|
|
388
|
+
if (scan.entryCount === 0) continue;
|
|
389
|
+
sessions.push({
|
|
390
|
+
id: meta.id,
|
|
391
|
+
source: 'codex-session',
|
|
392
|
+
startedAt: meta.timestamp,
|
|
393
|
+
endedAt: scan.lastTs ?? meta.timestamp,
|
|
394
|
+
...(meta.branch ? { branch: meta.branch } : {}),
|
|
395
|
+
entryCount: scan.entryCount,
|
|
396
|
+
summary: _summarize(scan.firstPrompt, scan.lastPrompt),
|
|
397
|
+
transcriptPath: file,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return sessions;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------
|
|
404
|
+
// merge
|
|
405
|
+
// ---------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
function _sort_key(s: HandoffSession): string {
|
|
408
|
+
return s.endedAt ?? s.startedAt ?? '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Unified newest-first session list. Native-store sessions whose platform
|
|
413
|
+
* id maps to an existing chat-history bucket are dropped (chat-history is
|
|
414
|
+
* the primary source).
|
|
415
|
+
*/
|
|
416
|
+
export function list_handoff_sessions(opts: ListOptions = {}): HandoffSession[] {
|
|
417
|
+
const cap = opts.cap ?? DEFAULT_CAP;
|
|
418
|
+
const primary = _chat_history_sessions(opts);
|
|
419
|
+
const knownTags = new Set(primary.map((s) => s.id));
|
|
420
|
+
|
|
421
|
+
const native = [
|
|
422
|
+
..._claude_transcript_sessions(opts, cap),
|
|
423
|
+
..._codex_sessions(opts, cap),
|
|
424
|
+
].filter((s) => {
|
|
425
|
+
try {
|
|
426
|
+
return !knownTags.has(derive_session_tag(s.id));
|
|
427
|
+
} catch {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return [...primary, ...native]
|
|
433
|
+
.sort((a, b) => (_sort_key(a) < _sort_key(b) ? 1 : _sort_key(a) > _sort_key(b) ? -1 : 0))
|
|
434
|
+
.slice(0, cap);
|
|
435
|
+
}
|
|
@@ -257,6 +257,7 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
257
257
|
telemetry:status Print artefact-engagement telemetry status (read-only)
|
|
258
258
|
telemetry:report Aggregate the engagement log into a quartile report
|
|
259
259
|
analyze-session Read-only post-session report from on-disk runtime state
|
|
260
|
+
handoff Pick a recent session, generate a handoff, seed a fresh session
|
|
260
261
|
EOF
|
|
261
262
|
fi
|
|
262
263
|
|
|
@@ -1182,6 +1183,13 @@ cmd_analyze_session() {
|
|
|
1182
1183
|
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_analyze_session.ts" "$@"
|
|
1183
1184
|
}
|
|
1184
1185
|
|
|
1186
|
+
# `agent-config handoff` — pick a recent session, generate a deterministic
|
|
1187
|
+
# handoff into agents/runtime/state/handoff-context.md, optionally --launch a
|
|
1188
|
+
# fresh host session. See scripts/_cli/cmd_handoff.ts.
|
|
1189
|
+
cmd_handoff() {
|
|
1190
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_handoff.ts" "$@"
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1185
1193
|
main() {
|
|
1186
1194
|
local cmd="${1-}"
|
|
1187
1195
|
[[ $# -gt 0 ]] && shift || true
|
|
@@ -1255,6 +1263,7 @@ main() {
|
|
|
1255
1263
|
versions) cmd_versions "$@" ;;
|
|
1256
1264
|
explain) cmd_explain "$@" ;;
|
|
1257
1265
|
analyze-session) cmd_analyze_session "$@" ;;
|
|
1266
|
+
handoff) cmd_handoff "$@" ;;
|
|
1258
1267
|
help|--help|-h|"")
|
|
1259
1268
|
# Optional `--tier=0|1|all` filter (default 0).
|
|
1260
1269
|
local tier_arg="0"
|
|
@@ -179,6 +179,27 @@ function _iterdirSorted(p: string): string[] {
|
|
|
179
179
|
function _rglobSorted(root: string, pattern: string): string[] {
|
|
180
180
|
const out: string[] = [];
|
|
181
181
|
const suffix = pattern === '*' ? null : pattern.slice(1); // drop leading '*'
|
|
182
|
+
// Symlink confinement: every symlink (leaf or directory) must resolve
|
|
183
|
+
// inside the walk root, or it is ignored. A visited-realpath set bounds
|
|
184
|
+
// symlink cycles, and a broken symlink (realpath throws) is skipped
|
|
185
|
+
// explicitly. Without this, a directory symlink planted under a source
|
|
186
|
+
// root walks arbitrary paths outside the package and a cycle recurses
|
|
187
|
+
// until stack exhaustion (release-truth Phase 3).
|
|
188
|
+
let rootReal: string;
|
|
189
|
+
try {
|
|
190
|
+
rootReal = fs.realpathSync(root);
|
|
191
|
+
} catch {
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
const _confined = (p: string): string | null => {
|
|
195
|
+
try {
|
|
196
|
+
const real = fs.realpathSync(p);
|
|
197
|
+
return real === rootReal || _isUnder(real, rootReal) ? real : null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null; // broken symlink → handled explicitly as "not yielded"
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const visited = new Set<string>([rootReal]);
|
|
182
203
|
const walk = (dir: string): void => {
|
|
183
204
|
let entries: fs.Dirent[];
|
|
184
205
|
try {
|
|
@@ -189,14 +210,21 @@ function _rglobSorted(root: string, pattern: string): string[] {
|
|
|
189
210
|
for (const ent of entries) {
|
|
190
211
|
const full = path.join(dir, ent.name);
|
|
191
212
|
const matches = suffix === null ? true : ent.name.endsWith(suffix);
|
|
192
|
-
if (matches) {
|
|
213
|
+
if (matches && (!ent.isSymbolicLink() || _confined(full) !== null)) {
|
|
193
214
|
out.push(full);
|
|
194
215
|
}
|
|
195
216
|
// Recurse into directories. `isDirectory()` on a Dirent does not
|
|
196
|
-
// follow symlinks; pathlib.rglob follows directory symlinks.
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
if (ent.isDirectory()
|
|
217
|
+
// follow symlinks; pathlib.rglob follows directory symlinks. Follow
|
|
218
|
+
// a directory symlink only when its target stays inside the walk
|
|
219
|
+
// root and has not been visited yet (cycle guard).
|
|
220
|
+
if (ent.isDirectory()) {
|
|
221
|
+
walk(full);
|
|
222
|
+
} else if (ent.isSymbolicLink() && _isDir(full)) {
|
|
223
|
+
const real = _confined(full);
|
|
224
|
+
if (real === null || visited.has(real)) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
visited.add(real);
|
|
200
228
|
walk(full);
|
|
201
229
|
}
|
|
202
230
|
}
|
|
@@ -432,7 +460,9 @@ export function artefact_roots(): string[] {
|
|
|
432
460
|
*
|
|
433
461
|
* Walks `_root_specs` (the leaf view). Deterministic order; deduplicated on
|
|
434
462
|
* logical path so a file present in two roots during the move window is
|
|
435
|
-
* yielded once.
|
|
463
|
+
* yielded once. Non-files are skipped; symlinks are followed only when they
|
|
464
|
+
* resolve inside their walk root (external targets, broken links, and cycles
|
|
465
|
+
* are ignored). Mirrors `iter_artefacts`.
|
|
436
466
|
*/
|
|
437
467
|
export function* iter_artefacts(suffix = '.md'): Generator<string> {
|
|
438
468
|
const seen: Set<string> = new Set();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-source release material (release-truth Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* The recorded 9.14.0 failure: PR body, CHANGELOG entry, GitHub release
|
|
5
|
+
* body, and tag metadata were generated at different times from different
|
|
6
|
+
* scopes — the PR body said 10,054 tests while the merged changelog carried
|
|
7
|
+
* 10,056 and more items. The fix is structural: the CHANGELOG section at the
|
|
8
|
+
* relevant head is the ONE final source, and every other surface is a pure
|
|
9
|
+
* derivation of it (release.ts re-derives on branch update / resume / merge,
|
|
10
|
+
* so late commits cannot desynchronize the surfaces).
|
|
11
|
+
*
|
|
12
|
+
* Everything here is a pure function of strings so the equality gate
|
|
13
|
+
* (`check_release_surface_equality.ts`) and the fixture tests exercise the
|
|
14
|
+
* exact production code paths.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ChangelogSection {
|
|
18
|
+
/** The `## …` heading line, verbatim. */
|
|
19
|
+
heading: string;
|
|
20
|
+
/** Section content below the heading, outer blank lines stripped. */
|
|
21
|
+
body: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const _NEXT_HEADING_RE = /^##\s+\[?\d+\.\d+\.\d+/m;
|
|
25
|
+
|
|
26
|
+
function _reEscape(s: string): string {
|
|
27
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract the CHANGELOG section for `version` — heading plus body up to the
|
|
32
|
+
* next version heading (or end of file). Returns null when no heading for
|
|
33
|
+
* `version` exists. Matches both heading shapes the generator emits
|
|
34
|
+
* (`## X.Y.Z (date)` and `## [X.Y.Z](compare-url) (date)`).
|
|
35
|
+
*/
|
|
36
|
+
export function extract_changelog_section(text: string, version: string): ChangelogSection | null {
|
|
37
|
+
const headingRe = new RegExp(`^##\\s+\\[?${_reEscape(version)}\\b.*$`, 'm');
|
|
38
|
+
const m = headingRe.exec(text);
|
|
39
|
+
if (!m) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const bodyStart = m.index + m[0].length;
|
|
43
|
+
const rest = text.slice(bodyStart);
|
|
44
|
+
const next = _NEXT_HEADING_RE.exec(rest);
|
|
45
|
+
const sectionEnd = bodyStart + (next ? next.index : rest.length);
|
|
46
|
+
const body = text
|
|
47
|
+
.slice(bodyStart, sectionEnd)
|
|
48
|
+
.replace(/^\n+/u, '')
|
|
49
|
+
.replace(/\s+$/u, '');
|
|
50
|
+
return { heading: m[0], body };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Footer appended to the release-PR body; the strip helper accepts legacy variants. */
|
|
54
|
+
export const PR_BODY_FOOTER = 'Created by `src/scripts/release.ts`.';
|
|
55
|
+
const _LEGACY_PR_FOOTERS = ['Created by `scripts/release.py`.', PR_BODY_FOOTER];
|
|
56
|
+
|
|
57
|
+
/** Marker `_cap_body` embeds when a surface was truncated to a GitHub limit. */
|
|
58
|
+
export const TRUNCATION_MARKER = '_Changelog truncated to fit GitHub';
|
|
59
|
+
|
|
60
|
+
/** Release-PR body derived from the (possibly capped) changelog-section body. */
|
|
61
|
+
export function pr_body_from_section(cappedBody: string, version: string): string {
|
|
62
|
+
return `Release ${version}.\n\n${cappedBody}\n\n${PR_BODY_FOOTER}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** GitHub-release notes derived from the (possibly capped) changelog-section body. */
|
|
66
|
+
export function release_notes_from_section(cappedBody: string, version: string): string {
|
|
67
|
+
return cappedBody || `Release ${version}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Annotated-tag message derived from the merged changelog-section body. */
|
|
71
|
+
export function tag_message_from_section(body: string, version: string): string {
|
|
72
|
+
const trailer = body ? `\n\n${body}` : '';
|
|
73
|
+
return `release: ${version}${trailer}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Inverse of `pr_body_from_section`: return the changelog-derived middle of a
|
|
78
|
+
* release-PR body, or null when the body does not carry the canonical
|
|
79
|
+
* `Release X.Y.Z.` prefix (then it was not generated by this pipeline).
|
|
80
|
+
*/
|
|
81
|
+
export function strip_pr_wrapper(prBody: string, version: string): string | null {
|
|
82
|
+
const prefix = `Release ${version}.`;
|
|
83
|
+
let s = prBody.replace(/\r\n/gu, '\n').trim();
|
|
84
|
+
if (!s.startsWith(prefix)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
s = s.slice(prefix.length);
|
|
88
|
+
for (const footer of _LEGACY_PR_FOOTERS) {
|
|
89
|
+
if (s.trimEnd().endsWith(footer)) {
|
|
90
|
+
s = s.trimEnd();
|
|
91
|
+
s = s.slice(0, s.length - footer.length);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return s.replace(/^\n+/u, '').replace(/\s+$/u, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whitespace/anchor normalization — deliberately nothing stronger, per the
|
|
100
|
+
* roadmap lock ("not 'similar', equal"): CRLF → LF, trailing whitespace per
|
|
101
|
+
* line dropped, runs of blank lines collapsed to one, outer blank lines
|
|
102
|
+
* trimmed. Content differences always survive normalization.
|
|
103
|
+
*/
|
|
104
|
+
export function normalize_release_text(s: string): string {
|
|
105
|
+
return s
|
|
106
|
+
.replace(/\r\n/gu, '\n')
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map((line) => line.replace(/[ \t]+$/u, ''))
|
|
109
|
+
.join('\n')
|
|
110
|
+
.replace(/\n{3,}/gu, '\n\n')
|
|
111
|
+
.replace(/^\n+/u, '')
|
|
112
|
+
.replace(/\n+$/u, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compare a derived release surface against the changelog-section body it
|
|
117
|
+
* must equal. Returns null when equal (after normalization), else a
|
|
118
|
+
* human-readable first-divergence report. A truncated surface (carrying the
|
|
119
|
+
* `_cap_body` notice) is compared as a prefix of the changelog side — the
|
|
120
|
+
* truncation itself is not a divergence.
|
|
121
|
+
*/
|
|
122
|
+
export function surface_divergence(surfaceText: string, changelogBody: string): string | null {
|
|
123
|
+
const a = normalize_release_text(surfaceText);
|
|
124
|
+
const b = normalize_release_text(changelogBody);
|
|
125
|
+
if (a.includes(TRUNCATION_MARKER)) {
|
|
126
|
+
const cut = a.indexOf(TRUNCATION_MARKER);
|
|
127
|
+
const prefix = a
|
|
128
|
+
.slice(0, cut)
|
|
129
|
+
.replace(/>\s*$/u, '')
|
|
130
|
+
.replace(/\n+$/u, '');
|
|
131
|
+
if (b.startsWith(prefix)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return _first_diff(prefix, b.slice(0, prefix.length));
|
|
135
|
+
}
|
|
136
|
+
if (a === b) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return _first_diff(a, b);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _first_diff(a: string, b: string): string {
|
|
143
|
+
const al = a.split('\n');
|
|
144
|
+
const bl = b.split('\n');
|
|
145
|
+
const n = Math.max(al.length, bl.length);
|
|
146
|
+
for (let i = 0; i < n; i++) {
|
|
147
|
+
if (al[i] !== bl[i]) {
|
|
148
|
+
return (
|
|
149
|
+
`first divergence at line ${i + 1}: ` +
|
|
150
|
+
`surface=${JSON.stringify(al[i] ?? '<missing>')} ` +
|
|
151
|
+
`changelog=${JSON.stringify(bl[i] ?? '<missing>')}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return 'surfaces differ only in normalization-invisible content';
|
|
156
|
+
}
|