tickmarkr 2.5.6 → 2.5.8

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 (59) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +13 -6
  3. package/dist/cli/commands/compile.js +7 -0
  4. package/dist/cli/commands/plan.d.ts +5 -0
  5. package/dist/cli/commands/plan.js +28 -23
  6. package/dist/cli/commands/resume.js +1 -1
  7. package/dist/cli/commands/run.js +1 -1
  8. package/dist/cli/commands/status.js +95 -34
  9. package/dist/cli/commands/verify.js +6 -4
  10. package/dist/compile/native.js +68 -7
  11. package/dist/compile/retired-literals.d.ts +22 -0
  12. package/dist/compile/retired-literals.js +271 -0
  13. package/dist/drivers/index.d.ts +4 -2
  14. package/dist/drivers/index.js +54 -6
  15. package/dist/gates/baseline.d.ts +8 -3
  16. package/dist/gates/baseline.js +6 -3
  17. package/dist/gates/review.js +12 -1
  18. package/dist/gates/run-gates.d.ts +6 -1
  19. package/dist/gates/run-gates.js +67 -9
  20. package/dist/gates/test-manifest.d.ts +12 -0
  21. package/dist/gates/test-manifest.js +29 -3
  22. package/dist/gates/test-reporter.js +28 -2
  23. package/dist/graph/graph.d.ts +2 -0
  24. package/dist/graph/graph.js +5 -0
  25. package/dist/graph/schema.d.ts +28 -0
  26. package/dist/graph/schema.js +13 -1
  27. package/dist/run/activity.d.ts +28 -0
  28. package/dist/run/activity.js +194 -0
  29. package/dist/run/daemon.d.ts +28 -0
  30. package/dist/run/daemon.js +466 -110
  31. package/dist/run/git.d.ts +6 -1
  32. package/dist/run/git.js +63 -11
  33. package/dist/run/journal.d.ts +57 -5
  34. package/dist/run/journal.js +103 -9
  35. package/dist/run/merge.d.ts +2 -0
  36. package/dist/run/merge.js +1 -0
  37. package/dist/run/operator-page-summary.d.ts +56 -0
  38. package/dist/run/operator-page-summary.js +68 -0
  39. package/dist/run/operator-summary.d.ts +69 -0
  40. package/dist/run/operator-summary.js +77 -0
  41. package/dist/run/protocol.d.ts +71 -0
  42. package/dist/run/protocol.js +32 -0
  43. package/dist/run/repair-disposition.d.ts +41 -0
  44. package/dist/run/repair-disposition.js +77 -0
  45. package/dist/tui/cockpit/board.d.ts +9 -0
  46. package/dist/tui/cockpit/board.js +10 -0
  47. package/dist/tui/cockpit/derive.d.ts +35 -0
  48. package/dist/tui/cockpit/derive.js +152 -10
  49. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  50. package/dist/tui/cockpit/evidence-view.js +42 -12
  51. package/dist/tui/cockpit/home-view.js +45 -30
  52. package/dist/tui/cockpit/live-store.d.ts +18 -0
  53. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  54. package/dist/tui/cockpit/run-cockpit.js +95 -1
  55. package/dist/tui/cockpit/run-view.d.ts +37 -0
  56. package/dist/tui/cockpit/run-view.js +189 -2
  57. package/package.json +1 -1
  58. package/schema/rungraph.schema.json +54 -0
  59. package/skills/tickmarkr-overseer/SKILL.md +162 -3
