linersh 0.5.7 → 1.0.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 (98) hide show
  1. package/README.md +65 -165
  2. package/bin/liner.js +82 -44
  3. package/cli-update-docs/ABOUT.md +16 -16
  4. package/cli-update-docs/AI-HANDOFF.md +62 -204
  5. package/cli-update-docs/CURATION.md +48 -19
  6. package/cli-update-docs/JTBD-MASTER-PROMPT-ABOUT.md +9 -12
  7. package/cli-update-docs/JTBD-MASTER-PROMPT.md +1 -1
  8. package/cli-update-docs/LINER-MASTER.md +77 -63
  9. package/cli-update-docs/LINER-MD-PRD.md +531 -0
  10. package/cli-update-docs/MIXTAPE-FORMAT.md +76 -44
  11. package/cli-update-docs/PLATFORM.md +47 -46
  12. package/cli-update-docs/README.md +6 -6
  13. package/cli-update-docs/SKILL.md +72 -37
  14. package/cli-update-docs/TUI-BACKLOG.md +205 -26
  15. package/cli-update-docs/quality-check-tests.md +256 -8
  16. package/cli-update-docs/source-finding-tactics.md +140 -3
  17. package/cli-update-docs/source-quality-hierarchy.md +83 -0
  18. package/cli-update-docs/synthesis-guidance.md +19 -5
  19. package/dist/agents/artifact-validation.js +440 -0
  20. package/dist/agents/artifact-validation.js.map +1 -0
  21. package/dist/agents/candidate-longlist.js +100 -0
  22. package/dist/agents/candidate-longlist.js.map +1 -0
  23. package/dist/agents/detect.js +3 -2
  24. package/dist/agents/detect.js.map +1 -1
  25. package/dist/agents/evaluation-assembly.js +270 -0
  26. package/dist/agents/evaluation-assembly.js.map +1 -0
  27. package/dist/agents/headless-runner.js +227 -0
  28. package/dist/agents/headless-runner.js.map +1 -0
  29. package/dist/agents/jtbd-clarify.js +42 -41
  30. package/dist/agents/jtbd-clarify.js.map +1 -1
  31. package/dist/agents/models.js +6 -7
  32. package/dist/agents/models.js.map +1 -1
  33. package/dist/agents/prompts.js +220 -20
  34. package/dist/agents/prompts.js.map +1 -1
  35. package/dist/agents/runner.js +462 -21
  36. package/dist/agents/runner.js.map +1 -1
  37. package/dist/phases.js +13 -13
  38. package/dist/phases.js.map +1 -1
  39. package/dist/source-inbox.js +297 -0
  40. package/dist/source-inbox.js.map +1 -0
  41. package/dist/version.js +3 -9
  42. package/dist/version.js.map +1 -1
  43. package/dist/yaml-io.js +42 -11
  44. package/dist/yaml-io.js.map +1 -1
  45. package/package.json +26 -23
  46. package/cli-update-docs/JOB-STORY-MIGRATION.md +0 -99
  47. package/dist/App.js +0 -220
  48. package/dist/App.js.map +0 -1
  49. package/dist/colors.js +0 -132
  50. package/dist/colors.js.map +0 -1
  51. package/dist/components/Header.js +0 -15
  52. package/dist/components/Header.js.map +0 -1
  53. package/dist/components/KeyHints.js +0 -17
  54. package/dist/components/KeyHints.js.map +0 -1
  55. package/dist/components/LabeledBox.js +0 -53
  56. package/dist/components/LabeledBox.js.map +0 -1
  57. package/dist/components/NextSteps.js +0 -18
  58. package/dist/components/NextSteps.js.map +0 -1
  59. package/dist/index.js +0 -17
  60. package/dist/index.js.map +0 -1
  61. package/dist/screens/AgentSetup.js +0 -224
  62. package/dist/screens/AgentSetup.js.map +0 -1
  63. package/dist/screens/BootSplash.js +0 -163
  64. package/dist/screens/BootSplash.js.map +0 -1
  65. package/dist/screens/CompileView.js +0 -567
  66. package/dist/screens/CompileView.js.map +0 -1
  67. package/dist/screens/FramingConfirmReview.js +0 -197
  68. package/dist/screens/FramingConfirmReview.js.map +0 -1
  69. package/dist/screens/Gate1Review.js +0 -147
  70. package/dist/screens/Gate1Review.js.map +0 -1
  71. package/dist/screens/Gate2Review.js +0 -162
  72. package/dist/screens/Gate2Review.js.map +0 -1
  73. package/dist/screens/ImportPrompt.js +0 -173
  74. package/dist/screens/ImportPrompt.js.map +0 -1
  75. package/dist/screens/JsSetup.js +0 -77
  76. package/dist/screens/JsSetup.js.map +0 -1
  77. package/dist/screens/LocalFilePicker.js +0 -139
  78. package/dist/screens/LocalFilePicker.js.map +0 -1
  79. package/dist/screens/NewMixtapeWizard.js +0 -662
  80. package/dist/screens/NewMixtapeWizard.js.map +0 -1
  81. package/dist/screens/PhaseRunner.js +0 -1203
  82. package/dist/screens/PhaseRunner.js.map +0 -1
  83. package/dist/screens/ProcessManifest.js +0 -160
  84. package/dist/screens/ProcessManifest.js.map +0 -1
  85. package/dist/screens/ProjectHub.js +0 -557
  86. package/dist/screens/ProjectHub.js.map +0 -1
  87. package/dist/screens/SourceModal.js +0 -269
  88. package/dist/screens/SourceModal.js.map +0 -1
  89. package/dist/screens/TapeBrowser.js +0 -312
  90. package/dist/screens/TapeBrowser.js.map +0 -1
  91. package/dist/screens/TapeDraftReview.js +0 -444
  92. package/dist/screens/TapeDraftReview.js.map +0 -1
  93. package/dist/screens/TapeEditor.js +0 -310
  94. package/dist/screens/TapeEditor.js.map +0 -1
  95. package/dist/screens/cassette-art.js +0 -72
  96. package/dist/screens/cassette-art.js.map +0 -1
  97. package/dist/store.js +0 -10
  98. package/dist/store.js.map +0 -1
