@pareto4/pp-core 1.20.0 → 1.21.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 (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-nyquist-auditor.md +19 -14
  3. package/agents/pp-plan-checker.md +59 -36
  4. package/agents/pp-planner.md +34 -53
  5. package/agents/pp-verifier.md +3 -52
  6. package/commands/pp-autonomous.md +2 -1
  7. package/commands/pp-plan-phase.md +2 -1
  8. package/commands/pp-plan-review-convergence.md +2 -1
  9. package/gemini-extension.json +1 -1
  10. package/package.json +1 -1
  11. package/pp-core/bin/lib/capability-activation.cjs +77 -0
  12. package/pp-core/bin/lib/capability-registry.cjs +14 -14
  13. package/pp-core/bin/lib/capability-state.cjs +8 -18
  14. package/pp-core/bin/lib/ceremony.cjs +1342 -0
  15. package/pp-core/bin/lib/check-command-router.cjs +62 -1
  16. package/pp-core/bin/lib/commands.cjs +29 -3
  17. package/pp-core/bin/lib/config.cjs +5 -0
  18. package/pp-core/bin/lib/init.cjs +50 -1
  19. package/pp-core/bin/lib/io.cjs +2 -0
  20. package/pp-core/bin/lib/loop-resolver.cjs +26 -12
  21. package/pp-core/bin/lib/phase.cjs +39 -3
  22. package/pp-core/bin/lib/plan-sizing.cjs +346 -0
  23. package/pp-core/bin/lib/test-substance.cjs +3190 -0
  24. package/pp-core/bin/lib/timeline.cjs +605 -0
  25. package/pp-core/bin/pp-tools.cjs +71 -4
  26. package/pp-core/bin/shared/ceremony-gates.json +17 -0
  27. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  28. package/pp-core/references/ceremony.md +81 -0
  29. package/pp-core/references/few-shot-examples/plan-checker.md +8 -8
  30. package/pp-core/references/phase-timeline.md +107 -0
  31. package/pp-core/references/planner-antipatterns.md +32 -0
  32. package/pp-core/references/planner-guidance.md +17 -14
  33. package/pp-core/references/planner-revision.md +1 -1
  34. package/pp-core/references/planning-config.md +3 -2
  35. package/pp-core/references/test-substance.md +324 -0
  36. package/pp-core/references/verifier-stub-patterns.md +51 -0
  37. package/pp-core/templates/VALIDATION.md +1 -0
  38. package/pp-core/templates/phase-prompt.md +19 -7
  39. package/pp-core/templates/roadmap.md +1 -1
  40. package/pp-core/templates/verification-report.md +1 -1
  41. package/pp-core/workflows/audit-milestone.md +4 -1
  42. package/pp-core/workflows/autonomous.md +11 -8
  43. package/pp-core/workflows/code-review.md +15 -6
  44. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +7 -7
  45. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -2
  46. package/pp-core/workflows/execute-phase.md +14 -3
  47. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +7 -3
  48. package/pp-core/workflows/plan-phase/steps/prd-express.md +104 -0
  49. package/pp-core/workflows/plan-phase.md +47 -114
  50. package/pp-core/workflows/plan-review-convergence.md +6 -3
  51. package/pp-core/workflows/ship.md +2 -2
  52. package/pp-core/workflows/stats.md +19 -4
  53. package/pp-core/workflows/validate-phase.md +19 -4
  54. package/pp-core/workflows/verify-phase.md +1 -1
  55. package/pp-core/workflows/verify-work.md +1 -1
  56. package/scripts/gen-capability-registry.cjs +43 -45
  57. package/scripts/run-tests.cjs +54 -30
@@ -0,0 +1,605 @@
1
+ "use strict";
2
+ /**
3
+ * Phase Timeline: run records for the orchestrator `Agent()` spawns (Phase 35).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/timeline.cjs`; do NOT hand-edit
7
+ * the compiled output.
8
+ *
9
+ * Record store (D-01, D-02): `{phase_dir}/{padded}-TIMELINE.jsonl`, one JSON
10
+ * object per line, append-only. `start` appends a full record with null end
11
+ * fields; `end` appends a second complete line carrying the same `id`. The
12
+ * current state of a run is the LAST line with its id (D-06), so nothing is
13
+ * ever rewritten and a torn final line costs one record at most.
14
+ *
15
+ * Only the orchestrator calls this verb, on its own checkout, after `Agent()`
16
+ * returns (D-03). Time comes from `realClock` so `GSD_TEST_MODE` +
17
+ * `GSD_NOW_MS` make every test deterministic.
18
+ *
19
+ * Subcommands: `start <role> <phase>`, `end <role> <phase> <outcome>` and
20
+ * `summary <phase> [--banner]`.
21
+ */
22
+ var __importDefault = (this && this.__importDefault) || function (mod) {
23
+ return (mod && mod.__esModule) ? mod : { "default": mod };
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.ROLE_SOURCE = exports.ROLE_AGENT = exports.TIMELINE_SOURCES = exports.TIMELINE_ROLES = void 0;
27
+ exports.timelineFileName = timelineFileName;
28
+ exports.foldRecords = foldRecords;
29
+ exports.formatDuration = formatDuration;
30
+ exports.buildPhaseTimeline = buildPhaseTimeline;
31
+ exports.renderBanner = renderBanner;
32
+ exports.cmdTimeline = cmdTimeline;
33
+ const node_fs_1 = __importDefault(require("node:fs"));
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const io = require("./io.cjs");
37
+ const { output, ERROR_REASON } = io;
38
+ /** Explicitly typed so TypeScript narrows after a failing guard (the destructured `io.error` does not). */
39
+ function error(message, reason) {
40
+ return io.error(message, reason);
41
+ }
42
+ const clock_cjs_1 = require("./clock.cjs");
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const phaseScopeMod = require("./phase-scope.cjs");
45
+ const { resolvePhaseForRead, resolvePhaseForWrite } = phaseScopeMod;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const phaseIdMod = require("./phase-id.cjs");
48
+ const { extractPhaseToken } = phaseIdMod;
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const modelResolverMod = require("./model-resolver.cjs");
51
+ const { resolveModelInternal } = modelResolverMod;
52
+ const security_cjs_1 = require("./security.cjs");
53
+ /** D-02: the eight roles. `other` carries an explicit agent and source. */
54
+ exports.TIMELINE_ROLES = Object.freeze([
55
+ 'researcher', 'pattern-mapper', 'planner', 'plan-checker',
56
+ 'executor', 'code-reviewer', 'verifier', 'other',
57
+ ]);
58
+ exports.TIMELINE_SOURCES = Object.freeze([
59
+ 'plan-phase', 'execute-phase', 'code-review',
60
+ ]);
61
+ /** D-08: the agent a role runs as when `--agent` is omitted (no entry for `other`). */
62
+ exports.ROLE_AGENT = Object.freeze({
63
+ 'researcher': 'pp-phase-researcher',
64
+ 'pattern-mapper': 'pp-pattern-mapper',
65
+ 'planner': 'pp-planner',
66
+ 'plan-checker': 'pp-plan-checker',
67
+ 'executor': 'pp-executor',
68
+ 'code-reviewer': 'pp-code-reviewer',
69
+ 'verifier': 'pp-verifier',
70
+ });
71
+ /** D-08: the workflow a role belongs to when `--source` is omitted (no entry for `other`). */
72
+ exports.ROLE_SOURCE = Object.freeze({
73
+ 'researcher': 'plan-phase',
74
+ 'pattern-mapper': 'plan-phase',
75
+ 'planner': 'plan-phase',
76
+ 'plan-checker': 'plan-phase',
77
+ 'executor': 'execute-phase',
78
+ 'verifier': 'execute-phase',
79
+ 'code-reviewer': 'code-review',
80
+ });
81
+ const PLAN_RE = /^[A-Za-z0-9_.-]{1,40}$/;
82
+ const MAX_LINE_CHARS = 4096;
83
+ const MAX_TEXT = 80;
84
+ const ID_RE = /^(.+?)-(researcher|pattern-mapper|planner|plan-checker|executor|code-reviewer|verifier|other)(?:-(.+))?-(\d+)$/;
85
+ const VALUE_FLAGS = new Set(['plan', 'agent', 'model', 'source', 'id', 'phase', 'role', 'outcome']);
86
+ function isRole(value) {
87
+ return typeof value === 'string' && exports.TIMELINE_ROLES.includes(value);
88
+ }
89
+ function isSource(value) {
90
+ return typeof value === 'string' && exports.TIMELINE_SOURCES.includes(value);
91
+ }
92
+ /** `35-phase-timeline` -> `35-TIMELINE.jsonl` (D-01). */
93
+ function timelineFileName(phaseDirName) {
94
+ return `${extractPhaseToken(phaseDirName) || phaseDirName}-TIMELINE.jsonl`;
95
+ }
96
+ /**
97
+ * Free text becomes one short line (T-35-01): prompt-injection markers are
98
+ * neutralised, control characters and `|` (the table delimiter) become spaces,
99
+ * whitespace collapses, length is capped. `safeAlphabet` additionally drops
100
+ * everything outside the agent/model alphabet.
101
+ */
102
+ function cleanText(value, max = MAX_TEXT, safeAlphabet = false) {
103
+ const raw = typeof value === 'string' ? value : '';
104
+ let text = (0, security_cjs_1.sanitizeForPrompt)(raw) || '';
105
+ text = text.replace(/[\u0000-\u001f\u007f|]/g, ' ');
106
+ if (safeAlphabet)
107
+ text = text.replace(/[^A-Za-z0-9_.:@/[\] -]/g, '');
108
+ return text.replace(/\s+/g, ' ').trim().slice(0, max).trim();
109
+ }
110
+ function escapeRegex(text) {
111
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ }
113
+ /**
114
+ * Turn one parsed JSON value into a record, or null when it fails the shape
115
+ * check (T-35-03). `duration_ms` is always recomputed from the two timestamps
116
+ * and clamped at 0 (T-35-04); a stored value is ignored. An `ended_at` that
117
+ * Date.parse rejects is dropped, which makes the run incomplete. Both
118
+ * timestamps are re-emitted in canonical ISO form, never as the stored text:
119
+ * `Date.parse` accepts strings the sanitizer would reject (parenthesised
120
+ * comments, for one), and the raw value would otherwise flow into `summary`,
121
+ * `stats.json` and the closing line `end` writes (T-35-03).
122
+ */
123
+ function normalizeRecord(value) {
124
+ if (!value || typeof value !== 'object')
125
+ return null;
126
+ const r = value;
127
+ if (typeof r['id'] !== 'string' || r['id'].length === 0 || r['id'].length > 160)
128
+ return null;
129
+ if (!isRole(r['role']))
130
+ return null;
131
+ if (typeof r['started_at'] !== 'string')
132
+ return null;
133
+ const startMs = Date.parse(r['started_at']);
134
+ if (!Number.isFinite(startMs))
135
+ return null;
136
+ const startedAt = new Date(startMs).toISOString();
137
+ const role = r['role'];
138
+ let endedAt = null;
139
+ let durationMs = null;
140
+ if (typeof r['ended_at'] === 'string') {
141
+ const endMs = Date.parse(r['ended_at']);
142
+ if (Number.isFinite(endMs)) {
143
+ endedAt = new Date(endMs).toISOString();
144
+ durationMs = Math.max(0, endMs - startMs);
145
+ }
146
+ }
147
+ const plan = typeof r['plan'] === 'string' && PLAN_RE.test(r['plan']) ? r['plan'] : null;
148
+ const outcome = typeof r['outcome'] === 'string' ? cleanText(r['outcome']) : null;
149
+ return {
150
+ id: r['id'],
151
+ phase: typeof r['phase'] === 'string' ? cleanText(r['phase'], 40, true) : '',
152
+ agent: cleanText(r['agent'], MAX_TEXT, true) || exports.ROLE_AGENT[role] || 'unknown',
153
+ role,
154
+ model: cleanText(r['model'], MAX_TEXT, true) || 'unknown',
155
+ plan,
156
+ started_at: startedAt,
157
+ ended_at: endedAt,
158
+ duration_ms: durationMs,
159
+ outcome,
160
+ source: isSource(r['source']) ? r['source'] : (exports.ROLE_SOURCE[role] ?? 'execute-phase'),
161
+ };
162
+ }
163
+ /** Every valid line of a file's text, in file order. Bad lines are skipped, never thrown on. */
164
+ function parseEvents(text) {
165
+ const events = [];
166
+ for (const line of text.split('\n')) {
167
+ if (!line.trim() || line.length > MAX_LINE_CHARS)
168
+ continue;
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(line);
172
+ }
173
+ catch {
174
+ continue; // torn or foreign line
175
+ }
176
+ const record = normalizeRecord(parsed);
177
+ if (record)
178
+ events.push(record);
179
+ }
180
+ return events;
181
+ }
182
+ function readEvents(file) {
183
+ let text;
184
+ try {
185
+ text = node_fs_1.default.readFileSync(file, 'utf-8');
186
+ }
187
+ catch {
188
+ return [];
189
+ }
190
+ return parseEvents(text);
191
+ }
192
+ /** D-06: the current state of each run is its last line. */
193
+ function currentStates(events) {
194
+ const states = new Map();
195
+ events.forEach((record, index) => {
196
+ const prior = states.get(record.id);
197
+ states.set(record.id, { current: record, seq: prior ? prior.seq : index });
198
+ });
199
+ return states;
200
+ }
201
+ /**
202
+ * D-11: every run of a file's text, one per id (last line wins, D-06), sorted
203
+ * by `started_at` ascending with ties in file order. Torn and foreign lines are
204
+ * skipped, so a damaged file degrades to fewer runs instead of an error.
205
+ */
206
+ function foldRecords(text) {
207
+ const states = [...currentStates(parseEvents(text)).values()];
208
+ states.sort((a, b) => {
209
+ const delta = Date.parse(a.current.started_at) - Date.parse(b.current.started_at);
210
+ return delta !== 0 ? delta : a.seq - b.seq;
211
+ });
212
+ return states.map((state) => state.current);
213
+ }
214
+ /** D-10: `38s`, `45m 10s`, `1h 12m`. Floors; negative and non-finite input is `0s`. */
215
+ function formatDuration(ms) {
216
+ if (!Number.isFinite(ms) || ms < 0)
217
+ return '0s';
218
+ const total = Math.floor(ms / 1000);
219
+ const hours = Math.floor(total / 3600);
220
+ const minutes = Math.floor((total % 3600) / 60);
221
+ const seconds = total % 60;
222
+ if (hours >= 1)
223
+ return `${hours}h ${String(minutes).padStart(2, '0')}m`;
224
+ if (minutes >= 1)
225
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
226
+ return `${seconds}s`;
227
+ }
228
+ const pad2 = (n) => String(n).padStart(2, '0');
229
+ const localDate = (ms) => {
230
+ const d = new Date(ms);
231
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
232
+ };
233
+ const localClock = (ms) => {
234
+ const d = new Date(ms);
235
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
236
+ };
237
+ /** max(end) - min(start) over the intervals, or null for an empty list. */
238
+ function spanOf(intervals) {
239
+ if (intervals.length === 0)
240
+ return null;
241
+ let start = Infinity;
242
+ let end = -Infinity;
243
+ for (const i of intervals) {
244
+ if (i.start < start)
245
+ start = i.start;
246
+ if (i.end > end)
247
+ end = i.end;
248
+ }
249
+ return { start, end: Math.max(start, end) };
250
+ }
251
+ function labelOf(role, plan, model, duration) {
252
+ return `${role}${plan ? ' ' + plan : ''}${model && model !== 'inherit' ? ` (${model})` : ''} ${duration}`;
253
+ }
254
+ const runsLabel = (n) => (n === 1 ? '1 run' : `${n} runs`);
255
+ /**
256
+ * D-04, D-10, D-11, D-13: fold records into the numbers every reader shows.
257
+ *
258
+ * Wall-clock interpretation (D-13): each source (plan-phase, execute-phase,
259
+ * code-review) gets one span, max(ended_at) - min(started_at) over its
260
+ * completed runs, so parallel executors count once and idle days between
261
+ * planning and executing drop out. The headline total is the length of the
262
+ * UNION of those spans: it equals the sum of the spans whenever they are
263
+ * disjoint, and a code review that runs inside the execute-phase span (the
264
+ * default flow: execute:post runs the review before the verifier) is counted
265
+ * once instead of twice. `elapsed` is the raw first-start to last-end span and
266
+ * agent time is the sum of completed durations. Orphans (no ended_at) are
267
+ * counted in `incomplete_count` and excluded from all three sums.
268
+ */
269
+ function buildPhaseTimeline(records) {
270
+ const completed = records.filter((r) => r.ended_at !== null);
271
+ const intervalOf = (r) => {
272
+ const start = Date.parse(r.started_at);
273
+ return { start, end: start + (r.duration_ms ?? 0) };
274
+ };
275
+ const bySource = { 'plan-phase': 0, 'execute-phase': 0, 'code-review': 0 };
276
+ const spans = [];
277
+ for (const source of exports.TIMELINE_SOURCES) {
278
+ const span = spanOf(completed.filter((r) => r.source === source).map(intervalOf));
279
+ if (!span)
280
+ continue;
281
+ bySource[source] = span.end - span.start;
282
+ if (span.end > span.start)
283
+ spans.push(span);
284
+ }
285
+ spans.sort((a, b) => a.start - b.start);
286
+ let wallClockMs = 0;
287
+ let open = null;
288
+ for (const span of spans) {
289
+ if (open && span.start <= open.end) {
290
+ open = { start: open.start, end: Math.max(open.end, span.end) };
291
+ }
292
+ else {
293
+ if (open)
294
+ wallClockMs += open.end - open.start;
295
+ open = { ...span };
296
+ }
297
+ }
298
+ if (open)
299
+ wallClockMs += open.end - open.start;
300
+ const all = spanOf(completed.map(intervalOf));
301
+ const elapsedMs = all ? all.end - all.start : 0;
302
+ const agentTimeMs = completed.reduce((sum, r) => sum + (r.duration_ms ?? 0), 0);
303
+ const firstStart = records.length > 0 ? Date.parse(records[0].started_at) : null;
304
+ const date = firstStart === null ? null : localDate(firstStart);
305
+ const runs = records.map((r) => {
306
+ const startMs = Date.parse(r.started_at);
307
+ const done = r.ended_at !== null;
308
+ return {
309
+ ...r,
310
+ display: {
311
+ started: date !== null && localDate(startMs) !== date ? `${localDate(startMs).slice(5)} ${localClock(startMs)}` : localClock(startMs),
312
+ duration: done ? formatDuration(r.duration_ms ?? 0) : 'incomplete',
313
+ outcome: done ? (r.outcome ?? '') : 'incomplete',
314
+ },
315
+ };
316
+ });
317
+ const longest = completed
318
+ .map((r, index) => ({ r, index }))
319
+ .sort((a, b) => {
320
+ const byDuration = (b.r.duration_ms ?? 0) - (a.r.duration_ms ?? 0);
321
+ if (byDuration !== 0)
322
+ return byDuration;
323
+ const byStart = Date.parse(a.r.started_at) - Date.parse(b.r.started_at);
324
+ return byStart !== 0 ? byStart : a.index - b.index;
325
+ })
326
+ .slice(0, 3)
327
+ .map(({ r }) => {
328
+ const duration = formatDuration(r.duration_ms ?? 0);
329
+ return {
330
+ role: r.role,
331
+ plan: r.plan,
332
+ model: r.model,
333
+ duration_ms: r.duration_ms ?? 0,
334
+ duration,
335
+ label: labelOf(r.role, r.plan, r.model, duration),
336
+ };
337
+ });
338
+ const incompleteCount = records.length - completed.length;
339
+ let footer = `Total wall-clock: ${formatDuration(wallClockMs)} \u00b7 agent time ${formatDuration(agentTimeMs)} \u00b7 ${runsLabel(records.length)}`;
340
+ if (incompleteCount > 0)
341
+ footer += ` (${incompleteCount} incomplete)`;
342
+ if (elapsedMs !== wallClockMs)
343
+ footer += ` \u00b7 elapsed ${formatDuration(elapsedMs)}`;
344
+ return {
345
+ runs,
346
+ run_count: records.length,
347
+ incomplete_count: incompleteCount,
348
+ wall_clock_ms: wallClockMs,
349
+ wall_clock: formatDuration(wallClockMs),
350
+ elapsed_ms: elapsedMs,
351
+ elapsed: formatDuration(elapsedMs),
352
+ agent_time_ms: agentTimeMs,
353
+ agent_time: formatDuration(agentTimeMs),
354
+ by_source: bySource,
355
+ longest,
356
+ date,
357
+ footer,
358
+ };
359
+ }
360
+ /** D-14, D-15: the two banner lines, or '' when nothing completed. */
361
+ function renderBanner(t) {
362
+ if (t.longest.length === 0)
363
+ return '';
364
+ return `Wall-clock: ${t.wall_clock} (${runsLabel(t.run_count)})\nLongest: ${t.longest.map((l) => l.label).join(' \u00b7 ')}`;
365
+ }
366
+ function parseArgs(argv) {
367
+ const positionals = [];
368
+ const flags = {};
369
+ for (let i = 0; i < argv.length; i++) {
370
+ const arg = argv[i];
371
+ if (arg === '--banner') {
372
+ flags['banner'] = '1';
373
+ }
374
+ else if (arg.startsWith('--')) {
375
+ const name = arg.slice(2);
376
+ if (!VALUE_FLAGS.has(name))
377
+ error(`Unknown timeline flag: ${arg}`, ERROR_REASON.USAGE);
378
+ const next = argv[i + 1];
379
+ if (next === undefined)
380
+ error(`Missing value for ${arg}`, ERROR_REASON.USAGE);
381
+ flags[name] = next;
382
+ i++;
383
+ }
384
+ else {
385
+ positionals.push(arg);
386
+ }
387
+ }
388
+ return { positionals, flags };
389
+ }
390
+ function requireRole(value) {
391
+ if (!isRole(value)) {
392
+ error(`Unknown role: ${String(value)}. Expected one of: ${exports.TIMELINE_ROLES.join(', ')}`, ERROR_REASON.USAGE);
393
+ }
394
+ return value;
395
+ }
396
+ function requirePlan(value) {
397
+ if (value === undefined)
398
+ return null;
399
+ if (!PLAN_RE.test(value))
400
+ error('--plan must match ^[A-Za-z0-9_.-]{1,40}$', ERROR_REASON.USAGE);
401
+ return value;
402
+ }
403
+ /** The live phase directory a record may be written into (T-35-02, T-35-05). */
404
+ function resolveWriteTarget(cwd, phase) {
405
+ if (phase === undefined || phase === '')
406
+ error('Missing phase argument', ERROR_REASON.USAGE);
407
+ const verdict = resolvePhaseForWrite(cwd, phase);
408
+ if (!verdict.ok) {
409
+ if (verdict.reason === 'archived') {
410
+ error(`Phase ${phase} is archived; timeline records are written to live phases only`, ERROR_REASON.PHASE_NOT_FOUND);
411
+ }
412
+ error(`Phase ${phase} directory not found`, ERROR_REASON.PHASE_NOT_FOUND);
413
+ }
414
+ const dir = node_path_1.default.join(cwd, verdict.match.directory);
415
+ const dirName = node_path_1.default.basename(verdict.match.directory);
416
+ return { token: extractPhaseToken(dirName) || dirName, file: node_path_1.default.join(dir, timelineFileName(dirName)) };
417
+ }
418
+ function cmdStart(cwd, argv, raw) {
419
+ const { positionals, flags } = parseArgs(argv);
420
+ const role = requireRole(flags['role'] ?? positionals[0]);
421
+ const phase = flags['phase'] ?? positionals[1];
422
+ const plan = requirePlan(flags['plan']);
423
+ if (flags['source'] !== undefined && !isSource(flags['source'])) {
424
+ error(`Unknown source: ${flags['source']}. Expected one of: ${exports.TIMELINE_SOURCES.join(', ')}`, ERROR_REASON.USAGE);
425
+ }
426
+ if (role === 'other' && (flags['agent'] === undefined || flags['source'] === undefined)) {
427
+ error('Role "other" requires --agent and --source', ERROR_REASON.USAGE);
428
+ }
429
+ const agent = cleanText(flags['agent'], MAX_TEXT, true) || exports.ROLE_AGENT[role];
430
+ if (!agent)
431
+ error('Role "other" requires a non-empty --agent', ERROR_REASON.USAGE);
432
+ const source = isSource(flags['source']) ? flags['source'] : exports.ROLE_SOURCE[role];
433
+ if (!source)
434
+ error('Role "other" requires --source', ERROR_REASON.USAGE);
435
+ const { token, file } = resolveWriteTarget(cwd, phase);
436
+ let model = cleanText(flags['model'], MAX_TEXT, true);
437
+ if (!model) {
438
+ try {
439
+ model = cleanText(resolveModelInternal(cwd, agent), MAX_TEXT, true);
440
+ }
441
+ catch {
442
+ model = '';
443
+ }
444
+ if (!model)
445
+ model = 'unknown';
446
+ }
447
+ const prefix = `${token}-${role}${plan ? '-' + plan : ''}-`;
448
+ const siblings = new RegExp(`^${escapeRegex(prefix)}\\d+$`);
449
+ const existing = new Set();
450
+ for (const record of readEvents(file)) {
451
+ if (siblings.test(record.id))
452
+ existing.add(record.id);
453
+ }
454
+ const id = `${prefix}${existing.size + 1}`;
455
+ const record = {
456
+ id,
457
+ phase: token,
458
+ agent,
459
+ role,
460
+ model,
461
+ plan,
462
+ started_at: clock_cjs_1.realClock.nowIso(),
463
+ ended_at: null,
464
+ duration_ms: null,
465
+ outcome: null,
466
+ source,
467
+ };
468
+ node_fs_1.default.appendFileSync(file, JSON.stringify(record) + '\n');
469
+ output(record, raw, record.id);
470
+ }
471
+ function cmdEnd(cwd, argv, raw) {
472
+ const { positionals, flags } = parseArgs(argv);
473
+ let id;
474
+ let role;
475
+ let phase;
476
+ let outcomeText;
477
+ if (flags['id'] !== undefined) {
478
+ id = flags['id'];
479
+ const derived = ID_RE.exec(id);
480
+ phase = flags['phase'] ?? positionals[1] ?? (derived ? derived[1] : undefined);
481
+ outcomeText = flags['outcome'] ?? (positionals.length >= 3 ? positionals[2] : positionals.length === 1 ? positionals[0] : undefined);
482
+ }
483
+ else if (positionals.length === 1 && flags['outcome'] !== undefined && ID_RE.test(positionals[0])) {
484
+ id = positionals[0];
485
+ const derived = ID_RE.exec(id);
486
+ phase = flags['phase'] ?? (derived ? derived[1] : undefined);
487
+ outcomeText = flags['outcome'];
488
+ }
489
+ else {
490
+ role = requireRole(flags['role'] ?? positionals[0]);
491
+ phase = flags['phase'] ?? positionals[1];
492
+ outcomeText = flags['outcome'] ?? positionals[2];
493
+ }
494
+ if (outcomeText === undefined || cleanText(outcomeText) === '')
495
+ error('Missing outcome', ERROR_REASON.USAGE);
496
+ const outcome = cleanText(outcomeText);
497
+ const plan = requirePlan(flags['plan']);
498
+ const { file } = resolveWriteTarget(cwd, phase);
499
+ const states = currentStates(readEvents(file));
500
+ let target;
501
+ if (id !== undefined) {
502
+ const state = states.get(id);
503
+ if (state && state.current.ended_at === null)
504
+ target = state.current;
505
+ if (!target)
506
+ error(`No open timeline run with id ${id}`, ERROR_REASON.TIMELINE_NO_OPEN_RUN);
507
+ }
508
+ else {
509
+ let best;
510
+ for (const state of states.values()) {
511
+ const c = state.current;
512
+ if (c.ended_at !== null || c.role !== role)
513
+ continue;
514
+ if (plan !== null && c.plan !== plan)
515
+ continue;
516
+ if (!best) {
517
+ best = state;
518
+ continue;
519
+ }
520
+ const a = Date.parse(c.started_at);
521
+ const b = Date.parse(best.current.started_at);
522
+ if (a > b || (a === b && state.seq > best.seq))
523
+ best = state;
524
+ }
525
+ if (!best) {
526
+ error(`No open timeline run for role ${String(role)} in phase ${String(phase)}${plan ? ` (plan ${plan})` : ''}`, ERROR_REASON.TIMELINE_NO_OPEN_RUN);
527
+ }
528
+ target = best.current;
529
+ }
530
+ const endedAt = clock_cjs_1.realClock.nowIso();
531
+ const closed = {
532
+ id: target.id,
533
+ phase: target.phase,
534
+ agent: target.agent,
535
+ role: target.role,
536
+ model: target.model,
537
+ plan: target.plan,
538
+ started_at: target.started_at,
539
+ ended_at: endedAt,
540
+ duration_ms: Math.max(0, Date.parse(endedAt) - Date.parse(target.started_at)),
541
+ outcome,
542
+ source: target.source,
543
+ };
544
+ node_fs_1.default.appendFileSync(file, JSON.stringify(closed) + '\n');
545
+ output(closed, raw, closed.id);
546
+ }
547
+ function cmdSummary(cwd, argv, raw) {
548
+ const { positionals, flags } = parseArgs(argv);
549
+ const phase = flags['phase'] ?? positionals[0];
550
+ const banner = flags['banner'] !== undefined;
551
+ if (phase === undefined || phase === '')
552
+ error('Missing phase argument', ERROR_REASON.USAGE);
553
+ // Readers may use a live or an archived phase (D-12).
554
+ const match = resolvePhaseForRead(cwd, phase);
555
+ if (!match) {
556
+ if (banner)
557
+ return; // a completion block must never carry a stray line or an error
558
+ error(`Phase ${phase} not found`, ERROR_REASON.PHASE_NOT_FOUND);
559
+ }
560
+ const dir = node_path_1.default.join(cwd, match.directory);
561
+ let texts = [];
562
+ try {
563
+ texts = node_fs_1.default.readdirSync(dir)
564
+ .filter((name) => name.endsWith('-TIMELINE.jsonl'))
565
+ .sort()
566
+ .map((name) => node_fs_1.default.readFileSync(node_path_1.default.join(dir, name), 'utf-8'));
567
+ }
568
+ catch {
569
+ texts = [];
570
+ }
571
+ const runs = foldRecords(texts.join('\n'));
572
+ if (runs.length === 0) {
573
+ if (banner) {
574
+ output({ phase, present: false }, true, '');
575
+ }
576
+ else {
577
+ output({ phase, present: false, run_count: 0, message: 'no recorded runs' }, raw, 'no recorded runs');
578
+ }
579
+ return;
580
+ }
581
+ const timeline = buildPhaseTimeline(runs);
582
+ if (banner) {
583
+ output(timeline, true, renderBanner(timeline));
584
+ }
585
+ else {
586
+ output({ phase, present: true, ...timeline }, raw, timeline.footer);
587
+ }
588
+ }
589
+ function cmdTimeline(cwd, args, raw) {
590
+ const sub = args[0];
591
+ const rest = args.slice(1);
592
+ switch (sub) {
593
+ case 'start':
594
+ cmdStart(cwd, rest, raw);
595
+ return;
596
+ case 'end':
597
+ cmdEnd(cwd, rest, raw);
598
+ return;
599
+ case 'summary':
600
+ cmdSummary(cwd, rest, raw);
601
+ return;
602
+ default:
603
+ error(`Unknown timeline subcommand: ${String(sub)}. Available: start, end, summary`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
604
+ }
605
+ }