@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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +53 -0
  4. package/README.md +1 -1
  5. package/dist/agent-src/commands/agent-handoff.md +100 -48
  6. package/dist/agent-src/commands/commit.md +1 -1
  7. package/dist/agent-src/commands/condense.md +19 -64
  8. package/dist/agent-src/commands/evals/agent-handoff.json +13 -1
  9. package/dist/agent-src/commands/memory/promote.md +14 -1
  10. package/dist/agent-src/commands/mode.md +9 -5
  11. package/dist/agent-src/commands/optimize/deep.md +72 -11
  12. package/dist/agent-src/contexts/authority/commit-mechanics.md +5 -5
  13. package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
  14. package/dist/agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +1 -1
  15. package/dist/agent-src/contexts/communication/rules-auto/think-before-action-mechanics.md +6 -1
  16. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +4 -1
  17. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +27 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +5 -2
  19. package/dist/agent-src/profiles/agency.yml +3 -3
  20. package/dist/agent-src/profiles/content_creator.yml +2 -2
  21. package/dist/agent-src/profiles/developer.yml +2 -2
  22. package/dist/agent-src/profiles/finance.yml +1 -1
  23. package/dist/agent-src/profiles/founder.yml +2 -2
  24. package/dist/agent-src/profiles/ops.yml +2 -2
  25. package/dist/agent-src/rules/architecture.md +2 -2
  26. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  27. package/dist/agent-src/rules/context-hygiene.md +3 -2
  28. package/dist/agent-src/rules/domain-safety-pii.md +1 -1
  29. package/dist/agent-src/rules/output-discipline.md +9 -2
  30. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  31. package/dist/agent-src/rules/user-interaction.md +3 -4
  32. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -1
  33. package/dist/agent-src/skills/copilot-agents-optimization/SKILL.md +4 -4
  34. package/dist/agent-src/skills/copilot-config/SKILL.md +1 -1
  35. package/dist/agent-src/skills/deep-reading-analyst/SKILL.md +7 -4
  36. package/dist/agent-src/skills/description-assist/SKILL.md +1 -1
  37. package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
  38. package/dist/agent-src/skills/market-entry-analysis/SKILL.md +1 -1
  39. package/dist/agent-src/skills/persona-writing/SKILL.md +2 -2
  40. package/dist/agent-src/skills/readme-reviewer/SKILL.md +2 -1
  41. package/dist/agent-src/skills/rule-writing/SKILL.md +4 -2
  42. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +2 -2
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +3 -1
  45. package/dist/agent-src/skills/token-optimizer/SKILL.md +2 -2
  46. package/dist/agent-src/skills/ui-component-architect/SKILL.md +6 -3
  47. package/dist/agent-src/templates/agent-settings.md +13 -15
  48. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +6 -2
  49. package/dist/agent-src/templates/persona.md +2 -2
  50. package/dist/agent-src/templates/roadmaps.md +1 -1
  51. package/dist/agent-src/templates/rule.md +1 -1
  52. package/dist/agent-src/templates/skill.md +1 -1
  53. package/dist/cli/registry.js +1 -0
  54. package/dist/cli/registry.js.map +1 -1
  55. package/dist/cli-delegate/{chunk-ZXXJLL5K.js → chunk-BSLUHV2F.js} +127 -1487
  56. package/dist/cli-delegate/{chunk-H574KOUH.js → chunk-IMSXPRVH.js} +24 -1
  57. package/dist/cli-delegate/chunk-VSYT7OHO.js +1382 -0
  58. package/dist/cli-delegate/chunk-XGA2NTU4.js +3727 -0
  59. package/dist/cli-delegate/cmd_conformance.js +4 -3
  60. package/dist/cli-delegate/cmd_converge.js +3 -3
  61. package/dist/cli-delegate/cmd_doctor.js +3 -2
  62. package/dist/cli-delegate/cmd_export.js +4 -4
  63. package/dist/cli-delegate/cmd_handoff.js +3194 -0
  64. package/dist/cli-delegate/cmd_memory_get.js +35 -3754
  65. package/dist/cli-delegate/cmd_migrate.js +2 -2
  66. package/dist/cli-delegate/cmd_prune.js +4 -4
  67. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  68. package/dist/cli-delegate/cmd_sync.js +4 -4
  69. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +4 -4
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +31 -31
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +1 -1
  78. package/dist/discovery/orphan-report.md +1 -1
  79. package/dist/discovery/packs.json +2 -2
  80. package/dist/discovery/trust-report.md +1 -1
  81. package/dist/discovery/workspaces.json +2 -2
  82. package/dist/hooks/dispatch.js +602 -507
  83. package/dist/install/install.mjs +23 -0
  84. package/dist/mcp/awesome-mcp-servers.row.md +1 -1
  85. package/dist/mcp/mcp-cloudflare-catalogue.json +2 -2
  86. package/dist/mcp/registry-manifest.json +4 -4
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +25 -3
  89. package/dist/scripts/_lib/agent_src.js +39 -6
  90. package/dist/scripts/_lib/agent_src.js.map +1 -1
  91. package/dist/server/schemas/settings.js +13 -0
  92. package/dist/server/schemas/settings.js.map +1 -1
  93. package/docs/guidelines/abstraction-thresholds.md +53 -0
  94. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -1
  95. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +7 -0
  96. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +2 -2
  97. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +5 -3
  98. package/docs/guidelines/agent-infra/size-and-scope.md +8 -2
  99. package/docs/guidelines/component-oriented-and-oop-development.md +11 -6
  100. package/docs/guidelines/docs/readme-size-and-splitting.md +5 -2
  101. package/llms.txt +1 -0
  102. package/package.json +1 -1
  103. package/src/config/agent-settings.template.yml +22 -0
  104. package/src/config/evaluator-budgets.json +8 -11
  105. package/src/config/gate-coverage.yml +30 -0
  106. package/src/scripts/_cli/cmd_handoff.ts +405 -0
  107. package/src/scripts/_cli/handoff_generate.ts +453 -0
  108. package/src/scripts/_cli/handoff_sessions.ts +435 -0
  109. package/src/scripts/_dispatch.bash +9 -0
  110. package/src/scripts/_lib/agent_src.ts +36 -6
  111. package/src/scripts/_lib/release_material.ts +156 -0
  112. package/src/scripts/check_condensation.ts +2 -1
  113. package/src/scripts/check_finding_dispositions.ts +278 -0
  114. package/src/scripts/check_pack_size.ts +4 -3
  115. package/src/scripts/check_references.ts +1 -0
  116. package/src/scripts/check_release_highlights.ts +300 -0
  117. package/src/scripts/check_release_pr_shape.ts +4 -0
  118. package/src/scripts/check_release_surface_equality.ts +132 -0
  119. package/src/scripts/check_reply_consistency.ts +187 -138
  120. package/src/scripts/handoff_context_hook.ts +148 -0
  121. package/src/scripts/hook_manifest.yaml +17 -7
  122. package/src/scripts/hooks/concern_registry.ts +2 -0
  123. package/src/scripts/lint_abstraction_thresholds.ts +325 -0
  124. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  125. package/src/scripts/lint_profile_personas.ts +153 -0
  126. package/src/scripts/lint_token_budget_discipline.ts +148 -0
  127. package/src/scripts/release.ts +156 -32
  128. package/src/scripts/release_drill.ts +491 -0
  129. package/src/scripts/schemas/command.schema.json +33 -0
  130. package/src/scripts/self_review_gate.ts +41 -3
  131. package/src/scripts/skill_linter.ts +3 -1
  132. package/src/scripts/smoke/router.sh +1 -1
  133. package/src/scripts/update_counts.ts +60 -12
  134. package/src/server/schemas/settings.ts +23 -0
  135. 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. Use a
197
- // stat-based dir check to match Python's traversal, but guard
198
- // against unreadable entries.
199
- if (ent.isDirectory() || (ent.isSymbolicLink() && _isDir(full))) {
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. Symlinks and non-files are skipped. Mirrors `iter_artefacts`.
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
+ }