@@ -1,5 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { buildPhasePrompt } from "./prompts.js";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { validatePhaseArtifact } from "./artifact-validation.js";
5
+ import { groupCandidateLonglist, readCandidateLonglist } from "./candidate-longlist.js";
6
+ import { ensureEvaluationArtifact, validateEvaluationFragment } from "./evaluation-assembly.js";
7
+ import { buildEvaluationSectionPrompt, buildPhasePrompt, WORKSPACE_DISCIPLINE } from "./prompts.js";
3
8
  import { LineBuffer, parseLine } from "./parsers.js";
4
9
  import { closeRunLog, openRunLog } from "./run-log.js";
5
10
  import { modelForPhase } from "./models.js";
@@ -8,6 +13,7 @@ import { readConfig } from "../config.js";
8
13
  // unreachable. Generous enough to cover a cold CLI start + auth refresh, short
9
14
  // enough that a dead subscription doesn't hang the TUI indefinitely.
10
15
  const WATCHDOG_MS = 15_000;
16
+ const DEFAULT_EVALUATION_CLOSURE_IDLE_MS = 120_000;
11
17
  /**
12
18
  * Spawn the configured agent CLI with a phase-specific prompt. The agent's
13
19
  * newline-delimited JSON stdout is parsed into AgentEvents so the PhaseRunner
@@ -17,6 +23,9 @@ const WATCHDOG_MS = 15_000;
17
23
  * `buildPhasePrompt` and labels the audit log with the phase id.
18
24
  */