@@ -0,0 +1,77 @@
1
+ /** Labels describe blockers, not journal park kinds or commands. */
2
+ export const BLOCKER_KINDS = [
3
+ "human-decision", "retry", "cooldown", "dependency-wait", "stale-receipt",
4
+ "missing-capability", "unknown",
5
+ ];
6
+ const SETTLED = new Set(["done", "completed", "merged"]);
7
+ const emptyBlocker = (kind) => ({
8
+ kind, permittedActions: [], humanInterventionRequired: false,
9
+ decisionRequired: false, nextAction: null,
10
+ });
11
+ /** Pure projection in current-task order; no clock, host access, or retained state. */
12
+ export function projectOperatorSummary(tasks, decisions) {
13
+ const statuses = new Map(tasks.map(task => [task.id, task.status]));
14
+ const parks = new Map(decisions.map(decision => [decision.taskId, decision]));
15
+ return tasks.map(task => {
16
+ let blocker = null;
17
+ const decision = task.status === "human" ? parks.get(task.id) : undefined;
18
+ const terminal = SETTLED.has(task.status) || task.status === "failed";
19
+ if (decision) {
20
+ const { park, verbs, diagnostic } = decision;
21
+ blocker = {
22
+ ...emptyBlocker(park.tombstone ? "unknown" : "human-decision"),
23
+ permittedActions: [...verbs],
24
+ humanInterventionRequired: !park.tombstone,
25
+ decisionRequired: verbs.length > 0,
26
+ ...(diagnostic !== undefined || park.reason !== undefined
27
+ ? { diagnostic: diagnostic ?? park.reason } : {}),
28
+ nextAction: verbs.length > 0 ? `Choose a decision: ${verbs.join(", ")}` : null,
29
+ };
30
+ if (!park.tombstone && park.kind === "infra" && task.missingCapability) {
31
+ const { capability, diagnostic: capabilityDiagnostic } = task.missingCapability;
32
+ blocker.kind = "missing-capability";
33
+ blocker.capability = capability;
34
+ blocker.nextAction = `Diagnose host capability: ${capability}`;
35
+ if (diagnostic === undefined && capabilityDiagnostic !== undefined)
36
+ blocker.diagnostic = capabilityDiagnostic;
37
+ }
38
+ }
39
+ else if (task.status === "human") {
40
+ // Missing decision evidence must not make a parked task look automatic.
41
+ blocker = { ...emptyBlocker("unknown"), humanInterventionRequired: true };
42
+ }
43
+ else if (!terminal) {
44
+ const unmet = task.status === "pending" ? task.deps.filter(id => !SETTLED.has(statuses.get(id))) : [];
45
+ if (unmet.length > 0) {
46
+ blocker = {
47
+ ...emptyBlocker("dependency-wait"), prerequisites: unmet,
48
+ nextAction: `Wait for prerequisites: ${unmet.join(", ")}`,
49
+ };
50
+ }
51
+ else if (task.staleReceipt) {
52
+ blocker = {
53
+ ...emptyBlocker("stale-receipt"), diagnostic: task.staleReceipt.diagnostic,
54
+ nextAction: "Inspect the stale receipt and refresh its evidence",
55
+ };
56
+ }
57
+ else if (task.wait) {
58
+ blocker = {
59
+ ...emptyBlocker(task.wait.kind),
60
+ ...(task.wait.diagnostic === undefined ? {} : { diagnostic: task.wait.diagnostic }),
61
+ ...(task.wait.wakeAt === undefined ? {} : { wakeAt: task.wait.wakeAt }),
62
+ };
63
+ }
64
+ else if (task.status === "blocked" || task.status === "unknown") {
65
+ blocker = emptyBlocker("unknown");
66
+ }
67
+ }
68
+ const owner = task.responsible;
69
+ return {
70
+ taskId: task.id, phase: task.phase ?? null, lastEvidenceAt: task.lastEvidenceAt ?? null,
71
+ responsible: owner?.role !== undefined || owner?.agent !== undefined
72
+ ? { ...(owner.role === undefined ? {} : { role: owner.role }),
73
+ ...(owner.agent === undefined ? {} : { agent: owner.agent }) } : null,
74
+ blocker,
75
+ };
76
+ });
77
+ }
@@ -1,6 +1,77 @@
1
1
  import { z } from "zod";
2
2
  import { type GateName } from "../graph/schema.js";
3
3
  import { type GateOutcome } from "./outcome.js";
4
+ /** Shared lifecycle vocabulary for shell evidence and later build-result emitters. */
5
+ export declare const COMMAND_RECEIPT_OUTCOMES: readonly ["started", "completed", "spawn-failed", "timed-out", "cancelled", "reused-result", "skipped", "refused"];
6
+ export declare const CommandReceiptAttributionSchema: z.ZodObject<{
7
+ runId: z.ZodString;
8
+ taskId: z.ZodString;
9
+ attempt: z.ZodNumber;
10
+ gateRound: z.ZodNumber;
11
+ invocation: z.ZodString;
12
+ }, z.core.$strict>;
13
+ export type CommandReceiptAttribution = z.infer<typeof CommandReceiptAttributionSchema>;
14
+ export declare const ShellReceiptSchema: z.ZodObject<{
15
+ outcome: z.ZodEnum<{
16
+ skipped: "skipped";
17
+ started: "started";
18
+ completed: "completed";
19
+ "spawn-failed": "spawn-failed";
20
+ "timed-out": "timed-out";
21
+ cancelled: "cancelled";
22
+ "reused-result": "reused-result";
23
+ refused: "refused";
24
+ }>;
25
+ confirmedStart: z.ZodBoolean;
26
+ attribution: z.ZodOptional<z.ZodObject<{
27
+ runId: z.ZodString;
28
+ taskId: z.ZodString;
29
+ attempt: z.ZodNumber;
30
+ gateRound: z.ZodNumber;
31
+ invocation: z.ZodString;
32
+ }, z.core.$strict>>;
33
+ pid: z.ZodOptional<z.ZodNumber>;
34
+ exitCode: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
35
+ signal: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
+ error: z.ZodOptional<z.ZodString>;
37
+ durationMs: z.ZodOptional<z.ZodNumber>;
38
+ }, z.core.$strict>;
39
+ export type ShellReceipt = z.infer<typeof ShellReceiptSchema>;
40
+ /** A task-build row must carry the entire caller-owned correlation, even before spawn. */
41
+ export declare const CommandReceiptSchema: z.ZodObject<{
42
+ outcome: z.ZodEnum<{
43
+ skipped: "skipped";
44
+ started: "started";
45
+ completed: "completed";
46
+ "spawn-failed": "spawn-failed";
47
+ "timed-out": "timed-out";
48
+ cancelled: "cancelled";
49
+ "reused-result": "reused-result";
50
+ refused: "refused";
51
+ }>;
52
+ confirmedStart: z.ZodBoolean;
53
+ pid: z.ZodOptional<z.ZodNumber>;
54
+ exitCode: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
55
+ signal: z.ZodOptional<z.ZodNullable<z.ZodString>>;
56
+ error: z.ZodOptional<z.ZodString>;
57
+ durationMs: z.ZodOptional<z.ZodNumber>;
58
+ attribution: z.ZodObject<{
59
+ runId: z.ZodString;
60
+ taskId: z.ZodString;
61
+ attempt: z.ZodNumber;
62
+ gateRound: z.ZodNumber;
63
+ invocation: z.ZodString;
64
+ }, z.core.$strict>;
65
+ }, z.core.$strict>;
66
+ export type CommandReceipt = z.infer<typeof CommandReceiptSchema>;
67
+ export declare function readCommandReceipt(raw: unknown): {
68
+ kind: "receipt";
69
+ receipt: CommandReceipt;
70
+ } | {
71
+ kind: "protocol-issue";
72
+ issues: string[];
73
+ raw: unknown;
74
+ };
4
75
  /** The T31 outcome vocabulary, made executable and strict at the persistence boundary. */
