tickmarkr 2.6.0 → 2.6.2

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 (103) hide show
  1. package/README.md +14 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
- import { APPROVAL_ENACTS, approvalDispositionForRelease, approvalEnactment, approvalRunOwner, approve, DECISION_VERBS, newestPark, permittedDecisionVerbs, readJournalEvents, releaseForDecision, } from "../../cli/commands/approve.js";
2
+ import { APPROVAL_ENACTS, approvalDispositionForRelease, approvalEnactment, approvalRunOwner, approve, DECISION_VERBS, newestPark, parkToken, permittedDecisionVerbs, readJournalEvents, releaseForDecision, } from "../../cli/commands/approve.js";
3
3
  import { stateDirName } from "../../graph/graph.js";
4
- import { Journal } from "../../run/journal.js";
4
+ import { bindingToken, effectiveDecisions, Journal, physicalLine, recordedBinding } from "../../run/journal.js";
5
5
  const parkDiagnostic = (park) => {
6
6
  if (park.kind === "scope-request")
7
7
  return park.approveCommand ?? "scope-request requires tickmarkr approve with --files <glob,…>";
@@ -50,6 +50,8 @@ export function decisionArgv(command, { runId, by }) {
50
50
  const flag = { approve: [], waive: ["--waive"], uphold: ["--uphold"], recheck: ["--recheck"] }[command.verb];
51
51
  return [
52
52
  runId, command.taskId, ...flag,
53
+ ...(command.park === undefined ? [] : ["--park", command.park]),
54
+ ...(command.gate === undefined ? [] : ["--gate", command.gate]),
53
55
  ...(command.reviewRounds === undefined ? [] : ["--review-rounds", String(command.reviewRounds)]),
54
56
  "--by", by,
55
57
  ...(command.reason === undefined ? [] : ["--reason", command.reason]),
@@ -62,10 +64,9 @@ export const journalFileFor = (cwd, runId) => join(stateDirName(cwd), "runs", ru
62
64
  * the named verb is outside the park's table, so a confirm inset can never promise a decision the
63
65
  * command would refuse. The enactor is read from the run lock now, and read again at the receipt.
64
66
  */
65
- export function previewDecision(command, { cwd, runId, by }) {
66
- let argv;
67
+ export function previewDecision(named, { cwd, runId, by }) {
67
68
  try {
68
- argv = decisionArgv(command, { runId, by });
69
+ decisionArgv(named, { runId, by });
69
70
  }
70
71
  catch (e) {
71
72
  return { ok: false, refusal: e.message };
@@ -78,15 +79,29 @@ export function previewDecision(command, { cwd, runId, by }) {
78
79
  catch (e) {
79
80
  return { ok: false, refusal: e.message };
80
81
  }
81
- const park = newestPark(events, command.taskId, sourceIndexes);
82
+ const park = newestPark(events, named.taskId, sourceIndexes);
82
83
  if (!park)
83
- return { ok: false, refusal: `task ${command.taskId} has no park in run ${runId} — nothing to decide` };
84
+ return { ok: false, refusal: `task ${named.taskId} has no park in run ${runId} — nothing to decide` };
85
+ // OBS-1178: the argv binds the park this preview displays; approve refuses it once another opens.
86
+ const token = parkToken(park);
87
+ if (named.park !== undefined && named.park !== token)
88
+ return { ok: false, refusal: `task ${named.taskId}'s open park is ${token ?? "untimestamped"}, not ${named.park} — refresh and decide the open park` };
89
+ if (named.gate !== undefined && named.gate !== park.failedGate)
90
+ return { ok: false, refusal: `refusing mismatched gate for ${named.taskId}: --gate ${named.gate} but park ${token} failed ${park.failedGate ?? "no gate"} — nothing appended` };
91
+ const command = { ...named, ...(token === undefined ? {} : { park: token }), ...(named.verb === "waive" && named.gate === undefined && park.failedGate !== undefined ? { gate: park.failedGate } : {}) };
92
+ let argv;
93
+ try {
94
+ argv = decisionArgv(command, { runId, by });
95
+ }
96
+ catch (e) {
97
+ return { ok: false, refusal: e.message };
98
+ }
84
99
  const verbs = permittedDecisionVerbs(park);
85
100
  if (!verbs.includes(command.verb)) {
86
101
  const why = parkDiagnostic(park) ?? `permitted: ${verbs.join(", ")}`;
87
102
  return { ok: false, refusal: `${command.verb} is not a decision for ${command.taskId}'s ${park.kind ?? "unknown"} park (${why})` };
88
103
  }
89
- const released = events.slice(park.index + 1).find((e) => e.event === "task-approved" && e.taskId === command.taskId);
104
+ const released = releaseSince(events, park.index, command.taskId);
90
105
  if (released) {
91
106
  return { ok: false, refusal: `task ${command.taskId} was already released at #L${sourceIndexes[events.indexOf(released)] + 1} by ${String(released.data.by ?? "unknown")} — nothing to confirm` };
92
107
  }
@@ -100,28 +115,12 @@ export function previewDecision(command, { cwd, runId, by }) {
100
115
  };
101
116
  }
102
117
  const parkLine = (park) => `#L${park.line} ${park.kind ?? "unknown kind"}${park.failedGate ? ` · failed gate ${park.failedGate}` : ""}${park.reason ? ` · ${park.reason}` : ""}`;
103
- const quoteArg = (arg) => (/^[\w.:/@=-]+$/u.test(arg) ? arg : JSON.stringify(arg));
104
- /** What a confirmed write buys, from the command's own table — never a phrase of this file's own. */
105
- function consequenceLines(verb, park, release, disposition) {
106
- const lines = [`consequence disposition ${disposition}; appends one task-approved${release ? ` with release ${release}` : " with no release"}`];
107
- if (verb === "waive")
108
- lines.push(` marks gate ${park.failedGate} satisfied; it does NOT mark the task done`);
109
- if (verb === "recheck")
110
- lines.push(" marks no gate satisfied; the whole declared battery re-runs");
111
- if (verb === "uphold")
112
- lines.push(" sides with the reviewer; funds one fixed attempt carrying the findings");
113
- lines.push(` it will ${APPROVAL_ENACTS[disposition]}`);
114
- return lines;
115
- }
116
- /** Who enacts it — the three lock states, each stated by the command's own sentence. */
117
- export function enactmentLines(run, disposition) {
118
- const owner = run.live
119
- ? "enactment matching live daemon — pending daemon enactment at its next task boundary"
120
- : run.blockingRunId
121
- ? `enactment other live run \`${run.blockingRunId}\` holds the repository lock — recorded, not dispatched`
122
- : `enactment no live owner — recorded, not dispatched; resume required: tickmarkr resume ${run.runId}`;
123
- return [owner, ` ${approvalEnactment(disposition, run)}`];
118
+ /** The decision already open on this park, if any. OBS-1178: a refused or unsound row released nothing. */
119
+ function releaseSince(events, parkIndex, taskId) {
120
+ const effective = effectiveDecisions(events);
121
+ return events.find((e, i) => i > parkIndex && e.event === "task-approved" && e.taskId === taskId && effective.has(physicalLine(events, i)));
124
122
  }
123
+ const quoteArg = (arg) => (/^[\w.:/@=-]+$/u.test(arg) ? arg : JSON.stringify(arg));
125
124
  /** The confirm inset: run/task, the newest park line, actor/reason, exact argv, consequence, enactor. */
126
125
  export function decisionConfirmLines(preview) {
127
126
  return [
@@ -157,7 +156,7 @@ export async function executeDecision(preview, { cwd, command = approve }) {
157
156
  if (!park || park.index !== preview.park.index || park.line !== preview.park.line || park.kind !== preview.park.kind) {
158
157
  return { ok: false, preview, stale: true, refusal: `task ${named.taskId}'s park changed since preview (${park ? `now #L${park.line} ${park.kind ?? "unknown"}` : "no park now"}) — refresh and preview again` };
159
158
  }
160
- const released = before.slice(park.index + 1).find((e) => e.event === "task-approved" && e.taskId === named.taskId);
159
+ const released = releaseSince(before, park.index, named.taskId);
161
160
  if (released) {
162
161
  return { ok: false, preview, stale: true, refusal: `task ${named.taskId} was released at #L${beforeSourceIndexes[before.indexOf(released)] + 1} by ${String(released.data.by ?? "unknown")} after this preview — no second decision appended; refresh` };
163
162
  }
@@ -208,6 +207,7 @@ export async function executeDecision(preview, { cwd, command = approve }) {
208
207
  via: "cli",
209
208
  gate: named.verb === "waive" ? park.failedGate : named.verb === "uphold" ? "review" : undefined,
210
209
  reviewRoundCeiling: named.reviewRounds,
210
+ park: parkToken(park),
211
211
  };
212
212
  const actual = {
213
213
  release,
@@ -216,6 +216,7 @@ export async function executeDecision(preview, { cwd, command = approve }) {
216
216
  via: appended.event.data.via,
217
217
  gate: appended.event.data.gate,
218
218
  reviewRoundCeiling: appended.event.data.reviewRoundCeiling,
219
+ park: recordedBinding(appended.event.data.park) === undefined ? undefined : bindingToken(recordedBinding(appended.event.data.park)),
219
220
  };
220
221
  const mismatch = Object.keys(expected)
221
222
  .find((field) => actual[field] !== expected[field]);
@@ -264,8 +265,7 @@ export const initialDecisionSession = () => ({ menu: null, confirming: null, rec
264
265
  /**
265
266
  * The reducer never writes and never reads a file; it names commands. `a` opens the menu for the
266
267
  * selected park (a diagnostic park opens a menu with no verbs, stating why); ↑↓ move; Enter picks a
267
- * verb — which OPENS the confirm inset, never confirms; `y` on a confirm names the write; `n`/Esc
268
- * cancel the deepest layer. Enter on a confirm is inert by construction.
268
+ * verb opens the confirm inset; `y` names the write; `n`/Esc cancel the deepest layer. Enter on a confirm is inert.
269
269
  */
270
270
  export function applyDecisionKey(session, event, selected) {
271
271
  if (session.confirming !== null) {
@@ -315,3 +315,26 @@ export function decisionKeybar(session, selected) {
315
315
  return session.menu.verbs.length === 0 ? "Esc Close" : "↑↓ Choose · Enter Preview · Esc Close";
316
316
  return selected === undefined ? "" : "a Actions";
317
317
  }
318
+ // Consequence and enactor lines, shared by the confirm inset and the receipt above. Kept below the key
319
+ // grammar so the operator walkthrough's line citations stay put.
320
+ /** What a confirmed write buys, from the command's own table — never a phrase of this file's own. */
321
+ function consequenceLines(verb, park, release, disposition) {
322
+ const lines = [`consequence disposition ${disposition}; appends one task-approved${release ? ` with release ${release}` : " with no release"}`];
323
+ if (verb === "waive")
324
+ lines.push(` marks gate ${park.failedGate} satisfied; it does NOT mark the task done`);
325
+ if (verb === "recheck")
326
+ lines.push(" marks no gate satisfied; the whole declared battery re-runs");
327
+ if (verb === "uphold")
328
+ lines.push(" sides with the reviewer; funds one fixed attempt carrying the findings");
329
+ lines.push(` it will ${APPROVAL_ENACTS[disposition]}`);
330
+ return lines;
331
+ }
332
+ /** Who enacts it — the three lock states, each stated by the command's own sentence. */
333
+ export function enactmentLines(run, disposition) {
334
+ const owner = run.live
335
+ ? "enactment matching live daemon — pending daemon enactment at its next task boundary"
336
+ : run.blockingRunId
337
+ ? `enactment other live run \`${run.blockingRunId}\` holds the repository lock — recorded, not dispatched`
338
+ : `enactment no live owner — recorded, not dispatched; resume required: tickmarkr resume ${run.runId}`;
339
+ return [owner, ` ${approvalEnactment(disposition, run)}`];
340
+ }
@@ -115,6 +115,8 @@ export type TaskRow = {
115
115
  readonly state?: TaskState;
116
116
  readonly attempts?: number;
117
117
  readonly actor?: string;
118
+ /** Distinct merged-author channels from task-done; legacy rows say ["unknown"]. Not the actor. */
119
+ readonly authors?: readonly string[];
118
120
  readonly lastEventTime?: string;
119
121
  /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
120
122
  readonly lastEventTimestamp?: string;
@@ -1,9 +1,11 @@
1
1
  // Consolidated C1/C6 readers use the incremental lifecycle contract; legacy captures keep their API.
2
2
  export { readOperatorState as deriveOperatorState } from "../../run/operator-state.js";
3
+ import { doneAuthors } from "../../run/operator-state.js";
3
4
  import { channelKey } from "../../adapters/types.js";
4
5
  import { newestPark, permittedDecisionVerbs } from "../../cli/commands/approve.js";
5
6
  import { parseOwnedName } from "../../drivers/types.js";
6
7
  import { projectActivity } from "../../run/activity.js";
8
+ import { effectiveDecisions, withPhysicalLine } from "../../run/journal.js";
7
9
  import { isPidLive } from "../../run/lock.js";
8
10
  import { normalizeGateOutcome } from "../../run/outcome.js";
9
11
  import { projectOperatorSummary } from "../../run/operator-summary.js";
@@ -78,6 +80,15 @@ function parseSource(raw) {
78
80
  }
79
81
  return { events, defects };
80
82
  }
83
+ /**
84
+ * OBS-1178: the rows the task-state folds read. A refused or unsound decision released nothing, so its
85
+ * task-approved row is dropped here; the one decision fold keys every row by its physical line. The
86
+ * journal rows the cockpit draws keep every line as evidence.
87
+ */
88
+ function decidedEvents(events) {
89
+ const effective = effectiveDecisions(events.map((event) => withPhysicalLine(event, event.line)));
90
+ return events.filter((event) => event.event !== "task-approved" || effective.has(event.line));
91
+ }
81
92
  function assignmentFrom(value) {
82
93
  const assignment = recordValue(value);
83
94
  return assignment
@@ -276,6 +287,8 @@ function deriveTasks(events, interrupted) {
276
287
  if (state) {
277
288
  const fact = task(event.taskId, index, event.ts);
278
289
  fact.state = state;
290
+ if (event.event === "task-done")
291
+ fact.authors = doneAuthors(event.data);
279
292
  // The park kind travels with the park: `task-human` records it, and the
280
293
  // next recorded state — approved, done, failed — retires it with the park
281
294
  // it named. A resolved state must not leave its badge behind (OBS-256).
@@ -641,6 +654,7 @@ function taskRows(events, tasks, graph, runId) {
641
654
  ...(state === undefined ? {} : { state }),
642
655
  ...(attempts === 0 ? {} : { attempts }),
643
656
  ...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
657
+ ...(fact?.authors === undefined ? {} : { authors: fact.authors }),
644
658
  ...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
645
659
  ...(lastTs === undefined ? {} : { lastEventTimestamp: lastTs }),
646
660
  ...(title === undefined ? {} : { title }),
@@ -840,7 +854,8 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
840
854
  // alive in the lock — the cockpit called a live run interrupted.
841
855
  : pid !== undefined && (options.isDaemonAlive ?? isPidLive)(pid);
842
856
  const interrupted = !alive;
843
- const tasks = deriveTasks(events, interrupted);
857
+ const decided = decidedEvents(events);
858
+ const tasks = deriveTasks(decided, interrupted);
844
859
  const taskFacts = [...tasks.values()];
845
860
  const done = taskFacts.filter((task) => task.state === "done").length;
846
861
  const failed = taskFacts.filter((task) => task.state === "failed").length;
@@ -869,7 +884,7 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
869
884
  // run-end — the run's own closing statement is enough.
870
885
  const runStatus = failed > 0
871
886
  ? "failed"
872
- : human > 0 || unresolvedSummaryParks(events).length > 0
887
+ : human > 0 || unresolvedSummaryParks(decided).length > 0
873
888
  ? "parked"
874
889
  : lifecycle === "superseded" || hasInterruptedTask
875
890
  ? "interrupted"
@@ -46,5 +46,15 @@ export interface ConsolidatedOptions {
46
46
  environment?: NodeJS.ProcessEnv;
47
47
  diagnostics?: readonly HomeNeedsYouTarget[];
48
48
  }
49
+ /**
50
+ * OBS-1178: the store's incremental fold reads every task-approved row as a release. A refused or unsound
51
+ * decision released nothing, so whenever the rows the store has read hold one — whatever state the raw
52
+ * fold shows now, since a later run-end restores a park but not the gate evidence a rejected recheck
53
+ * cleared — the operator snapshot is refolded over the decided rows, each keeping its physical line and
54
+ * the store's evidence identity.
55
+ * ponytail: a whole-journal read per observed journal change (parsed only when it holds an approval);
56
+ * move the decision fold into OperatorStateFold if that ever measures slow.
57
+ */
58
+ export declare function decidedLiveStore(store: LiveStore): LiveStore;
49
59
  /** The live entry mounts the dependency-owned leaves, using only C2 snapshots/history/page. */
50
60
  export declare function runConsolidatedCockpit(options: ConsolidatedOptions): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { existsSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { renderMarkdownRecord } from "../../cli/commands/report.js";
5
5
  import { loadConfig } from "../../config/config.js";
@@ -20,9 +20,10 @@ import { createPointerReportReader, POINTER_TRACKING_ON, POINTER_TRACKING_OFF }
20
20
  import { deriveRunDecisions, previewDecision, executeDecision, withDecisionPreview, withDecisionReceipt, decisionConfirmLines } from "./decision-actions.js";
21
21
  import { approvalRunOwner } from "../../cli/commands/approve.js";
22
22
  import { GLYPHS } from "../../brand.js";
23
- import { Journal } from "../../run/journal.js";
23
+ import { effectiveEvents, Journal, parseJournalText, physicalLine } from "../../run/journal.js";
24
24
  import { observeNamedRun, WATCH_OWNER_ENV } from "../../run/supervision.js";
25
25
  import { formatOwnedName } from "../../drivers/types.js";
26
+ import { readOperatorState } from "../../run/operator-state.js";
26
27
  import { cellWidth, wrapCells } from "./width.js";
27
28
  import { resolveShellColourMode } from "./theme.js";
28
29
  let timelineUsers = 0;
@@ -58,6 +59,52 @@ export function borrowRuntimeTimeline() {
58
59
  }
59
60
  export const SHELL_TERMINAL_ENTER = "\x1b[?1049h\x1b[22;0t\x1b]0;tickmarkr cockpit\x07" + POINTER_TRACKING_ON;
60
61
  export const SHELL_TERMINAL_RESTORE = POINTER_TRACKING_OFF + "\x1b[23;0t\x1b[?25h\x1b[?1049l";
62
+ /**
63
+ * OBS-1178: the store's incremental fold reads every task-approved row as a release. A refused or unsound
64
+ * decision released nothing, so whenever the rows the store has read hold one — whatever state the raw
65
+ * fold shows now, since a later run-end restores a park but not the gate evidence a rejected recheck
66
+ * cleared — the operator snapshot is refolded over the decided rows, each keeping its physical line and
67
+ * the store's evidence identity.
68
+ * ponytail: a whole-journal read per observed journal change (parsed only when it holds an approval);
69
+ * move the decision fold into OperatorStateFold if that ever measures slow.
70
+ */
71
+ export function decidedLiveStore(store) {
72
+ let key;
73
+ let operator;
74
+ const decided = (snap) => {
75
+ const { journal, graph } = snap;
76
+ const observed = `${journal.source}:${journal.generation}:${journal.offset}:${graph.identity}:${graph.status}`;
77
+ if (observed !== key) {
78
+ key = observed;
79
+ operator = undefined;
80
+ let bytes;
81
+ try {
82
+ bytes = readFileSync(journal.source).subarray(0, journal.offset);
83
+ }
84
+ catch {
85
+ return snap;
86
+ }
87
+ if (bytes.includes("task-approved")) {
88
+ const rows = parseJournalText(bytes.subarray(0, bytes.lastIndexOf(10) + 1).toString("utf8"))
89
+ .filter(e => e !== null && typeof e === "object" && typeof e.event === "string" && typeof e.data === "object" && e.data !== null);
90
+ const kept = effectiveEvents(rows);
91
+ if (kept.length !== rows.length) {
92
+ const events = kept.map((event, i) => {
93
+ const line = physicalLine(kept, i);
94
+ return { source: journal.source, line, id: `${journal.source}#L${line}`, generation: journal.generation, event };
95
+ });
96
+ operator = readOperatorState({ events, graph: graph.status === "readable" ? graph.value : undefined,
97
+ readable: journal.status === "readable" && journal.backlogBytes === 0 });
98
+ }
99
+ }
100
+ }
101
+ if (!operator)
102
+ return snap;
103
+ const { sequence, observedAt, graphAvailability } = snap.operator;
104
+ return { ...snap, operator: { ...operator, sequence, observedAt, graphAvailability } };
105
+ };
106
+ return { ...store, snapshot: () => decided(store.snapshot()), refresh: () => decided(store.refresh()) };
107
+ }
61
108
  /** Preserve a policy-disabled gate from C2 even when C5's generic event
62
109
  * mapper calls an unpassed result unknown. Lead with the gate name so a long
63
110
  * task identity cannot clip the label that explains the disabled glyph. */
@@ -97,7 +144,7 @@ export async function runConsolidatedCockpit(options) {
97
144
  output.write(SHELL_TERMINAL_ENTER);
98
145
  }
99
146
  store = createLiveStore({ cwd, runId, now: options.now });
100
- const source = store;
147
+ const source = decidedLiveStore(store);
101
148
  if (options.observeRun !== false)
102
149
  observation = observeNamedRun(cwd, runId, options.environment);
103
150
  // BD-1 (RULING-231-19 §2): the daemon-placed board carries the watch owner token and mounts
@@ -2,6 +2,7 @@ import { type JournalEvent } from "../../run/journal.js";
2
2
  import { type OperatorRecord } from "../../run/operator-state.js";
3
3
  export declare const OBSERVATION_INTERVAL_MS = 1000;
4
4
  export declare const STORE_LIMITS: {
5
+ readonly graphBytes: number;
5
6
  readonly history: 256;
6
7
  readonly historyBytes: number;
7
8
  readonly recordBytes: number;
@@ -1,4 +1,4 @@
1
- import { closeSync, fstatSync, openSync, readFileSync, readSync, statSync } from "node:fs";
1
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { graphPath, stateDirName } from "../../graph/graph.js";
4
4
  import { validateGraph } from "../../graph/schema.js";
@@ -7,7 +7,8 @@ import { isPidLive, STALE_MS } from "../../run/lock.js";
7
7
  import { readTierLiveness, SUPERVISION_TIERS } from "../../run/supervision.js";
8
8
  import { OperatorStateFold } from "../../run/operator-state.js";
9
9
  export const OBSERVATION_INTERVAL_MS = 1_000;
10
- export const STORE_LIMITS = { history: 256, historyBytes: 2 * 1024 * 1024, recordBytes: 1024 * 1024, readBytes: 1024 * 1024, subscribers: 64, metrics: 12, errors: 32 };
10
+ // Graph declarations have a separate 16 MiB bound; journal retention remains unchanged.
11
+ export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, historyBytes: 2 * 1024 * 1024, recordBytes: 1024 * 1024, readBytes: 1024 * 1024, subscribers: 64, metrics: 12, errors: 32 };
11
12
  const errorText = (e) => e instanceof Error ? e.message : String(e);
12
13
  const fileIdentity = (st) => `${st.dev}:${st.ino}`;
13
14
  const stamp = (st) => `${fileIdentity(st)}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
@@ -192,10 +193,12 @@ export class JournalTail {
192
193
  class JsonSource {
193
194
  path;
194
195
  parse;
196
+ maxBytes;
195
197
  cached;
196
- constructor(path, parse) {
198
+ constructor(path, parse, maxBytes = STORE_LIMITS.recordBytes) {
197
199
  this.path = path;
198
200
  this.parse = parse;
201
+ this.maxBytes = maxBytes;
199
202
  }
200
203
  read(now) {
201
204
  try {
@@ -203,9 +206,29 @@ class JsonSource {
203
206
  const id = stamp(st);
204
207
  if (this.cached?.identity === id && this.cached.status === "readable")
205
208
  return this.cached = { ...this.cached, observedAt: now };
206
- if (!st.isFile() || st.size > BigInt(STORE_LIMITS.recordBytes))
207
- throw new Error("source is not a bounded regular file");
208
- const value = this.parse(readFileSync(this.path, "utf8"));
209
+ if (!st.isFile())
210
+ throw new Error("source is not a regular file");
211
+ if (st.size > BigInt(this.maxBytes))
212
+ throw new Error(`source exceeds ${this.maxBytes} byte cap`);
213
+ // Bound the read itself too: a writer can grow the file after stat.
214
+ const fd = openSync(this.path, "r");
215
+ let value;
216
+ try {
217
+ const bytes = Buffer.alloc(Number(st.size) + 1);
218
+ let length = 0;
219
+ while (length < bytes.length) {
220
+ const n = readSync(fd, bytes, length, bytes.length - length, length);
221
+ if (!n)
222
+ break;
223
+ length += n;
224
+ }
225
+ if (length !== Number(st.size) || stamp(fstatSync(fd, { bigint: true })) !== id)
226
+ throw new Error("source changed during read; retry observation");
227
+ value = this.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)));
228
+ }
229
+ finally {
230
+ closeSync(fd);
231
+ }
209
232
  return this.cached = { source: this.path, identity: id, value, status: "readable", observedAt: now };
210
233
  }
211
234
  catch (e) {
@@ -222,7 +245,7 @@ export function createLiveStore(options) {
222
245
  const tail = new JournalTail(join(state, "runs", parseRunId(options.runId), "journal.jsonl"), {
223
246
  reset: () => { fold = new OperatorStateFold(); }, record: record => fold.apply(record),
224
247
  });
225
- const graph = new JsonSource(graphPath(options.cwd), raw => validateGraph(JSON.parse(raw)));
248
+ const graph = new JsonSource(graphPath(options.cwd), raw => validateGraph(JSON.parse(raw)), STORE_LIMITS.graphBytes);
226
249
  const config = new JsonSource(options.configPath ?? join(state, "config.yaml"), raw => raw);
227
250
  const cache = new JsonSource(options.cachePath ?? join(state, "doctor.json"), JSON.parse);
228
251
  const lock = new JsonSource(join(state, "graph.lock"), raw => {
@@ -256,7 +279,7 @@ export function createLiveStore(options) {
256
279
  const readable = journal.status === "readable" && journal.backlogBytes === 0;
257
280
  return {
258
281
  sequence: ++sequence, observedAt, delayed, freshness: errors.length ? "failed" : delayed ? "delayed" : "fresh",
259
- operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable }),
282
+ operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error } }),
260
283
  journal, graph: graphReading, config: configReading, cache: cacheReading,
261
284
  lock: { ...owner, state: lockState, alive, expired: owner.identity ? observedAt - Number(owner.identity.split(":")[3]) / 1e6 > STALE_MS : undefined },
262
285
  supervision, errors, actionsEnabled: readable && !errors.length && !delayed,
@@ -34,6 +34,9 @@ export declare function deriveRunViewRows(data: RunCockpitData, viewId: RunViewI
34
34
  * task row — identity, phase, build evidence, blocker, next action — each unrecorded field saying so,
35
35
  * each recorded one citing the journal line it was read from, and the OBS-1048 stall marker when the
36
36
  * newest attempt's harvest is suspect. No attempt label is printed here: the ruler is the row's own.
37
+ * A task with no recorded dispatch folds identity, phase, and build into one missing-evidence clause
38
+ * (OBS-1104). A recorded blocker or next action — a pre-dispatch park, a dependency wait — stays,
39
+ * cited to the journal row that produced it.
37
40
  */
38
41
  export declare function taskProjectionText(row: TaskRow, journal?: readonly JournalRow[]): string;
39
42
  /**
@@ -3,8 +3,9 @@ import { Box, useApp, useStdout } from "ink";
3
3
  import { cloneElement, createContext, useContext, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from "react";
4
4
  import { GLYPHS, PLAIN_COMPACT_LOCKUP, } from "../../brand.js";
5
5
  import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, keyRosterLines, Panel, PANEL_CHROME_ROWS, ProgressMeter, Sparkline, StatTile, StatusStrip, } from "./components.js";
6
+ import { authorsNote } from "../../run/operator-state.js";
6
7
  import { fieldReading } from "./derive.js";
7
- import { STALL_MARKER } from "./run-view.js";
8
+ import { MISSING_EVIDENCE, STALL_MARKER } from "./run-view.js";
8
9
  import { initialRunInteractionState, projectRunKeyEntries, reconcileRunInteraction, RUN_SIDE_RAIL_COLUMN_FLOOR, runPanelFocusOrder, runSideRailVisible, selectableRunViewRowIds, } from "./keys.js";
9
10
  import { DECISIONS_TAB_TITLE, RUN_VIEWS, runViewById, setupSectionAt, SETUP_SECTIONS, } from "./views.js";
10
11
  import { FRAME_HEADER_GAP, FRAME_NESTED_ROW_BANDS, FULL_JOURNAL_ROWS, planFrame, RUN_PANEL_HORIZONTAL_CHROME, SIDEBAR_COLUMN_FLOOR, } from "./layout.js";
@@ -280,7 +281,7 @@ function promotedViewRows(data, viewId) {
280
281
  time: fieldReading(row.lastEventTime),
281
282
  ...(row.lastEventTimestamp === undefined ? {} : { timestamp: row.lastEventTimestamp }),
282
283
  state: taskRowState(row),
283
- text: `${row.taskId} · ${fieldReading(row.state)} · ${runAttemptLabel(row.attempts)} · ${fieldReading(row.actor)}${row.title === undefined ? "" : ` · ${row.title}`} · ${taskProjectionText(row, data.journalRows)}`,
284
+ text: `${row.taskId} · ${fieldReading(row.state)} · ${runAttemptLabel(row.attempts)} · ${fieldReading(row.actor)}${authorsNote(row.authors, row.actor) === undefined ? "" : ` · ${authorsNote(row.authors, row.actor)}`}${row.title === undefined ? "" : ` · ${row.title}`} · ${taskProjectionText(row, data.journalRows)}`,
284
285
  }));
285
286
  }
286
287
  if (viewId === "gates") {
@@ -308,8 +309,13 @@ function promotedViewRows(data, viewId) {
308
309
  * task row — identity, phase, build evidence, blocker, next action — each unrecorded field saying so,
309
310
  * each recorded one citing the journal line it was read from, and the OBS-1048 stall marker when the
310
311
  * newest attempt's harvest is suspect. No attempt label is printed here: the ruler is the row's own.
312
+ * A task with no recorded dispatch folds identity, phase, and build into one missing-evidence clause
313
+ * (OBS-1104). A recorded blocker or next action — a pre-dispatch park, a dependency wait — stays,
314
+ * cited to the journal row that produced it.
311
315
  */
312
316
  export function taskProjectionText(row, journal = []) {
317
+ if ((row.attempts ?? 0) === 0)
318
+ return undispatchedProjectionText(row, journal);
313
319
  const identity = row.identities?.at(-1);
314
320
  const blocker = row.summary?.blocker;
315
321
  const harvest = row.harvests?.at(-1);
@@ -330,6 +336,26 @@ export function taskProjectionText(row, journal = []) {
330
336
  ...(harvest?.suspectedStalledHarvest ? [`${STALL_MARKER} · launch ${at(evidence.launch)} · ${harvest.nudgeFailures} nudge failed ${at(evidence.nudge)} · ${harvest.pageCount} paged ${at(evidence.page)} · no worker-result`] : []),
331
337
  ].reduce((text, part) => `${text} · ${part}`);
332
338
  }
339
+ /**
340
+ * Identity, phase, and build are unrecorded until a dispatch. Append the blocker and next action
341
+ * only when the summary recorded one — an unknown placeholder with no journal row is the missing clause.
342
+ */
343
+ function undispatchedProjectionText(row, journal) {
344
+ const blocker = row.summary?.blocker;
345
+ if (blocker == null)
346
+ return MISSING_EVIDENCE;
347
+ const evidence = projectionEvidence(row, journal);
348
+ const at = (line) => line === undefined ? "(no journal row)" : `#L${line}`;
349
+ const showBlocker = blocker.kind !== "unknown" || evidence.blocker !== undefined;
350
+ const showNext = blocker.nextAction != null;
351
+ if (!showBlocker && !showNext)
352
+ return MISSING_EVIDENCE;
353
+ return [
354
+ MISSING_EVIDENCE,
355
+ ...(showBlocker ? [`blocker ${blocker.kind} ${at(evidence.blocker)}`] : []),
356
+ ...(showNext ? [`next ${blocker.nextAction} ${at(evidence.nextAction)}`] : []),
357
+ ].reduce((text, part) => `${text} · ${part}`);
358
+ }
333
359
  /**
334
360
  * Correlate each already-derived field with the row that can have produced that value. This is
335
361
  * intentionally not a "newest row with a plausible name" lookup: launch identity is paired by
@@ -1,7 +1,7 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { ApprovalRunOwner } from "../../cli/commands/approve.js";
3
3
  import { type GateName, type RunGraph } from "../../graph/schema.js";
4
- import type { JournalEvent } from "../../run/journal.js";
4
+ import { type JournalEvent } from "../../run/journal.js";
5
5
  import { type GateOutcome } from "../../run/outcome.js";
6
6
  import type { OperatorGateState, OperatorSnapshot, OperatorTask } from "../../run/operator-state.js";
7
7
  import { type DecisionCommand, type DecisionPreview, type DecisionSession, type RunDecision } from "./decision-actions.js";
@@ -35,10 +35,8 @@ export interface RunGateCell {
35
35
  /** The verdict text the evidence row carries, split into lines for paging. */
36
36
  readonly verdict: readonly string[];
37
37
  }
38
- /**
39
- * The current attempt's seven cells in declaration order. `rows` supplies the later journal rows
40
- * that give a cell its inherited/satisfied label; only rows for this task after the evidence line count.
41
- */
38
+ /** Current-attempt cells in declaration order; later task rows supply inherited/satisfied labels.
39
+ * Only rows after each cell's own evidence line count. */
42
40
  export declare function runGateCells(task: OperatorTask, evidence: EvidenceLookup, rows?: readonly RunEvidenceRow[]): readonly RunGateCell[];
43
41
  export declare const VERDICT_WINDOW = 10;
44
42
  export interface RunViewSession {
@@ -95,8 +93,9 @@ export interface TaskProjection {
95
93
  readonly build: ProjectionField;
96
94
  readonly blocker: ProjectionField;
97
95
  readonly nextAction: ProjectionField;
98
- /** OBS-1048: present only when the newest launched attempt has failed nudges, pages and no worker-result. */
96
+ /** OBS-1048: present only when the newest launched attempt has failed nudges, pages and no worker-result. OBS-1104: neverDispatched collapses that task's line. */
99
97
  readonly stalled?: ProjectionField;
98
+ readonly neverDispatched?: true;
100
99
  }
101
100
  export declare const STALL_MARKER = "\u26A0 stalled harvest suspected";
102
101
  /**
@@ -105,7 +104,12 @@ export declare const STALL_MARKER = "\u26A0 stalled harvest suspected";
105
104
  * Nothing is inferred from a title, a provider or a task id: an unrecorded field says so.
106
105
  */
107
106
  export declare function projectRunTasks(snapshot: OperatorSnapshot, rows: readonly RunEvidenceRow[], graph: RunGraph | undefined, decisions: readonly RunDecision[], runId: string): readonly TaskProjection[];
108
- /** One wrapped line per task: every field with its line, and the stall marker when the harvest is suspect. */
107
+ /**
108
+ * OBS-1104: a never-dispatched task has no identity, phase, or build row. Those fold into one
109
+ * missing-evidence clause. A substantive or journal-cited blocker and next action stay — a
110
+ * pre-dispatch park and a dependency wait are recorded facts, and dropping them hides the park token.
111
+ */
112
+ export declare const MISSING_EVIDENCE = "missing evidence";
109
113
  export declare const projectionLine: (p: TaskProjection) => string;
110
114
  export interface PaneLocator {
111
115
  readonly status: "recorded" | "unavailable";