19
25
  export function runPhaseWithAgent(args) {
26
+ if (args.phaseId === "evaluation" && (!args.resume || hasSectionedEvaluationState(args.project))) {
27
+ return runEvaluationSectionsWithAgent(args);
28
+ }
20
29
  const prompt = buildPhasePrompt({
21
30
  phaseId: args.phaseId,
22
31
  project: args.project,
@@ -28,7 +37,7 @@ export function runPhaseWithAgent(args) {
28
37
  // run; gates don't reach here either.
29
38
  const overrides = readConfig().models?.[args.agent.id];
30
39
  const model = modelForPhase(args.agent.id, args.phaseId, overrides);
31
- return runAgentTask({
40
+ const handle = runAgentTask({
32
41
  agent: args.agent,
33
42
  project: args.project,
34
43
  skillPath: args.skillPath,
@@ -38,6 +47,129 @@ export function runPhaseWithAgent(args) {
38
47
  resume: args.resume,
39
48
  onEvent: args.onEvent,
40
49
  });
50
+ return {
51
+ cancel: handle.cancel,
52
+ done: handle.done.then((result) => {
53
+ if (result.code !== 0)
54
+ return result;
55
+ const validation = ensurePhaseArtifact(args.project, args.phaseId);
56
+ if (validation.ok) {
57
+ if ("message" in validation && validation.message) {
58
+ args.onEvent({ kind: "raw", text: `[liner] ${validation.message}` });
59
+ }
60
+ return result;
61
+ }
62
+ const message = `[liner] Artifact validation failed after ${args.phaseId}: ${validation.message}`;
63
+ args.onEvent({ kind: "text", text: message });
64
+ return {
65
+ code: 1,
66
+ stderr: result.stderr ? `${result.stderr}\n${message}` : message,
67
+ };
68
+ }),
69
+ };
70
+ }
71
+ function runEvaluationSectionsWithAgent(args) {
72
+ if (args.resume) {
73
+ const existingEvaluation = ensureEvaluationArtifact(args.project);
74
+ if (existingEvaluation.ok) {
75
+ const message = existingEvaluation.message || "Evaluation artifact already complete.";
76
+ args.onEvent({ kind: "raw", text: `[liner] ${message}` });
77
+ return { cancel: () => { }, done: Promise.resolve({ code: 0, stderr: "" }) };
78
+ }
79
+ }
80
+ const groups = groupCandidateLonglist(readCandidateLonglist(join(args.project, "working/02-candidate-longlist.md")));
81
+ if (groups.length === 0) {
82
+ const message = "[liner] Cannot run Evaluation: working/02-candidate-longlist.md has no URL candidates.";
83
+ args.onEvent({ kind: "raw", text: message });
84
+ return { cancel: () => { }, done: Promise.resolve({ code: 1, stderr: message }) };
85
+ }
86
+ const overrides = readConfig().models?.[args.agent.id];
87
+ const model = modelForPhase(args.agent.id, "evaluation", overrides);
88
+ let cancelCurrent = () => { };
89
+ let cancelled = false;
90
+ const done = (async () => {
91
+ markSectionedEvaluationStarted(args.project, groups.length);
92
+ args.onEvent({
93
+ kind: "raw",
94
+ text: `[liner] Running Evaluation in ${groups.length} section chunk${groups.length === 1 ? "" : "s"}.`,
95
+ });
96
+ for (const group of groups) {
97
+ if (cancelled)
98
+ return { code: 1, stderr: "[liner] Evaluation cancelled." };
99
+ const fragment = validateEvaluationFragment(join(args.project, group.fragmentPath), group.candidates);
100
+ if (fragment.ok) {
101
+ args.onEvent({
102
+ kind: "raw",
103
+ text: `[liner] Evaluation chunk ${group.index}/${group.total} already complete: ` +
104
+ `${group.section} (${fragment.count} candidates).`,
105
+ });
106
+ continue;
107
+ }
108
+ args.onEvent({
109
+ kind: "raw",
110
+ text: `[liner] Evaluation chunk ${group.index}/${group.total}: ` +
111
+ `${group.section} (${group.candidates.length} candidates).`,
112
+ });
113
+ const handle = runAgentTask({
114
+ agent: args.agent,
115
+ project: args.project,
116
+ skillPath: args.skillPath,
117
+ prompt: buildEvaluationSectionPrompt({
118
+ project: args.project,
119
+ skillPath: args.skillPath,
120
+ tape: args.tape,
121
+ group,
122
+ }),
123
+ model,
124
+ taskLabel: "evaluation",
125
+ onEvent: args.onEvent,
126
+ });
127
+ cancelCurrent = handle.cancel;
128
+ const result = await handle.done;
129
+ if (result.code !== 0)
130
+ return result;
131
+ }
132
+ const validation = ensureEvaluationArtifact(args.project);
133
+ if (!validation.ok) {
134
+ const message = `[liner] Artifact validation failed after evaluation: ${validation.message}`;
135
+ args.onEvent({ kind: "text", text: message });
136
+ return { code: 1, stderr: message };
137
+ }
138
+ if (validation.message) {
139
+ args.onEvent({ kind: "raw", text: `[liner] ${validation.message}` });
140
+ }
141
+ return { code: 0, stderr: "" };
142
+ })();
143
+ return {
144
+ cancel: () => {
145
+ cancelled = true;
146
+ cancelCurrent();
147
+ },
148
+ done,
149
+ };
150
+ }
151
+ function hasSectionedEvaluationState(project) {
152
+ if (existsSync(join(project, "working/evaluation-decisions/.liner-sectioned-evaluation.json"))) {
153
+ return true;
154
+ }
155
+ const dir = join(project, "working/evaluation-decisions");
156
+ if (!existsSync(dir))
157
+ return false;
158
+ return readdirSync(dir).some((file) => /\.ya?ml$/i.test(file));
159
+ }
160
+ function markSectionedEvaluationStarted(project, chunkCount) {
161
+ const dir = join(project, "working/evaluation-decisions");
162
+ mkdirSync(dir, { recursive: true });
163
+ writeFileSync(join(dir, ".liner-sectioned-evaluation.json"), JSON.stringify({
164
+ version: 1,
165
+ chunkCount,
166
+ updatedAt: new Date().toISOString(),
167
+ }, null, 2) + "\n", "utf8");
168
+ }
169
+ function ensurePhaseArtifact(project, phaseId) {
170
+ if (phaseId !== "evaluation")
171
+ return validatePhaseArtifact(project, phaseId);
172
+ return ensureEvaluationArtifact(project);
41
173
  }
42
174
  /**
43
175
  * Run a single agent task with a caller-supplied prompt. Used by methodology
@@ -50,11 +182,14 @@ export function runAgentTask(args) {
50
182
  // Delegating cancel: a model-rejection fallback re-spawns under the hood, so
51
183
  // the handle's cancel must target whichever child is currently live.
52
184
  let cancelCurrent = () => { };
53
- const done = runAttempt(args, false, (c) => {
185
+ const done = runAttempt(args, {}, (c) => {
54
186
  cancelCurrent = c;
55
187
  });
56
188
  return { cancel: () => cancelCurrent(), done };
57
189
  }
190
+ const CLAUDE_METHOD_TOOLS = "Read Write Edit Glob Grep WebFetch";
191
+ const CLAUDE_METHOD_BUILTIN_TOOLS = "Read,Write,Edit,Glob,Grep,WebFetch";
192
+ const CLAUDE_HARD_DENIED_TOOLS = "Bash Task WebSearch ToolSearch";
58
193
  /**
59
194
  * One spawn attempt. On a model-rejection failure — and only on a fresh,
60
195
  * model-pinned run — it warns the curator with the exact fix and retries once
@@ -62,10 +197,25 @@ export function runAgentTask(args) {
62
197
  * degrades to "ran on the default" instead of a hard failure. `registerCancel`
63
198
  * is re-invoked per attempt so the caller's cancel always hits the live child.
64
199
  */
65
- function runAttempt(args, isModelFallback, registerCancel) {
200
+ function runAttempt(args, attempt, registerCancel) {
66
201
  const command = args.agent.bin;
67
202
  const resume = args.resume === true;
68
- const cliArgs = buildArgs(args.agent.id, args.project, args.skillPath, resume, args.model);
203
+ const resumeSessionId = args.agent.id === "codex" && resume
204
+ ? findLatestCodexSessionId(args.project, args.taskLabel)
205
+ : undefined;
206
+ if (args.agent.id === "codex" && resume && !resumeSessionId) {
207
+ const message = `[liner] Cannot resume Codex ${args.taskLabel}: no prior Codex session id was found ` +
208
+ `in .liner-runs/${args.taskLabel}. Retry this phase from a fresh run instead.`;
209
+ args.onEvent({ kind: "raw", text: message });
210
+ const log = openRunLog(args.project, args.taskLabel, {
211
+ agent: args.agent.id,
212
+ resume,
213
+ });
214
+ log.write(JSON.stringify({ type: "_liner_error", message }));
215
+ closeRunLog(log, 1, message.length);
216
+ return Promise.resolve({ code: 1, stderr: message });
217
+ }
218
+ const cliArgs = buildArgs(args.agent.id, args.project, args.skillPath, resume, args.model, resumeSessionId);
69
219
  const child = spawn(command, cliArgs, {
70
220
  stdio: ["pipe", "pipe", "pipe"],
71
221
  cwd: args.project,
@@ -101,7 +251,7 @@ function runAttempt(args, isModelFallback, registerCancel) {
101
251
  // re-shipping the full prompt — that's the whole point of resume (cheaper,
102
252
  // no re-fetching).
103
253
  const stdinPayload = resume
104
- ? "Continue from where you left off. Pick up the same task; do not re-do tool calls you already completed."
254
+ ? args.resumePrompt ?? resumePromptForTask(args.taskLabel)
105
255
  : args.prompt;
106
256
  child.stdin?.write(stdinPayload, "utf8");
107
257
  child.stdin?.end();
@@ -140,6 +290,119 @@ function runAttempt(args, isModelFallback, registerCancel) {
140
290
  sawOutput = true;
141
291
  clearTimeout(watchdog);
142
292
  };
293
+ const closureIdleMs = evaluationClosureIdleMs();
294
+ const canCloseEvaluationArtifact = args.taskLabel === "evaluation" &&
295
+ !attempt.artifactClosure &&
296
+ closureIdleMs > 0;
297
+ let evaluationClosureTimer;
298
+ let evaluationClosureTimedOut = false;
299
+ let childClosed = false;
300
+ let forcedEvaluationResult;
301
+ const activeTools = new Set();
302
+ const clearEvaluationClosureTimer = () => {
303
+ if (!evaluationClosureTimer)
304
+ return;
305
+ clearTimeout(evaluationClosureTimer);
306
+ evaluationClosureTimer = undefined;
307
+ };
308
+ const scheduleEvaluationClosureCheck = () => {
309
+ if (!canCloseEvaluationArtifact || cancelled || child.killed || childClosed)
310
+ return;
311
+ clearEvaluationClosureTimer();
312
+ evaluationClosureTimer = setTimeout(() => {
313
+ if (cancelled || child.killed)
314
+ return;
315
+ if (activeTools.size > 0) {
316
+ scheduleEvaluationClosureCheck();
317
+ return;
318
+ }
319
+ const validation = ensureEvaluationArtifact(args.project);
320
+ if (validation.ok) {
321
+ forcedEvaluationResult = {
322
+ code: 0,
323
+ message: "[liner] Evaluation artifact is valid; stopping the quiet agent turn.",
324
+ };
325
+ args.onEvent({ kind: "raw", text: forcedEvaluationResult.message });
326
+ log.write(JSON.stringify({
327
+ type: "_liner_artifact_closure_complete",
328
+ phase: "evaluation",
329
+ ms: closureIdleMs,
330
+ }));
331
+ child.kill("SIGTERM");
332
+ return;
333
+ }
334
+ evaluationClosureTimedOut = true;
335
+ const idleLabel = closureIdleMs >= 1000 ? `${Math.round(closureIdleMs / 1000)}s` : `${closureIdleMs}ms`;
336
+ const message = `[liner] Evaluation has been quiet for ${idleLabel} ` +
337
+ `with no valid working/03-evaluation.yaml. Stopping this turn and resuming once ` +
338
+ `with a write-only artifact closure prompt.`;
339
+ args.onEvent({ kind: "raw", text: message });
340
+ log.write(JSON.stringify({
341
+ type: "_liner_artifact_closure_timeout",
342
+ phase: "evaluation",
343
+ ms: closureIdleMs,
344
+ validation: validation.message,
345
+ }));
346
+ child.kill("SIGTERM");
347
+ }, closureIdleMs);
348
+ evaluationClosureTimer.unref?.();
349
+ };
350
+ const noteAgentEvent = (event) => {
351
+ if (!canCloseEvaluationArtifact)
352
+ return;
353
+ if (event.kind === "tool_start" && event.id) {
354
+ activeTools.add(event.id);
355
+ scheduleEvaluationClosureCheck();
356
+ return;
357
+ }
358
+ if (event.kind === "tool_done" && event.id) {
359
+ activeTools.delete(event.id);
360
+ scheduleEvaluationClosureCheck();
361
+ return;
362
+ }
363
+ if (event.kind === "summary") {
364
+ clearEvaluationClosureTimer();
365
+ return;
366
+ }
367
+ scheduleEvaluationClosureCheck();
368
+ };
369
+ if (args.taskLabel === "evaluation" && attempt.artifactClosure && closureIdleMs > 0) {
370
+ clearEvaluationClosureTimer();
371
+ evaluationClosureTimer = setTimeout(() => {
372
+ if (cancelled || child.killed || childClosed)
373
+ return;
374
+ const validation = ensureEvaluationArtifact(args.project);
375
+ const idleLabel = closureIdleMs >= 1000 ? `${Math.round(closureIdleMs / 1000)}s` : `${closureIdleMs}ms`;
376
+ if (validation.ok) {
377
+ forcedEvaluationResult = {
378
+ code: 0,
379
+ message: `[liner] Evaluation artifact closure produced a valid YAML file within ${idleLabel}; stopping the idle closure turn.`,
380
+ };
381
+ args.onEvent({ kind: "raw", text: forcedEvaluationResult.message });
382
+ log.write(JSON.stringify({
383
+ type: "_liner_artifact_closure_complete",
384
+ phase: "evaluation",
385
+ ms: closureIdleMs,
386
+ }));
387
+ child.kill("SIGTERM");
388
+ return;
389
+ }
390
+ forcedEvaluationResult = {
391
+ code: 1,
392
+ message: `[liner] Evaluation artifact closure timed out after ${idleLabel}: ${validation.message}. ` +
393
+ "Retry Evaluation fresh or reduce the candidate set before continuing.",
394
+ };
395
+ args.onEvent({ kind: "raw", text: forcedEvaluationResult.message });
396
+ log.write(JSON.stringify({
397
+ type: "_liner_artifact_closure_failed",
398
+ phase: "evaluation",
399
+ ms: closureIdleMs,
400
+ validation: validation.message,
401
+ }));
402
+ child.kill("SIGTERM");
403
+ }, closureIdleMs);
404
+ evaluationClosureTimer.unref?.();
405
+ }
143
406
  const buffer = new LineBuffer();
144
407
  if (child.stdout) {
145
408
  child.stdout.setEncoding("utf8");
@@ -150,6 +413,7 @@ function runAttempt(args, isModelFallback, registerCancel) {
150
413
  for (const line of lines) {
151
414
  log.write(line);
152
415
  for (const event of parseLine(args.agent.id, line)) {
416
+ noteAgentEvent(event);
153
417
  args.onEvent(event);
154
418
  }
155
419
  }
@@ -162,14 +426,18 @@ function runAttempt(args, isModelFallback, registerCancel) {
162
426
  noteOutput();
163
427
  noteForDetection(chunk);
164
428
  stderr += chunk;
165
- // Surface stderr as raw events so users see install/auth hints inline.
429
+ // Surface meaningful stderr as raw events so users see install/auth
430
+ // hints inline, while keeping repetitive cosmetic Codex startup warnings
431
+ // out of the live TUI. The full stream is still recorded in the run log.
166
432
  for (const line of chunk.split("\n")) {
167
433
  const trimmed = line.trim();
168
434
  if (trimmed) {
169
- args.onEvent({ kind: "raw", text: trimmed });
170
435
  // Record stderr in the log too — wrapped so it doesn't masquerade
171
436
  // as a real JSON event when the log is replayed.
172
437
  log.write(JSON.stringify({ type: "_liner_stderr", text: trimmed }));
438
+ if (shouldSurfaceAgentStderr(args.agent.id, trimmed)) {
439
+ args.onEvent({ kind: "raw", text: trimmed });
440
+ }
173
441
  }
174
442
  }
175
443
  });
@@ -177,14 +445,40 @@ function runAttempt(args, isModelFallback, registerCancel) {
177
445
  return new Promise((resolve) => {
178
446
  child.on("close", (code) => {
179
447
  clearTimeout(watchdog);
448
+ clearEvaluationClosureTimer();
449
+ childClosed = true;
180
450
  // Flush any trailing partial line.
181
451
  for (const line of buffer.flush()) {
182
452
  log.write(line);
183
453
  for (const event of parseLine(args.agent.id, line)) {
454
+ noteAgentEvent(event);
184
455
  args.onEvent(event);
185
456
  }
186
457
  }
187
458
  closeRunLog(log, code, stderr.length);
459
+ if (forcedEvaluationResult) {
460
+ resolve({
461
+ code: forcedEvaluationResult.code,
462
+ stderr: forcedEvaluationResult.code === 0
463
+ ? stderr
464
+ : stderr
465
+ ? `${stderr}\n${forcedEvaluationResult.message}`
466
+ : forcedEvaluationResult.message,
467
+ });
468
+ return;
469
+ }
470
+ if (evaluationClosureTimedOut && !cancelled) {
471
+ args.onEvent({
472
+ kind: "raw",
473
+ text: "[liner] Resuming Evaluation once to close working/03-evaluation.yaml. No more source fetching in this pass.",
474
+ });
475
+ resolve(runAttempt({
476
+ ...args,
477
+ resume: true,
478
+ resumePrompt: evaluationArtifactClosurePrompt(),
479
+ }, { artifactClosure: true }, registerCancel));
480
+ return;
481
+ }
188
482
  // Model-rejection fallback. If a fresh, model-pinned run failed because
189
483
  // the agent didn't recognize the model (renamed / retired / mistyped in
190
484
  // config), warn the curator with the exact fix, then retry once on the
@@ -194,7 +488,7 @@ function runAttempt(args, isModelFallback, registerCancel) {
194
488
  if (code !== 0 &&
195
489
  !cancelled &&
196
490
  !resume &&
197
- !isModelFallback &&
491
+ !attempt.modelFallback &&
198
492
  args.model &&
199
493
  looksLikeModelRejection(outputTail, args.model)) {
200
494
  args.onEvent({
@@ -205,13 +499,14 @@ function runAttempt(args, isModelFallback, registerCancel) {
205
499
  `~/.liner/config.yaml, or remove it to keep the default.`,
206
500
  });
207
501
  log.write(JSON.stringify({ type: "_liner_model_fallback", model: args.model }));
208
- resolve(runAttempt({ ...args, model: undefined }, true, registerCancel));
502
+ resolve(runAttempt({ ...args, model: undefined }, { modelFallback: true }, registerCancel));
209
503
  return;
210
504
  }
211
505
  resolve({ code, stderr });
212
506
  });
213
507
  child.on("error", (e) => {
214
508
  clearTimeout(watchdog);
509
+ clearEvaluationClosureTimer();
215
510
  args.onEvent({ kind: "raw", text: `[runner error] ${e.message}` });
216
511
  log.write(JSON.stringify({ type: "_liner_error", message: e.message }));
217
512
  closeRunLog(log, 1, stderr.length);
@@ -219,15 +514,56 @@ function runAttempt(args, isModelFallback, registerCancel) {
219
514
  });
220
515
  });
221
516
  }
222
- function buildArgs(id, project, skillPath, resume, model) {
517
+ export function resumePromptForTask(taskLabel) {
518
+ const base = "Continue from where you left off. Pick up the same task; do not re-do tool calls you already completed." +
519
+ `\n\n${WORKSPACE_DISCIPLINE}`;
520
+ if (taskLabel === "candidates") {
521
+ return `${base}\n\nPhase 2 reminder: stop any open-ended web/search loop. If working/05-operating-fit-audit.md exists with status: improvement_recommended, treat this as a focused improvement pass and search for the missing source roles named there. Otherwise use the verified URLs, curator-provided tape sources, titles, and reasons already gathered in this conversation. Write working/02-candidate-longlist.md now, grouped by the knowledge-map sections, replacing the placeholder completely. Do not run more than one final targeted search for a genuinely empty section; if a section is still thin, document the best verified candidates you have and stop after writing the file.`;
522
+ }
523
+ if (taskLabel === "evaluation") {
524
+ return `${base}\n\nPhase 4 reminder: do not get stuck chasing unavailable content. For any candidate whose content is still unavailable, make at most one more recovery attempt, then evaluate from verified metadata, the Phase 2 reason, and any partial content. Every candidate still needs a keep/trim/drop decision. Kept/trim entries need rating, jtbd_fit (direct/bridge/background), section, rationale, fetch_status (readable/partial only), content_quality (high/medium only), at least two content-specific evidence bullets, and the three-part curator note. Do not keep sources from URL/title/search snippets/model memory alone. Prefer section-sized fragments under working/evaluation-decisions/ so Liner can assemble working/03-evaluation.yaml; if you write the final file directly, do not say you are ready to write the artifact before writing it.`;
525
+ }
526
+ if (taskLabel === "quality") {
527
+ return `${base}\n\nPhase 5 reminder: stop any open-ended web/search loop. Quality is a bounded audit, not fresh discovery. Use the existing kept/trim decisions in working/03-evaluation.yaml, assign missing jtbd_fit and source kinds from the title/note/section/source type, repair weak curator notes from existing evidence, spend no more external search attempts, and write working/04-quality-checks.md now with Test 0 core-action fit, Tests 1-6, Perspectives audit, the kind distribution, and the note-quality check. If a material source-role gap remains, write working/05-operating-fit-audit.md with status: improvement_recommended and concrete search lanes for a focused second pass; do not call the corpus ready with a limitation.`;
528
+ }
529
+ return base;
530
+ }
531
+ export function evaluationArtifactClosurePrompt() {
532
+ return [
533
+ "Continue Phase 4, but switch to artifact closure only.",
534
+ "",
535
+ WORKSPACE_DISCIPLINE,
536
+ "",
537
+ "Do not fetch, search, or read external sources in this pass. Use the evidence, metadata, Phase 2 rationale, and partial content already gathered in this conversation.",
538
+ "Write complete section-sized YAML fragments under working/evaluation-decisions/ now, or write working/03-evaluation.yaml directly if it is small enough.",
539
+ "Every URL candidate in working/02-candidate-longlist.md must appear exactly once across those fragments or in the final file.",
540
+ "Use decision: dropped for uncertain, duplicate, unavailable, or metadata-only candidates unless the prior evidence clearly supports kept or trim.",
541
+ "For kept/trim entries include rating, jtbd_fit (direct/bridge/background), fetch_status (readable/partial only), content_quality (high/medium only), at least two content-specific evidence bullets, section, rationale, and the three-part curator note. For dropped entries include URL, title, decision, section if known, and one rationale sentence.",
542
+ "Do not keep sources from URL/title/search snippets/model memory alone; if evidence is not already in the conversation, mark the candidate dropped in this closure pass.",
543
+ "After writing fragments, Liner will assemble working/03-evaluation.yaml; if you write the final file yourself, parse/check it before the required Phase 4 final message.",
544
+ ].join("\n");
545
+ }
546
+ export function evaluationClosureIdleMs(env = process.env) {
547
+ const raw = env["LINER_EVALUATION_CLOSURE_IDLE_MS"];
548
+ if (raw == null || raw.trim() === "")
549
+ return DEFAULT_EVALUATION_CLOSURE_IDLE_MS;
550
+ const parsed = Number(raw);
551
+ if (!Number.isFinite(parsed) || parsed < 0)
552
+ return DEFAULT_EVALUATION_CLOSURE_IDLE_MS;
553
+ return parsed;
554
+ }
555
+ export function buildArgs(id, project, skillPath, resume, model, resumeSessionId) {
223
556
  if (id === "claude") {
224
557
  // Stream-json requires --verbose. --include-partial-messages would give
225
558
  // us mid-message chunking but the cost is ~10× more events; we let the
226
559
  // assistant message arrive as one block per turn instead.
227
560
  //
228
- // Tool whitelist keeps the agent's reach scoped even with permissions
229
- // skipped — Read/Write/Edit/Glob/Grep/WebFetch is exactly what the
230
- // methodology needs and nothing more (no Bash, no Task).
561
+ // Tool scoping keeps the agent's reach bounded even with permissions
562
+ // skipped. --tools constrains Claude's built-in tool surface shown in the
563
+ // init stream, while --allowedTools/--disallowedTools guard permission
564
+ // checks and any globally-provided names that may appear despite the
565
+ // built-in list. The methodology needs local file reads/writes plus
566
+ // targeted fetches; it should not shell out, launch subagents, or web-search.
231
567
  //
232
568
  // `--continue` (resume mode) picks up the most recent session in the
233
569
  // current working directory. Since we spawn with cwd=project, "most
@@ -247,8 +583,13 @@ function buildArgs(id, project, skillPath, resume, model) {
247
583
  skillPath,
248
584
  "--strict-mcp-config",
249
585
  "--dangerously-skip-permissions",
586
+ "--tools",
587
+ CLAUDE_METHOD_BUILTIN_TOOLS,
250
588
  "--allowedTools",
251
- "Read Write Edit Glob Grep WebFetch",
589
+ CLAUDE_METHOD_TOOLS,
590
+ "--disallowedTools",
591
+ CLAUDE_HARD_DENIED_TOOLS,
592
+ "--disable-slash-commands",
252
593
  ];
253
594
  // Apply the per-phase model on fresh runs only — a resumed session keeps
254
595
  // the model it was started with, and `--model` alongside `--continue`
@@ -261,17 +602,17 @@ function buildArgs(id, project, skillPath, resume, model) {
261
602
  }
262
603
  if (id === "codex") {
263
604
  if (resume) {
264
- // Codex's `exec resume` subcommand resumes the most recent thread.
605
+ // Codex's `exec resume` subcommand has a narrower option set than fresh
606
+ // exec: there is no --cd or --sandbox flag here. Use Liner's recorded
607
+ // session id instead of --last so a resume cannot pick up unrelated
608
+ // Codex work from the user's machine.
609
+ const sessionArg = resumeSessionId ? [resumeSessionId] : ["--last"];
265
610
  return [
266
611
  "exec",
267
612
  "resume",
268
- "--last",
269
- "--cd",
270
- project,
271
613
  "--skip-git-repo-check",
272
- "-s",
273
- "workspace-write",
274
614
  "--json",
615
+ ...sessionArg,
275
616
  "-",
276
617
  ];
277
618
  }
@@ -291,6 +632,78 @@ function buildArgs(id, project, skillPath, resume, model) {
291
632
  }
292
633
  return [];
293
634
  }
635
+ export function findLatestCodexSessionId(project, taskLabel) {
636
+ const dir = join(project, ".liner-runs", taskLabel.replace(/[^a-zA-Z0-9_-]/g, "-") || "task");
637
+ if (!existsSync(dir))
638
+ return undefined;
639
+ const files = readdirSync(dir)
640
+ .filter((name) => name.endsWith(".jsonl"))
641
+ .map((name) => join(dir, name))
642
+ .filter((path) => {
643
+ try {
644
+ return statSync(path).isFile();
645
+ }
646
+ catch {
647
+ return false;
648
+ }
649
+ })
650
+ .sort((a, b) => {
651
+ try {
652
+ return statSync(b).mtimeMs - statSync(a).mtimeMs;
653
+ }
654
+ catch {
655
+ return 0;
656
+ }
657
+ });
658
+ const fallback = [];
659
+ for (const file of files) {
660
+ const candidate = codexSessionIdFromRunLog(file, false);
661
+ if (candidate)
662
+ return candidate;
663
+ const resumedCandidate = codexSessionIdFromRunLog(file, true);
664
+ if (resumedCandidate)
665
+ fallback.push(resumedCandidate);
666
+ }
667
+ return fallback[0];
668
+ }
669
+ function codexSessionIdFromRunLog(path, allowResumeLog) {
670
+ let metaAgent = "";
671
+ let metaResume = false;
672
+ try {
673
+ for (const line of readFileSync(path, "utf8").split("\n")) {
674
+ if (!line.trim())
675
+ continue;
676
+ let value;
677
+ try {
678
+ value = JSON.parse(line);
679
+ }
680
+ catch {
681
+ continue;
682
+ }
683
+ if (!value || typeof value !== "object")
684
+ continue;
685
+ const record = value;
686
+ if (record.type === "_liner_meta") {
687
+ metaAgent = typeof record.agent === "string" ? record.agent : "";
688
+ metaResume = record.resume === true;
689
+ continue;
690
+ }
691
+ if (record.type !== "thread.started")
692
+ continue;
693
+ if (metaAgent !== "codex")
694
+ return undefined;
695
+ if (metaResume && !allowResumeLog)
696
+ return undefined;
697
+ return typeof record.thread_id === "string" && record.thread_id.trim() !== ""
698
+ ? record.thread_id
699
+ : undefined;
700
+ }
701
+ }
702
+ catch {
703
+ return undefined;
704
+ }
705
+ return undefined;
706
+ }
294
707
  /**
295
708
  * Heuristic: did this failure come from the agent not recognizing the model?
296
709
  * Matches the common "unknown/invalid model" phrasings both CLIs emit, plus
@@ -323,4 +736,32 @@ export function looksLikeModelRejection(output, model) {
323
736
  t.includes(m) &&
324
737
  (t.includes("error") || t.includes("invalid") || t.includes("unknown") || t.includes("not found")));
325
738
  }
739
+ export function shouldSurfaceAgentStderr(agent, line) {
740
+ const value = line.trim();
741
+ if (!value)
742
+ return false;
743
+ if (agent !== "codex")
744
+ return true;
745
+ return !isNoisyCodexStderr(value);
746
+ }
747
+ function isNoisyCodexStderr(line) {
748
+ const value = line.toLowerCase();
749
+ return (isCosmeticCodexStartupWarning(line) ||
750
+ isCodexGlobalSkillLoadWarning(value) ||
751
+ isNoisyCodexMcpTransport(value) ||
752
+ value.includes("rmcp::transport::auth") ||
753
+ (value.includes("oauth") && (value.includes("mcp") || value.includes("connector"))) ||
754
+ value.includes("write_stdin failed: stdin is closed"));
755
+ }
756
+ function isCodexGlobalSkillLoadWarning(value) {
757
+ return value.includes("failed to load skill");
758
+ }
759
+ function isNoisyCodexMcpTransport(value) {
760
+ return value.includes("rmcp::transport::worker") && value.includes("transport channel closed");
761
+ }
762
+ function isCosmeticCodexStartupWarning(line) {
763
+ return (line.includes("codex_core_plugins::manifest: ignoring interface.defaultPrompt") ||
764
+ line.includes("codex_core_skills::loader: ignoring interface.icon_small") ||
765
+ line.includes("codex_core_skills::loader: ignoring interface.icon_large"));
766
+ }
326
767
  //# sourceMappingURL=runner.js.map