5
76
  export declare const GateOutcomeSchema: z.ZodType<GateOutcome>;
6
77
  export declare const ROLE_INVOCATION_ROLES: readonly ["worker", "judge", "review", "consult"];
@@ -12,6 +12,38 @@ const TaskIdSchema = NonEmptyStringSchema;
12
12
  const TaskIdsSchema = z.array(TaskIdSchema);
13
13
  const AttemptSchema = z.number().int().nonnegative();
14
14
  const EvidenceSchema = z.record(z.string(), z.unknown());
15
+ /** Shared lifecycle vocabulary for shell evidence and later build-result emitters. */
16
+ export const COMMAND_RECEIPT_OUTCOMES = [
17
+ "started", "completed", "spawn-failed", "timed-out", "cancelled", "reused-result", "skipped", "refused",
18
+ ];
19
+ export const CommandReceiptAttributionSchema = z.object({
20
+ runId: NonEmptyStringSchema,
21
+ taskId: TaskIdSchema,
22
+ attempt: AttemptSchema,
23
+ gateRound: z.number().int().nonnegative(),
24
+ invocation: NonEmptyStringSchema,
25
+ }).strict();
26
+ export const ShellReceiptSchema = z.object({
27
+ outcome: z.enum(COMMAND_RECEIPT_OUTCOMES),
28
+ confirmedStart: z.boolean(),
29
+ attribution: CommandReceiptAttributionSchema.optional(),
30
+ pid: z.number().int().positive().optional(),
31
+ exitCode: z.number().int().nullable().optional(),
32
+ signal: NonEmptyStringSchema.nullable().optional(),
33
+ error: z.string().optional(),
34
+ durationMs: z.number().nonnegative().optional(),
35
+ }).strict();
36
+ /** A task-build row must carry the entire caller-owned correlation, even before spawn. */
37
+ export const CommandReceiptSchema = ShellReceiptSchema.extend({
38
+ attribution: CommandReceiptAttributionSchema,
39
+ });
40
+ export function readCommandReceipt(raw) {
41
+ const parsed = CommandReceiptSchema.safeParse(raw);
42
+ return parsed.success ? { kind: "receipt", receipt: parsed.data } : {
43
+ kind: "protocol-issue", raw,
44
+ issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
45
+ };
46
+ }
15
47
  /** The T31 outcome vocabulary, made executable and strict at the persistence boundary. */
16
48
  export const GateOutcomeSchema = z.discriminatedUnion("kind", [
17
49
  z.object({ kind: z.literal("passed") }).strict(),
@@ -0,0 +1,41 @@
1
+ import type { GateResult } from "../gates/types.js";
2
+ /** Pure decision table of the scope-red disposition (OBS-1077, OBS-1074 residual): no filesystem,
3
+ * process, clock or journal. The caller supplies the inventory and performs every side effect. */
4
+ export type RepairDispositionKind = "fund-repair" | "scope-request" | "authoring" | "none";
5
+ export interface RepairDisposition {
6
+ kind: RepairDispositionKind;
7
+ /** validated inventory paths only — the sole paths a caller may put in an executable command */
8
+ paths: string[];
9
+ reason: string;
10
+ /** which evidence decided: a gate name, "worker" for a refusal, absent for the ordinary disposition */
11
+ source?: string;
12
+ /** an independent classification that kept priority over path prose */
13
+ blocker?: "infra" | "scope-collateral";
14
+ /** candidates that bound to nothing, or to more than one path; evidence, never approval advice */
15
+ diagnostics: ScopeHintDiagnostic[];
16
+ }
17
+ export interface ScopeHintDiagnostic {
18
+ candidate: string;
19
+ kind: "unresolved" | "ambiguous";
20
+ matches: string[];
21
+ reason: string;
22
+ }
23
+ export interface ScopeHintResolution {
24
+ resolved: string[];
25
+ diagnostics: ScopeHintDiagnostic[];
26
+ }
27
+ export interface RepairDispositionInput {
28
+ /** the failing gate results (passing rows are ignored) */
29
+ results: readonly GateResult[];
30
+ files: readonly string[];
31
+ /** the worker's refusal summary — present only when the worker reported ok:false */
32
+ refusalSummary?: string;
33
+ /** task tree plus diff, deleted tracked paths included */
34
+ inventory: Iterable<string>;
35
+ }
36
+ /** Lexing never starts in the middle of a word (same lexer as the daemon's scope-red disposition). */
37
+ export declare const namedPaths: (text: string) => string[];
38
+ /** Binds candidates to the SUPPLIED inventory and nothing else: an exact path, else a unique
39
+ * path-suffix. Anything unbound or bound twice comes back as a non-executable diagnostic. */
40
+ export declare function resolveScopeHints(candidates: Iterable<string>, inventory: Iterable<string>): ScopeHintResolution;
41
+ export declare function classifyRepairDisposition(input: RepairDispositionInput): RepairDisposition;
@@ -0,0 +1,77 @@
1
+ import { filesGlob } from "../graph/files-glob.js";
2
+ const failed = (g) => !(g.pass || g.meta?.skipped === true) || g.meta?.infra === true;
3
+ const REFUSAL_RE = /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i;
4
+ /** Lexing never starts in the middle of a word (same lexer as the daemon's scope-red disposition). */
5
+ export const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
6
+ .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
7
+ .filter((path) => !path.split("/").includes(".."));
8
+ /** Binds candidates to the SUPPLIED inventory and nothing else: an exact path, else a unique
9
+ * path-suffix. Anything unbound or bound twice comes back as a non-executable diagnostic. */
10
+ export function resolveScopeHints(candidates, inventory) {
11
+ const known = new Set(inventory);
12
+ const resolved = new Set();
13
+ const diagnostics = [];
14
+ for (const candidate of new Set(candidates)) {
15
+ if (known.has(candidate)) {
16
+ resolved.add(candidate);
17
+ continue;
18
+ }
19
+ const matches = [...known].filter((path) => path.endsWith(`/${candidate}`)).sort();
20
+ if (matches.length === 1)
21
+ resolved.add(matches[0]);
22
+ else
23
+ diagnostics.push(matches.length === 0
24
+ ? { candidate, kind: "unresolved", matches, reason: `scope hint resolves to nothing in the task tree: ${candidate}` }
25
+ : { candidate, kind: "ambiguous", matches, reason: `scope hint ${candidate} matches ${matches.length} paths: ${matches.join(", ")}` });
26
+ }
27
+ return { resolved: [...resolved].sort(), diagnostics };
28
+ }
29
+ export function classifyRepairDisposition(input) {
30
+ const reds = input.results.filter(failed);
31
+ const none = (reason, extra = {}) => ({ kind: "none", paths: [], reason, diagnostics: [], ...extra });
32
+ // Independent classifications first: prose naming a path never erases them.
33
+ const infra = reds.find((g) => g.meta?.infra === true);
34
+ if (infra)
35
+ return none(`${infra.gate}: infra red keeps its own classification`, { source: infra.gate, blocker: "infra" });
36
+ const verdict = reds.find((g) => g.gate === "scope")?.meta?.collateral;
37
+ if (verdict) {
38
+ return verdict.authoring
39
+ ? { kind: "authoring", paths: [...verdict.predicted], reason: verdict.repair, source: "scope", blocker: "scope-collateral", diagnostics: [] }
40
+ : none("scope: collateral verdict has unpredicted offenders — ordinary chargeable disposition", { source: "scope", blocker: "scope-collateral" });
41
+ }
42
+ if (input.files.length === 0)
43
+ return none("task declares no files[]");
44
+ const allowed = filesGlob([...input.files]);
45
+ const inventory = [...input.inventory];
46
+ const unowned = (text) => {
47
+ const r = resolveScopeHints(namedPaths(text).filter((path) => !allowed(path)), inventory);
48
+ return { paths: r.resolved.filter((path) => !allowed(path)), diagnostics: r.diagnostics };
49
+ };
50
+ const owned = (text) => resolveScopeHints(namedPaths(text), inventory).resolved.some(allowed);
51
+ const refusal = input.refusalSummary !== undefined && REFUSAL_RE.test(input.refusalSummary)
52
+ ? unowned(input.refusalSummary) : { paths: [], diagnostics: [] };
53
+ // A test gate names where the ASSERTION lives — a detection site, not the code that needs fixing.
54
+ const sites = reds.filter((g) => g.gate === "test").map((g) => unowned(g.details));
55
+ const others = reds.filter((g) => g.gate !== "test");
56
+ const hints = others.map((g) => unowned(g.details));
57
+ const diagnostics = [refusal, ...sites, ...hints].flatMap((r) => r.diagnostics)
58
+ .filter((d, i, all) => all.findIndex((o) => o.candidate === d.candidate) === i);
59
+ const uniq = (rs) => [...new Set(rs.flatMap((r) => r.paths))].sort();
60
+ if (refusal.paths.length) {
61
+ return { kind: "scope-request", paths: refusal.paths, source: "worker", diagnostics,
62
+ reason: `worker refusal names unowned paths: ${refusal.paths.join(", ")}` };
63
+ }
64
+ const repair = uniq(hints);
65
+ if (repair.length && !others.some((g) => owned(g.details))) {
66
+ const review = others.some((g) => g.gate === "review");
67
+ return { kind: review ? "scope-request" : "authoring", paths: repair, source: others[0].gate, diagnostics,
68
+ reason: `files[] repair hint: ${repair.join(", ")}` };
69
+ }
70
+ const detection = uniq(sites);
71
+ if (detection.length) {
72
+ return { kind: "fund-repair", paths: detection, source: "test", diagnostics,
73
+ reason: `test gate names a detection site, not an out-of-scope repair: ${detection.join(", ")}` };
74
+ }
75
+ // Unknown attribution never blocks funding: the ordinary chargeable disposition applies.
76
+ return none("no attributable out-of-scope repair — ordinary chargeable disposition", { diagnostics });
77
+ }
@@ -1,5 +1,6 @@
1
1
  import { type RunGraph } from "../../graph/schema.js";
2
2
  import type { OperatorSnapshot } from "../../run/operator-state.js";
3
+ import type { AttemptHarvest, TaskRow } from "./derive.js";
3
4
  /** The prototype's palette (tasks-redesign.mjs:24-30): xterm-256 indices, stated once. */
4
5
  export declare const BOARD_PALETTE: {
5
6
  readonly chrome: 238;
@@ -49,6 +50,8 @@ export interface BoardInput {
49
50
  /** Footer key hints, joined the prototype's way; `false` leaves the footer to the caller (see boardFooter). */
50
51
  readonly keys?: readonly string[] | false;
51
52
  readonly colour?: boolean;
53
+ /** The cockpit's derived rows: the shared projection (blocker, next action, harvest) rides on each board row, unrendered. */
54
+ readonly rows?: readonly TaskRow[];
52
55
  }
53
56
  /** One task row, in the parts the Run view recomposes per width band. */
54
57
  export interface BoardRow {
@@ -63,6 +66,12 @@ export interface BoardRow {
63
66
  readonly note: string;
64
67
  /** The note's segments, so a band can wrap it across continuation rows without cutting one. */
65
68
  readonly noteParts: readonly string[];
69
+ /** From the shared projection (derive.ts TaskRow.summary), when the caller supplied rows. */
70
+ readonly blocker?: string;
71
+ readonly nextAction?: string;
72
+ readonly permittedActions?: readonly string[];
73
+ /** OBS-1048: the newest recorded attempt's harvest evidence, when the caller supplied rows. */
74
+ readonly harvest?: AttemptHarvest;
66
75
  }
67
76
  export interface BoardFrame {
68
77
  /** Header through the gate header rule, in the prototype's order. */
@@ -89,6 +89,15 @@ export function areasFor(files) {
89
89
  const hm = (ms) => `${Math.floor(ms / 3.6e6)}h${String(Math.floor((ms % 3.6e6) / 6e4)).padStart(2, "0")}m`;
90
90
  const ago = (ms) => (ms < 6e4 ? "just now" : ms < 3.6e6 ? `${Math.round(ms / 6e4)}m ago` : `${Math.round(ms / 3.6e6)}h ago`);
91
91
  const isDone = (t) => t !== undefined && (t.merged || t.state === "completed");
92
+ const projected = (row) => {
93
+ const blocker = row?.summary?.blocker;
94
+ const harvest = row?.harvests?.at(-1);
95
+ return {
96
+ ...(blocker ? { blocker: blocker.kind, permittedActions: blocker.permittedActions } : {}),
97
+ ...(blocker?.nextAction ? { nextAction: blocker.nextAction } : {}),
98
+ ...(harvest ? { harvest } : {}),
99
+ };
100
+ };
92
101
  /** The prototype's frame, sectioned. `renderBoard` joins it row for row. */
93
102
  export function boardFrame(input, width) {
94
103
  const W = Math.max(1, Math.floor(width));
@@ -218,6 +227,7 @@ export function boardFrame(input, width) {
218
227
  tail: ` ${pad(mute(sliceCells(t?.channel ?? "—", CHAN - 2).head), CHAN)}${pad(mute(String(attempts)), 5)}`,
219
228
  note: note.join(mute(" · ")),
220
229
  noteParts: note,
230
+ ...projected(input.rows?.find((row) => row.taskId === g.id)),
221
231
  });
222
232
  }
223
233
  const bottom = [];
@@ -1,4 +1,6 @@
1
1
  export { readOperatorState as deriveOperatorState } from "../../run/operator-state.js";
2
+ import { type TaskActivityProjection } from "../../run/activity.js";
3
+ import { type OperatorTaskSummary } from "../../run/operator-summary.js";
2
4
  import type { ComponentState, JournalRow, StatusStripItem } from "./components.js";
3
5
  import type { RunViewId } from "./views.js";
4
6
  export declare const SPARKLINE_BUCKET_WINDOW = 12;
@@ -60,8 +62,34 @@ export type CockpitGraph = {
60
62
  readonly id: string;
61
63
  readonly title?: string;
62
64
  readonly status?: string;
65
+ /** Prerequisites and declared gates: read only by the shared projection (blocker, readiness). */
66
+ readonly deps?: readonly string[];
67
+ readonly gates?: readonly string[];
63
68
  }[];
64
69
  };
70
+ /** One recorded seat: a task attempt in a role. Keyed by all three, never by provider alone. */
71
+ export type RecordedIdentity = {
72
+ readonly key: string;
73
+ readonly role: string;
74
+ readonly attempt: number;
75
+ /** The slot name the driver recorded, verbatim; absent when the row named none. */
76
+ readonly name?: string;
77
+ };
78
+ /**
79
+ * OBS-1048: what the journal recorded about one in-flight attempt's harvest. `trailerFirstSeenAt`
80
+ * is always absent — no producer journals it (daemon.ts samples the trailer without recording;
81
+ * OBS-1048 add.1, 2.5.8) — and is never synthesized from any other timestamp.
82
+ */
83
+ export type AttemptHarvest = {
84
+ readonly attempt: number;
85
+ readonly lastWorkerStatus?: string;
86
+ readonly nudgeFailures: number;
87
+ readonly lastNudgeFailureAt?: string;
88
+ readonly pageCount: number;
89
+ readonly trailerFirstSeenAt?: undefined;
90
+ /** SUSPECTED: nudges failed and pages fired, and no worker-result of THIS attempt followed the launch. */
91
+ readonly suspectedStalledHarvest: boolean;
92
+ };
65
93
  /** How an unrecorded field draws: the engagement said nothing, so the row says nothing. */
66
94
  export declare const ABSENT_FIELD = "-";
67
95
  /**
@@ -105,6 +133,13 @@ export type TaskRow = {
105
133
  * that earns one rather than having to read `done` as if it were the same.
106
134
  */
107
135
  readonly merged?: true;
136
+ /** Evidence-only activity projection (run/activity.ts), the one status renders. */
137
+ readonly activity?: TaskActivityProjection;
138
+ /** Operator summary (phase, blocker, next action) — the same projection status renders. */
139
+ readonly summary?: OperatorTaskSummary;
140
+ readonly identities?: readonly RecordedIdentity[];
141
+ /** Per recorded attempt, newest last (OBS-1048). */
142
+ readonly harvests?: readonly AttemptHarvest[];
108
143
  };
109
144
  /** One recorded gate result. `details` is the record's own text, verbatim. */
110
145
  export type GateRow = {
@@ -1,7 +1,13 @@
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
3
  import { channelKey } from "../../adapters/types.js";
4
+ import { newestPark, permittedDecisionVerbs } from "../../cli/commands/approve.js";
5
+ import { parseOwnedName } from "../../drivers/types.js";
6
+ import { projectActivity } from "../../run/activity.js";
4
7
  import { isPidLive } from "../../run/lock.js";
8
+ import { normalizeGateOutcome } from "../../run/outcome.js";
9
+ import { projectOperatorSummary } from "../../run/operator-summary.js";
10
+ import { trackJournalRows } from "../../run/protocol.js";
5
11
  export const SPARKLINE_BUCKET_WINDOW = 12;
6
12
  const MINUTE_MS = 60_000;
7
13
  const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
@@ -470,7 +476,133 @@ function journalRows(events, tasks, defects, runStatus) {
470
476
  * graph's own `status` is never consulted, which is what keeps a recompiled
471
477
  * graph from repainting a parked task green.
472
478
  */
473
- function taskRows(events, tasks, graph) {
479
+ const stringField = (data, key) => typeof data[key] === "string" ? data[key] : undefined;
480
+ const attemptField = (data) => typeof data.attempt === "number" && Number.isInteger(data.attempt) && data.attempt >= 0 ? data.attempt : undefined;
481
+ /** Recorded seats for one task: role and attempt from the row, or from an owned slot name it carries. */
482
+ function recordedIdentities(events, taskId) {
483
+ const seen = new Map();
484
+ for (const event of events) {
485
+ if (event.taskId !== taskId)
486
+ continue;
487
+ const slot = recordValue(event.data.slot);
488
+ const name = stringField(event.data, "slotName") ?? (slot ? stringField(slot, "name") : stringField(event.data, "slot"));
489
+ const owned = name === undefined ? null : parseOwnedName(name);
490
+ const role = stringField(event.data, "role") ?? owned?.role ?? (event.event === "worker-launch" ? "worker" : undefined);
491
+ if (role === undefined)
492
+ continue;
493
+ const attempt = attemptField(event.data) ?? owned?.attempt;
494
+ if (attempt === undefined)
495
+ continue;
496
+ const key = `${taskId}:${attempt}:${role}`;
497
+ if (!seen.has(key))
498
+ seen.set(key, { key, role, attempt, ...(name === undefined ? {} : { name }) });
499
+ }
500
+ return [...seen.values()];
501
+ }
502
+ /**
503
+ * OBS-1048 fold, chronological: each worker-launch opens a fresh occurrence of its attempt label
504
+ * (labels reset on resume), and only a worker-result recorded AFTER that launch retires it — an
505
+ * unnumbered result binds to the newest launch so far, a numbered one to its own attempt's launch.
506
+ */
507
+ function attemptHarvests(events, taskId) {
508
+ const byAttempt = new Map();
509
+ let newest;
510
+ for (const [order, event] of events.entries()) {
511
+ if (event.taskId !== taskId)
512
+ continue;
513
+ const attempt = attemptField(event.data) ?? (event.event === "worker-result" ? newest : undefined);
514
+ if (attempt === undefined)
515
+ continue;
516
+ const existing = byAttempt.get(attempt);
517
+ if (event.event === "worker-launch") {
518
+ // A launch resets its label's evidence: an earlier occurrence's status, nudges, pages and result are history.
519
+ byAttempt.set(attempt, { launched: true, returned: false, nudges: 0, pages: 0, order });
520
+ newest = attempt;
521
+ continue;
522
+ }
523
+ const h = existing ?? { launched: false, returned: false, nudges: 0, pages: 0, order };
524
+ if (!existing)
525
+ byAttempt.set(attempt, h);
526
+ switch (event.event) {
527
+ case "worker-status":
528
+ h.status = stringField(event.data, "status");
529
+ break;
530
+ case "worker-nudge-failed":
531
+ h.nudges += 1;
532
+ h.nudgeAt = event.ts;
533
+ break;
534
+ case "operator-page":
535
+ h.pages += 1;
536
+ break;
537
+ case "worker-result":
538
+ h.returned = true;
539
+ break;
540
+ }
541
+ }
542
+ // Attempt labels reset on resume, so numeric order is not chronology. The board reads the
543
+ // last harvest as newest; retain the order of each label's latest worker-launch occurrence.
544
+ return [...byAttempt].sort(([, a], [, b]) => a.order - b.order).map(([attempt, h]) => ({
545
+ attempt,
546
+ ...(h.status === undefined ? {} : { lastWorkerStatus: h.status }),
547
+ nudgeFailures: h.nudges,
548
+ ...(h.nudgeAt === undefined ? {} : { lastNudgeFailureAt: h.nudgeAt }),
549
+ pageCount: h.pages,
550
+ suspectedStalledHarvest: h.launched && !h.returned && h.nudges > 0 && h.pages > 0,
551
+ }));
552
+ }
553
+ /**
554
+ * The shared, evidence-only projection status renders (status.ts recordProjection), folded here
555
+ * so both surfaces read one fact. Phase/blocker/next-action come from run/activity.ts and
556
+ * run/operator-summary.ts; decisions from the production park oracle.
557
+ */
558
+ function projectSummaries(events, runId, identities, graph, rowState) {
559
+ const journal = events.map((event) => ({
560
+ ts: event.ts, event: event.event, ...(event.taskId === undefined ? {} : { taskId: event.taskId }), data: event.data,
561
+ }));
562
+ const graphTask = (id) => graph?.tasks.find((task) => task.id === id);
563
+ const tasks = identities.map(({ id }) => ({
564
+ id, gates: graphTask(id)?.gates ?? [], deps: graphTask(id)?.deps ?? [], status: rowState(id) ?? graphTask(id)?.status ?? "unknown",
565
+ }));
566
+ const activity = projectActivity(runId, trackJournalRows(runId, journal.map((raw, sourceIndex) => ({ raw, sourceIndex }))), tasks);
567
+ const rehashAt = journal.reduce((at, event, index) => event.event === "graph-rehash" ? index : at, -1);
568
+ const summaryTasks = tasks.map((task) => {
569
+ const evidence = journal.filter((event) => event.taskId === task.id);
570
+ const last = evidence.at(-1);
571
+ const recorded = activity.get(task.id);
572
+ let phase = last ? recorded.state : undefined;
573
+ // A completed battery is evidence of readiness, never evidence that merge started (status.ts).
574
+ const boundary = journal.reduce((index, event, at) => ["run-start", "run-resume", "run-end"].includes(event.event)
575
+ || (event.taskId === task.id && (event.event === "task-dispatch"
576
+ || (event.event === "phase-start" && event.data.phase === "gates"))) ? at : index, -1);
577
+ const latest = new Map();
578
+ journal.forEach((event, index) => {
579
+ if (event.taskId === task.id && event.event === "gate-result" && typeof event.data.gate === "string")
580
+ latest.set(event.data.gate, index);
581
+ });
582
+ const priorGraph = rehashAt >= 0 && [...latest.values()].some((index) => index < rehashAt);
583
+ // Latest outcome including non-verdicts (status.ts): a held screen or skipped gate is not a pass.
584
+ const passedNow = (gate) => {
585
+ const latestResult = journal.slice(boundary + 1).filter((event) => event.taskId === task.id && event.event === "gate-result" && event.data.gate === gate).at(-1);
586
+ return latestResult !== undefined && normalizeGateOutcome(latestResult.data).kind === "passed";
587
+ };
588
+ if (recorded.state === "unconfirmed" && task.gates.length > 0 && !priorGraph && task.gates.every(passedNow)) {
589
+ phase = "awaiting merge phase";
590
+ }
591
+ const responsibility = [...evidence].reverse().find((event) => typeof event.data.role === "string" || typeof event.data.agent === "string");
592
+ return {
593
+ id: task.id, deps: task.deps, status: task.status, phase, lastEvidenceAt: last?.ts,
594
+ responsible: responsibility ? {
595
+ role: stringField(responsibility.data, "role"), agent: stringField(responsibility.data, "agent"),
596
+ } : undefined,
597
+ };
598
+ });
599
+ const decisions = summaryTasks.flatMap((task) => {
600
+ const park = task.status === "human" ? newestPark(journal, task.id) : undefined;
601
+ return park ? [{ taskId: task.id, park, verbs: permittedDecisionVerbs(park) }] : [];
602
+ });
603
+ return { activity, summaries: new Map(projectOperatorSummary(summaryTasks, decisions).map((task) => [task.taskId, task])) };
604
+ }
605
+ function taskRows(events, tasks, graph, runId) {
474
606
  const lastSeen = new Map();
475
607
  for (const event of events) {
476
608
  if (event.taskId !== undefined)
@@ -483,6 +615,15 @@ function taskRows(events, tasks, graph) {
483
615
  id,
484
616
  title: undefined,
485
617
  }));
618
+ // `task-done` records completion; only `merge` records landing. The fixed
619
+ // Tasks-view consumer maps `done` to a check, so the derived row must keep
620
+ // an unmerged completion in the neutral `completed` state and reserve
621
+ // `done` for a fact whose merge the journal actually carries.
622
+ const rowState = (id) => {
623
+ const fact = tasks.get(id);
624
+ return fact?.state === "done" && fact.merged !== true ? "completed" : fact?.state;
625
+ };
626
+ const projection = projectSummaries(events, runId, identities, graph, rowState);
486
627
  return identities.map(({ id, title }) => {
487
628
  const fact = tasks.get(id);
488
629
  const assignment = fact?.dispatches.at(-1)?.assignment;
@@ -491,13 +632,9 @@ function taskRows(events, tasks, graph) {
491
632
  // zero when a run resumes, so a task dispatched 0,1,0 was tried three times
492
633
  // and reporting its last label would say one.
493
634
  const attempts = fact?.dispatches.length ?? 0;
494
- // `task-done` records completion; only `merge` records landing. The fixed
495
- // Tasks-view consumer maps `done` to a check, so the derived row must keep
496
- // an unmerged completion in the neutral `completed` state and reserve
497
- // `done` for a fact whose merge the journal actually carries.
498
- const state = fact?.state === "done" && fact.merged !== true
499
- ? "completed"
500
- : fact?.state;
635
+ const state = rowState(id);
636
+ const recorded = recordedIdentities(events, id);
637
+ const harvests = attemptHarvests(events, id);
501
638
  return {
502
639
  id: `task:${id}`,
503
640
  taskId: id,
@@ -513,6 +650,10 @@ function taskRows(events, tasks, graph) {
513
650
  // Landing is its own fact. `done` is work finished; only a recorded merge
514
651
  // is work landed, and only that earns a check-mark.
515
652
  ...(fact?.merged === true ? { merged: true } : {}),
653
+ activity: projection.activity.get(id),
654
+ summary: projection.summaries.get(id),
655
+ ...(recorded.length === 0 ? {} : { identities: recorded }),
656
+ ...(harvests.length === 0 ? {} : { harvests }),
516
657
  };
517
658
  });
518
659
  }
@@ -737,9 +878,10 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
737
878
  : interrupted
738
879
  ? "interrupted"
739
880
  : "running";
881
+ const runId = source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, "");
740
882
  return {
741
883
  binaryVersion,
742
- runId: source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, ""),
884
+ runId,
743
885
  branch: typeof start.data.branch === "string" ? start.data.branch : "unknown",
744
886
  status: runStatus,
745
887
  elapsed: elapsedReading(events[0].ts, events.at(-1).ts),
@@ -762,7 +904,7 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
762
904
  ? `${spotlight.id} · ${spotlight.phase} · attempt ${taskAttempt(spotlight)} · ${taskActor(spotlight)}`
763
905
  : "- · pending · attempt 1 · unassigned",
764
906
  journalRows: journalRows(events, tasks, defects, runStatus),
765
- taskRows: taskRows(events, tasks, options.graph),
907
+ taskRows: taskRows(events, tasks, options.graph, runId),
766
908
  gateRows: gateRows(events),
767
909
  fleetRows: fleetRows(events),
768
910
  statusItems: [
@@ -1,6 +1,7 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { ChannelCost } from "../../report/cost.js";
3
3
  import { type OperatorRecordRow } from "../../report/operator-record.js";
4
+ import { type OperatorPageGroup } from "../../run/operator-page-summary.js";
4
5
  import type { EvidenceIdentity } from "../../run/operator-state.js";
5
6
  import { type JournalEvent } from "../../run/journal.js";
6
7
  import { type ComponentState } from "./components.js";
@@ -49,6 +50,7 @@ export interface EvidenceViewInput {
49
50
  readonly learningPreview?: readonly string[];
50
51
  }
51
52
  export interface EvidenceViewModel {
53
+ readonly operatorPages: readonly OperatorPageGroup[];
52
54
  readonly journal: readonly EvidenceRow[];
53
55
  readonly channels: readonly OperatorRecordRow[];
54
56
  readonly reportLines: readonly string[];