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.
- package/dist/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +13 -6
- package/dist/cli/commands/compile.js +7 -0
- package/dist/cli/commands/plan.d.ts +5 -0
- package/dist/cli/commands/plan.js +28 -23
- package/dist/cli/commands/resume.js +1 -1
- package/dist/cli/commands/run.js +1 -1
- package/dist/cli/commands/status.js +95 -34
- package/dist/cli/commands/verify.js +6 -4
- package/dist/compile/native.js +68 -7
- package/dist/compile/retired-literals.d.ts +22 -0
- package/dist/compile/retired-literals.js +271 -0
- package/dist/drivers/index.d.ts +4 -2
- package/dist/drivers/index.js +54 -6
- package/dist/gates/baseline.d.ts +8 -3
- package/dist/gates/baseline.js +6 -3
- package/dist/gates/review.js +12 -1
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +67 -9
- package/dist/gates/test-manifest.d.ts +12 -0
- package/dist/gates/test-manifest.js +29 -3
- package/dist/gates/test-reporter.js +28 -2
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +5 -0
- package/dist/graph/schema.d.ts +28 -0
- package/dist/graph/schema.js +13 -1
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/daemon.d.ts +28 -0
- package/dist/run/daemon.js +466 -110
- package/dist/run/git.d.ts +6 -1
- package/dist/run/git.js +63 -11
- package/dist/run/journal.d.ts +57 -5
- package/dist/run/journal.js +103 -9
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +1 -0
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- package/dist/run/repair-disposition.d.ts +41 -0
- package/dist/run/repair-disposition.js +77 -0
- package/dist/tui/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/home-view.js +45 -30
- package/dist/tui/cockpit/live-store.d.ts +18 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- package/package.json +1 -1
- package/schema/rungraph.schema.json +54 -0
- package/skills/tickmarkr-overseer/SKILL.md +162 -3
package/dist/adapters/prompt.js
CHANGED
|
@@ -71,9 +71,29 @@ function workerVerdictWitness(raw, nonce, positions) {
|
|
|
71
71
|
// these — a result carrying any other summary is a PARSED trailer, i.e. the worker speaking.
|
|
72
72
|
export const NO_TRAILER_SUMMARY = "worker produced no TICKMARKR_RESULT trailer";
|
|
73
73
|
export const UNPARSEABLE_TRAILER_SUMMARY = "unparseable TICKMARKR_RESULT trailer";
|
|
74
|
+
// OBS-1062: every interactive TUI echoes the brief into the pane, and the brief carries the trailer
|
|
75
|
+
// TEMPLATE above — so the nonce token is on screen before the worker has said anything. A token
|
|
76
|
+
// whose object opens with the template's literal `"ok":true|false` (not a bool) is the brief being
|
|
77
|
+
// displayed, never the worker speaking; it must not count as trailer participation, or the daemon
|
|
78
|
+
// reaps the attempt as malformed at its first poll (v2.5.7 run …115246, both workers at 34 s).
|
|
79
|
+
const TEMPLATE_BODY = '{"ok":true|false';
|
|
80
|
+
// Bounded to THIS occurrence (review finding, Leg 0a R2): the `{` must sit before the next nonce
|
|
81
|
+
// token, or a malformed worker token followed by a later template redraw would be erased as echo.
|
|
82
|
+
function isTemplateEcho(raw, at, end) {
|
|
83
|
+
const open = raw.indexOf("{", at);
|
|
84
|
+
if (open === -1 || open >= end)
|
|
85
|
+
return false;
|
|
86
|
+
const joined = raw
|
|
87
|
+
.slice(open, open + 64)
|
|
88
|
+
.split("\n")
|
|
89
|
+
.map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, ""))
|
|
90
|
+
.join("");
|
|
91
|
+
return joined.startsWith(TEMPLATE_BODY);
|
|
92
|
+
}
|
|
74
93
|
export function parseWorkerResult(raw, nonce) {
|
|
75
94
|
const fail = (summary, cause) => ({ ok: false, summary, deviations: [], raw, cause });
|
|
76
|
-
const
|
|
95
|
+
const all = trailerTokenPositions(raw, nonce);
|
|
96
|
+
const positions = all.filter((at, i) => !isTemplateEcho(raw, at, all[i + 1] ?? raw.length));
|
|
77
97
|
// TUIs echo the prompt template, redraw lines, and HARD-wrap the JSON with per-line margins
|
|
78
98
|
// (cursor does; recent-unwrapped can't rejoin hard newlines). Scan occurrences backward — last
|
|
79
99
|
// parseable wins — joining wrapped lines, stripping margin/box chrome, and growing the candidate
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { graphDefinitionHash, loadGraph, saveGraph } from "../../graph/graph.js";
|
|
1
|
+
import { graphDefinitionHash, loadGraph, saveGraph, taskDefinitionFingerprint } from "../../graph/graph.js";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { userInfo } from "node:os";
|
|
4
4
|
import { loadConfig } from "../../config/config.js";
|
|
5
5
|
import { integrationBranch } from "../../run/merge.js";
|
|
6
|
-
import { separabilityErrors } from "../../compile/collateral.js";
|
|
6
|
+
import { separabilityErrors, surfaceErrors, taskBudgetErrors } from "../../compile/collateral.js";
|
|
7
7
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
8
|
-
import { applyScopeAmendments, engagementComparable, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
|
|
8
|
+
import { applyScopeAmendments, engagementComparable, engagementReleased, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
|
|
9
9
|
export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
|
|
10
10
|
/**
|
|
11
11
|
* What each disposition's release actually buys — the clause every operator-facing sentence about
|
|
@@ -178,7 +178,7 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
178
178
|
throw new Error(`scope-request for ${taskId} requires --files <glob,…>`);
|
|
179
179
|
if (decisions)
|
|
180
180
|
throw new Error("--files cannot be combined with --waive, --uphold or --recheck");
|
|
181
|
-
const graph = applyScopeAmendments(loadGraph(cwd), journal);
|
|
181
|
+
const graph = applyScopeAmendments(loadGraph(cwd), journal, false, engagementReleased(events));
|
|
182
182
|
const from = graphDefinitionHash(graph);
|
|
183
183
|
if (!engagementComparable(journal.read(), from).comparable
|
|
184
184
|
|| (lastHuman?.data.graphDefinitionHash !== undefined && lastHuman.data.graphDefinitionHash !== from)) {
|
|
@@ -194,13 +194,20 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
194
194
|
const conflicts = separabilityErrors(amended.tasks);
|
|
195
195
|
if (conflicts.length)
|
|
196
196
|
throw new Error(`refusing scope-request approval for ${taskId}: ${conflicts.join("\n")}`);
|
|
197
|
+
// C4: a live grant may not take a task past the bounds that fail every compile. The exception
|
|
198
|
+
// list is EMPTY on purpose — a recorded historical compile exception confers no live authority.
|
|
199
|
+
const overBound = [...taskBudgetErrors([{ ...task, files: amendedFiles }]), ...surfaceErrors([{ ...task, files: amendedFiles }], [])];
|
|
200
|
+
if (overBound.length) {
|
|
201
|
+
throw new Error(`refusing scope-request approval for ${taskId}: ${overBound.join("\n")}\n`
|
|
202
|
+
+ `remedy: close the run, split the task in its spec, recompile, then \`tickmarkr resume ${runId} --graph-changed\``);
|
|
203
|
+
}
|
|
197
204
|
journal.append("task-approved", taskId, {
|
|
198
205
|
by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
|
|
199
|
-
amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line },
|
|
206
|
+
amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
|
|
200
207
|
});
|
|
201
208
|
// Do not write graph.json from this process while the daemon owns it: its sweep materializes
|
|
202
209
|
// the amendment without replacing a sibling's running state with this command's snapshot.
|
|
203
|
-
const projected = applyScopeAmendments(graph, journal);
|
|
210
|
+
const projected = applyScopeAmendments(graph, journal, false, engagementReleased(events));
|
|
204
211
|
const owner = approvalRunOwner(cwd, runId);
|
|
205
212
|
if (!owner.live && !owner.blockingRunId)
|
|
206
213
|
saveGraph(cwd, projected);
|
|
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { collateralLints, sourceScopeFindings, sourceScopeLints, } from "../../compile/collateral.js";
|
|
4
4
|
import { CompileError } from "../../compile/common.js";
|
|
5
5
|
import { compileSource } from "../../compile/index.js";
|
|
6
|
+
import { retiredLiteralErrors } from "../../compile/retired-literals.js";
|
|
6
7
|
import { clearCompileRefusal, saveCompileRefusal, saveGraph, stateDirName } from "../../graph/graph.js";
|
|
7
8
|
import { formatPriorFindingEvidence, readPriorRunEvidence } from "../../run/journal.js";
|
|
8
9
|
import { shGit } from "../../run/git.js";
|
|
@@ -75,6 +76,12 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
|
|
|
75
76
|
if (unwaived.length > 0) {
|
|
76
77
|
throw new CompileError(`${src} has unwaived native source-scope authoring errors:\n${unwaived.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
|
|
77
78
|
}
|
|
79
|
+
// v2.5.8 T14: a declared pin whose obligated file the declaring task does not own refuses the seal
|
|
80
|
+
// here, before any state write, so a dry run reaches the same refusal. Diagnostic only: never widens scope.
|
|
81
|
+
const uncovered = retiredLiteralErrors(g.tasks, cwd);
|
|
82
|
+
if (uncovered.length > 0) {
|
|
83
|
+
throw new CompileError(`${src} has uncovered declared pin obligations:\n${uncovered.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
|
|
84
|
+
}
|
|
78
85
|
// One bounded read supplies both cross-run surfaces: unresolved findings below and merge facts for
|
|
79
86
|
// the ancestry check. Neither fact mutates the compiled graph; status and every readiness predicate
|
|
80
87
|
// remain the source compiler's answer.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { type Task } from "../../graph/schema.js";
|
|
1
2
|
import { type VitestListResult } from "../../gates/acceptance.js";
|
|
2
3
|
import { type WorkerAdapter } from "../../adapters/types.js";
|
|
3
4
|
export type PlanOpts = {
|
|
4
5
|
listTests?: (cwd: string) => Promise<VitestListResult>;
|
|
5
6
|
};
|
|
7
|
+
export declare function plannedOracleDisposition(task: Pick<Task, "files" | "acceptance">, listing: VitestListResult): {
|
|
8
|
+
refusals: string[];
|
|
9
|
+
advisories: string[];
|
|
10
|
+
};
|
|
6
11
|
export declare function plan(argv: string[], cwd?: string, adapters?: WorkerAdapter[], harnessFrom?: string | undefined, opts?: PlanOpts): Promise<string>;
|
|
@@ -8,6 +8,7 @@ import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
|
8
8
|
import { classifyContextPath } from "../../compile/native.js";
|
|
9
9
|
import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
10
10
|
import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
11
|
+
import { filesGlob } from "../../graph/files-glob.js";
|
|
11
12
|
import { renderAcceptanceItem } from "../../graph/schema.js";
|
|
12
13
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
13
14
|
import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
|
|
@@ -47,6 +48,32 @@ const fleetCanCrossVendorReview = (channels) => {
|
|
|
47
48
|
return true;
|
|
48
49
|
return false;
|
|
49
50
|
};
|
|
51
|
+
// Planning can defer authorship only to this criterion's explicitly owned landing.
|
|
52
|
+
// This advisory does not change runtime oracle execution or name resolution.
|
|
53
|
+
export function plannedOracleDisposition(task, listing) {
|
|
54
|
+
const refusals = [];
|
|
55
|
+
const advisories = [];
|
|
56
|
+
const items = task.acceptance.filter((item) => typeof item === "object" && item.oracle === "test");
|
|
57
|
+
if (listing.status === "failed") {
|
|
58
|
+
if (items.length)
|
|
59
|
+
refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
|
|
60
|
+
return { refusals, advisories };
|
|
61
|
+
}
|
|
62
|
+
const owns = filesGlob(task.files.map((path) => path.replace(/^\.\//, "")));
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const audit = auditNamedTestOracles([item], listing.tests)[0];
|
|
65
|
+
if (audit.matches.length === 0 && item.landing && owns(item.landing)) {
|
|
66
|
+
advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${item.landing})`);
|
|
67
|
+
}
|
|
68
|
+
else if (audit.matches.length === 0) {
|
|
69
|
+
refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
|
|
70
|
+
}
|
|
71
|
+
else if (audit.matches.length > 1) {
|
|
72
|
+
refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches ${audit.matches.length} runner-listed test names`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { refusals, advisories };
|
|
76
|
+
}
|
|
50
77
|
const NAMED_CRITERION_PATH = /(?:^|[\s("'`])((?:src|tests|fixtures|scripts)\/[A-Za-z0-9_@{}*?.,/+-]+\.(?:tsx|ts|jsx|json|js|mjs|cjs|md|txt))(?![A-Za-z0-9])/g;
|
|
51
78
|
const judgeOracle = (task) => task.acceptance.some((item) => typeof item === "string" || (typeof item === "object" && item.oracle === "judge"));
|
|
52
79
|
const latestRunStatusLine = (cwd) => {
|
|
@@ -166,29 +193,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
166
193
|
if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
|
|
167
194
|
const listing = await (opts.listTests ?? listVitestTests)(cwd);
|
|
168
195
|
for (const task of g.tasks) {
|
|
169
|
-
const refusals =
|
|
170
|
-
const advisories = [];
|
|
171
|
-
const authoredTest = task.files
|
|
172
|
-
.map((path) => path.replace(/^\.\//, ""))
|
|
173
|
-
.find((path) => /^tests\/.+\.test\.ts$/.test(path) && !/[?*{[]/.test(path) && !existsSync(join(cwd, path)));
|
|
174
|
-
if (listing.status === "failed") {
|
|
175
|
-
if (task.acceptance.some((item) => typeof item === "object" && item.oracle === "test")) {
|
|
176
|
-
refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
for (const audit of auditNamedTestOracles(task.acceptance, listing.tests)) {
|
|
181
|
-
if (audit.matches.length === 0 && authoredTest) {
|
|
182
|
-
advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${authoredTest})`);
|
|
183
|
-
}
|
|
184
|
-
else if (audit.matches.length === 0) {
|
|
185
|
-
refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
|
|
186
|
-
}
|
|
187
|
-
else if (audit.matches.length > 1) {
|
|
188
|
-
refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches ${audit.matches.length} runner-listed test names`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
196
|
+
const { refusals, advisories } = plannedOracleDisposition(task, listing);
|
|
192
197
|
if (refusals.length)
|
|
193
198
|
oracleRefusals.set(task.id, refusals);
|
|
194
199
|
if (advisories.length)
|
|
@@ -39,7 +39,7 @@ export async function resume(argv, cwd = process.cwd()) {
|
|
|
39
39
|
}
|
|
40
40
|
await assertRefsWritable(cwd, "resume");
|
|
41
41
|
const host = classifyHost();
|
|
42
|
-
preflightHostDriver(cfg, driverOverride, host);
|
|
42
|
+
await preflightHostDriver(cfg, driverOverride, host, cwd);
|
|
43
43
|
const narrate = narrationSink(runId);
|
|
44
44
|
const s = await runDaemon(cwd, {
|
|
45
45
|
runId,
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -511,7 +511,7 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
511
511
|
}
|
|
512
512
|
await assertRefsWritable(cwd, "run");
|
|
513
513
|
const host = classifyHost();
|
|
514
|
-
preflightHostDriver(cfg, driverOverride, host);
|
|
514
|
+
await preflightHostDriver(cfg, driverOverride, host, cwd);
|
|
515
515
|
// The run id is minted HERE rather than inside the daemon, because the narration sink has to know
|
|
516
516
|
// which run it is narrating before the first event arrives (the daemon's `narrate` callback is
|
|
517
517
|
// handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
|
|
@@ -7,8 +7,11 @@ import { HerdrDriver } from "../../drivers/herdr.js";
|
|
|
7
7
|
import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
|
|
8
8
|
import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
9
9
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { projectActivity } from "../../run/activity.js";
|
|
11
|
+
import { projectOperatorSummary } from "../../run/operator-summary.js";
|
|
12
|
+
import { trackJournalRows } from "../../run/protocol.js";
|
|
13
|
+
import { newestPark, permittedDecisionVerbs } from "./approve.js";
|
|
14
|
+
import { Journal, formatJournalNarration, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
|
|
12
15
|
import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
|
|
13
16
|
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
14
17
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
@@ -96,8 +99,8 @@ const taskClockReference = (events, taskId, content, fallback) => {
|
|
|
96
99
|
}
|
|
97
100
|
return fallback;
|
|
98
101
|
};
|
|
99
|
-
// Every attempt number a cell phrase carries is the engagement's own ladder (
|
|
100
|
-
//
|
|
102
|
+
// Every attempt number a cell phrase carries is the engagement's own ladder (journal dispatch
|
|
103
|
+
// labels are displayed from 1). Name that ruler wherever a bare one appears, not only in the one phrase
|
|
101
104
|
// shipping today — a phrase added upstream must not reach the operator unruled.
|
|
102
105
|
const labelAttemptRuler = (text) => text.replace(/(?<!engagement |run )\battempt (?=\d)/gu, "engagement attempt ");
|
|
103
106
|
// The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
|
|
@@ -907,9 +910,58 @@ const readRunRecord = (cwd, graph, namedRunId) => {
|
|
|
907
910
|
* catch an empty or torn first write. Those snapshots own no task fold yet; once run-start exists,
|
|
908
911
|
* however, a fold failure is unexpected and must surface instead of becoming a false empty run.
|
|
909
912
|
*/
|
|
910
|
-
const recordTaskRows = (record, graph) => record.comparable && record.events.some((event) => event.event === "run-start")
|
|
911
|
-
? new Map(deriveRunCockpitData({ fileName: `${record.runId}.journal.jsonl`, raw: record.raw }, "status", { graph }).taskRows.map((row) => [row.taskId, row]))
|
|
913
|
+
const recordTaskRows = (record, graph, isDaemonAlive) => record.comparable && record.events.some((event) => event.event === "run-start")
|
|
914
|
+
? new Map(deriveRunCockpitData({ fileName: `${record.runId}.journal.jsonl`, raw: record.raw }, "status", { graph, isDaemonAlive }).taskRows.map((row) => [row.taskId, row]))
|
|
912
915
|
: new Map();
|
|
916
|
+
/** Adapt the single journal snapshot to the shared, evidence-only projections. */
|
|
917
|
+
const recordProjection = (record, graph, isDaemonAlive) => {
|
|
918
|
+
const rows = record ? recordTaskRows(record, graph, isDaemonAlive) : new Map();
|
|
919
|
+
const events = record?.comparable ? record.events : [];
|
|
920
|
+
const activity = projectActivity(record?.runId ?? "", trackJournalRows(record?.runId ?? "", events.map((raw, sourceIndex) => ({ raw, sourceIndex }))), graph.tasks);
|
|
921
|
+
const tasks = graph.tasks.map(task => {
|
|
922
|
+
const row = rows.get(task.id);
|
|
923
|
+
const evidence = events.filter(event => event.taskId === task.id);
|
|
924
|
+
const last = evidence.at(-1);
|
|
925
|
+
const recorded = activity.get(task.id);
|
|
926
|
+
let phase = last ? recorded.state : undefined;
|
|
927
|
+
// A completed battery is evidence of readiness, never evidence that merge started.
|
|
928
|
+
const gates = gateSnapshot(task, events, record?.rehashAt);
|
|
929
|
+
const boundary = events.reduce((index, event, at) => ["run-start", "run-resume", "run-end"].includes(event.event)
|
|
930
|
+
|| (event.taskId === task.id && (event.event === "task-dispatch"
|
|
931
|
+
|| (event.event === "phase-start" && event.data.phase === "gates"))) ? at : index, -1);
|
|
932
|
+
// Read the latest outcome, including non-verdicts. The gate rail retains earlier
|
|
933
|
+
// verdicts while a screen or infrastructure retry is pending; that historical
|
|
934
|
+
// pass must not make the current battery look ready to merge.
|
|
935
|
+
const currentResults = new Map(events.slice(boundary + 1).filter(event => event.taskId === task.id && event.event === "gate-result")
|
|
936
|
+
.map(event => [event.data.gate, normalizeGateOutcome(event.data).kind]));
|
|
937
|
+
if (recorded.state === "unconfirmed" && task.gates.length > 0 && !gates.priorGraph
|
|
938
|
+
&& task.gates.every(gate => currentResults.get(gate) === "passed")) {
|
|
939
|
+
phase = "awaiting merge phase";
|
|
940
|
+
}
|
|
941
|
+
const responsibility = [...evidence].reverse().find(event => typeof event.data.role === "string" || typeof event.data.agent === "string");
|
|
942
|
+
return {
|
|
943
|
+
id: task.id, deps: task.deps, status: row?.state ?? task.status,
|
|
944
|
+
phase, lastEvidenceAt: last?.ts,
|
|
945
|
+
responsible: responsibility ? {
|
|
946
|
+
role: typeof responsibility.data.role === "string" ? responsibility.data.role : undefined,
|
|
947
|
+
agent: typeof responsibility.data.agent === "string" ? responsibility.data.agent : undefined,
|
|
948
|
+
} : undefined,
|
|
949
|
+
};
|
|
950
|
+
});
|
|
951
|
+
const decisions = tasks.flatMap(task => {
|
|
952
|
+
const park = task.status === "human" ? newestPark(events, task.id) : undefined;
|
|
953
|
+
return park ? [{ taskId: task.id, park, verbs: permittedDecisionVerbs(park) }] : [];
|
|
954
|
+
});
|
|
955
|
+
return { rows, activity, summaries: new Map(projectOperatorSummary(tasks, decisions).map(task => [task.taskId, task])) };
|
|
956
|
+
};
|
|
957
|
+
const summaryText = (summary) => sanitizeTaskText([
|
|
958
|
+
`phase ${summary.phase ?? "unrecorded"}`,
|
|
959
|
+
`last evidence ${summary.lastEvidenceAt ?? "unrecorded"}`,
|
|
960
|
+
`responsible ${[summary.responsible?.role, summary.responsible?.agent].filter(Boolean).join(" / ") || "unrecorded"}`,
|
|
961
|
+
`blocker ${summary.blocker?.kind ?? "none"}`,
|
|
962
|
+
`next action ${summary.blocker?.nextAction ?? "unrecorded"}`,
|
|
963
|
+
`human-decision ${summary.blocker?.decisionRequired ? "yes" : "no"}`,
|
|
964
|
+
].join(" · "));
|
|
913
965
|
/** The graph tasks this run's record actually speaks about — a row with no recorded fact is silence. */
|
|
914
966
|
const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
|
|
915
967
|
const row = rows.get(task.id);
|
|
@@ -936,14 +988,11 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
936
988
|
const comparable = record?.comparable ?? false;
|
|
937
989
|
const rehashAt = record?.rehashAt;
|
|
938
990
|
const assignments = new Map();
|
|
939
|
-
let replayed = null;
|
|
940
991
|
const contexts = new Map();
|
|
941
992
|
// v1.53 T5: this run is dead — a newer run replaced it
|
|
942
993
|
const supersededBy = [...events].reverse()
|
|
943
994
|
.find((e) => e.event === "superseded" && typeof e.data.by === "string")?.data.by;
|
|
944
995
|
if (record && comparable) {
|
|
945
|
-
if (!journalRowsOnly)
|
|
946
|
-
replayed = Journal.open(cwd, record.runId).replayStatuses();
|
|
947
996
|
for (const e of events) {
|
|
948
997
|
if (!journalRowsOnly && e.event === "task-dispatch" && e.taskId) {
|
|
949
998
|
const a = e.data.assignment;
|
|
@@ -956,7 +1005,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
956
1005
|
}
|
|
957
1006
|
}
|
|
958
1007
|
}
|
|
959
|
-
const
|
|
1008
|
+
const projection = recordProjection(record, g, () => daemon.state !== "dead");
|
|
1009
|
+
const taskRows = projection.rows;
|
|
1010
|
+
// Preserve the plain table's compact status vocabulary using the same snapshot as its evidence.
|
|
1011
|
+
const replayed = new Map([...taskRows].flatMap(([id, row]) => row.state === undefined ? [] : [[id,
|
|
1012
|
+
row.state === "running" || row.state === "interrupted" ? "pending" : graphTaskStatus(row.state, "pending"),
|
|
1013
|
+
]]));
|
|
960
1014
|
const clockFallback = new Date(now);
|
|
961
1015
|
const recordedClock = new Date(events.at(-1)?.ts ?? now);
|
|
962
1016
|
const zoneReference = Number.isFinite(recordedClock.getTime()) ? recordedClock : clockFallback;
|
|
@@ -973,14 +1027,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
973
1027
|
};
|
|
974
1028
|
const renderedTasks = journalRowsOnly ? recordedTasks(g, taskRows) : g.tasks;
|
|
975
1029
|
const starved = new Set(blockedTasks(effective).map((t) => t.id));
|
|
976
|
-
//
|
|
977
|
-
// (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
|
|
978
|
-
// dep-waiting cells still derive from the effective graph statuses.
|
|
979
|
-
const activity = foldActivity(comparable ? events : [], effective.tasks);
|
|
980
|
-
// RULING-v189-scope §14c: a card answers BOTH supervisor questions. `foldActivity` names what a
|
|
981
|
-
// task waits FOR; this names what waits ON it. Read from `effective.tasks` — whose statuses are
|
|
982
|
-
// the journal's replay, never the compiled graph's — so a dependent the record says is done stops
|
|
983
|
-
// being named, and a task all of whose dependents are done acquires no entry at all.
|
|
1030
|
+
// Reverse dependencies share the effective task statuses with the projection.
|
|
984
1031
|
const dependents = new Map();
|
|
985
1032
|
for (const task of effective.tasks) {
|
|
986
1033
|
if (task.status === "done")
|
|
@@ -1039,7 +1086,18 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1039
1086
|
|| (st === "failed" && failureKind === undefined));
|
|
1040
1087
|
const livePhase = phases.get(t.id);
|
|
1041
1088
|
const isStarved = !livePhase && starved.has(t.id);
|
|
1042
|
-
const
|
|
1089
|
+
const summary = projection.summaries.get(t.id);
|
|
1090
|
+
// Attempt labels can restart at zero in legacy engagements. Pair the preparing caption's
|
|
1091
|
+
// counter and timestamp from the same dispatch, rather than retaining a prior higher label.
|
|
1092
|
+
const dispatch = [...events].reverse().find(event => event.taskId === t.id && event.event === "task-dispatch");
|
|
1093
|
+
const projectedPhrase = summary.blocker?.kind === "dependency-wait"
|
|
1094
|
+
? `dep-waiting on ${summary.blocker.prerequisites.join(", ")}`
|
|
1095
|
+
: summary.phase === "terminal"
|
|
1096
|
+
? st === "human" ? `parked${failureKind ? ` (${failureKind})` : ""}` : undefined
|
|
1097
|
+
: summary.phase === "preparing" && dispatch
|
|
1098
|
+
? `preparing · attempt ${(Number.isInteger(dispatch.data.attempt) ? dispatch.data.attempt : 0) + 1} since ${dispatch.ts.slice(11, 19)}`
|
|
1099
|
+
: summary.phase ?? undefined;
|
|
1100
|
+
const rawPhrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : projectedPhrase;
|
|
1043
1101
|
const phrase = rawPhrase === undefined
|
|
1044
1102
|
? undefined
|
|
1045
1103
|
: journalRowsOnly
|
|
@@ -1053,12 +1111,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1053
1111
|
? gateSnapshot(t, events, rehashAt)
|
|
1054
1112
|
: { states: defaultGateStates(t), priorGraph: false };
|
|
1055
1113
|
const pane = panes.get(t.id);
|
|
1056
|
-
return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, pane, ...gates };
|
|
1114
|
+
return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, pane, summary, ...gates };
|
|
1057
1115
|
});
|
|
1058
1116
|
if (!unicode) {
|
|
1059
|
-
// machine
|
|
1060
|
-
// use
|
|
1061
|
-
const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
|
|
1117
|
+
// Keep the machine task-title columns intact; projection details occupy their own line.
|
|
1118
|
+
// Phase-aware frames use ASCII spinners so pipes never receive terminal-only braille/ANSI.
|
|
1119
|
+
const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane, summary }) => {
|
|
1062
1120
|
const chain = gateChain(states, false);
|
|
1063
1121
|
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
|
|
1064
1122
|
const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
|
|
@@ -1069,6 +1127,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1069
1127
|
// (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
|
|
1070
1128
|
return [
|
|
1071
1129
|
`${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`,
|
|
1130
|
+
` ${summaryText(summary)}`,
|
|
1072
1131
|
...recoveryLinesForTask(t.id).map((line) => ` ${line}`),
|
|
1073
1132
|
];
|
|
1074
1133
|
});
|
|
@@ -1142,7 +1201,8 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1142
1201
|
besideLockup(fillLockup(dim(versionText), versionCells), factLines[1]),
|
|
1143
1202
|
...factLines.slice(2).map((line) => `${" ".repeat(lockupCells + lockupGapCells)}${line}`),
|
|
1144
1203
|
];
|
|
1145
|
-
const
|
|
1204
|
+
const lastEvent = comparable ? events.at(-1) : undefined;
|
|
1205
|
+
const nowLine = lastEvent ? [legend(` now: ${formatJournalNarration(lastEvent)}`)] : [];
|
|
1146
1206
|
const supervisionLegend = legend(` ${supervisionText(supervision)}`);
|
|
1147
1207
|
const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
|
|
1148
1208
|
const taskSection = ` ${ok("▌")} ${title("TASKS")} ${dim(taskSectionSummary)}`;
|
|
@@ -1220,6 +1280,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1220
1280
|
` ${tone(cell.t.id)} ${dim(taskArea(cell.t))} ${sanitizeTaskText(cell.t.title)}`,
|
|
1221
1281
|
...wrapCells(` ${dim(machinery)}`, boardColumns, { continuationPrefix: " " }),
|
|
1222
1282
|
...wrapCells(` ${dim(noteFor(cell))}`, boardColumns, { continuationPrefix: " " }).filter((line) => line.trim()),
|
|
1283
|
+
...wrapCells(` ${dim(summaryText(cell.summary))}`, boardColumns, { continuationPrefix: " " }),
|
|
1223
1284
|
"",
|
|
1224
1285
|
];
|
|
1225
1286
|
});
|
|
@@ -1249,15 +1310,15 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1249
1310
|
? running
|
|
1250
1311
|
: dim;
|
|
1251
1312
|
const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
|
|
1252
|
-
return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1313
|
+
return [...Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
|
|
1314
|
+
+ dim(fitColumn(columns[1][index] ?? "", areaWidth))
|
|
1315
|
+
+ dim(fitColumn(columns[2][index] ?? "", depsWidth))
|
|
1316
|
+
+ titleTone(fitColumn(columns[3][index] ?? "", taskWidth))
|
|
1317
|
+
+ fitCells(columns[4][index] ?? "", gatesWidth)
|
|
1318
|
+
+ " "
|
|
1319
|
+
+ dim(fitColumn(columns[5][index] ?? "", channelWidth))
|
|
1320
|
+
+ dim(fitColumn(columns[6][index] ?? "", attemptWidth, 1))
|
|
1321
|
+
+ dim(fitCells(columns[7][index] ?? "", noteWidth))), ...wrapCells(` ${dim(summaryText(cell.summary))}`, boardColumns, { continuationPrefix: " " })];
|
|
1261
1322
|
});
|
|
1262
1323
|
return [legend(headerRow), dim(ruleRow), ...rows];
|
|
1263
1324
|
};
|
|
@@ -1305,7 +1366,7 @@ const oneLine = (cwd, namedRunId) => {
|
|
|
1305
1366
|
const record = readRunRecord(cwd, graph, namedRunId);
|
|
1306
1367
|
if (!record)
|
|
1307
1368
|
return sanitizeTaskText(`tickmarkr · no runs yet · 0/${graph.tasks.length} done`);
|
|
1308
|
-
const rows =
|
|
1369
|
+
const { rows } = recordProjection(record, graph);
|
|
1309
1370
|
const claims = record.comparable
|
|
1310
1371
|
? [
|
|
1311
1372
|
`${recordedDone(recordedTasks(graph, rows), rows)}/${graph.tasks.length} done`,
|
|
@@ -150,10 +150,12 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
150
150
|
allowPositionals: false,
|
|
151
151
|
});
|
|
152
152
|
const stateRoot = await verifyStateRoot(cwd);
|
|
153
|
+
const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
|
|
153
154
|
if (resolve(stateRoot) !== resolve(cwd)) {
|
|
154
|
-
console.error(`verify: state files
|
|
155
|
+
console.error(`verify: state files resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree; per-file origins: `
|
|
156
|
+
+ STATE_FILES.map(file => `${file}: ${join(fileRoot(file), ".tickmarkr", file)} (${fileRoot(file) === cwd ? "local" : "common root"})`).join("; ") + ")");
|
|
155
157
|
}
|
|
156
|
-
const cfg = loadConfig(
|
|
158
|
+
const cfg = loadConfig(fileRoot("config.yaml"));
|
|
157
159
|
const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
|
|
158
160
|
const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
|
|
159
161
|
throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
|
|
@@ -167,7 +169,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
167
169
|
let files = values.files ?? [];
|
|
168
170
|
let goal = `independent verification of the ${values.base}..HEAD diff`;
|
|
169
171
|
if (values.task) {
|
|
170
|
-
const t = getTask(loadGraph(
|
|
172
|
+
const t = getTask(loadGraph(fileRoot("graph.json")), values.task);
|
|
171
173
|
acceptance = t.acceptance;
|
|
172
174
|
if (!files.length)
|
|
173
175
|
files = t.files;
|
|
@@ -223,7 +225,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
223
225
|
let author = HUMAN_AUTHOR;
|
|
224
226
|
const adapters = allAdapters();
|
|
225
227
|
if (wantAcceptance || wantReview) {
|
|
226
|
-
const health = readDoctor(
|
|
228
|
+
const health = readDoctor(fileRoot("doctor.json")) ?? (await probeAll(adapters));
|
|
227
229
|
const pools = rolePools(cfg, adapters, health);
|
|
228
230
|
judgeChannels = pools.judge;
|
|
229
231
|
channels = pools.review;
|
package/dist/compile/native.js
CHANGED
|
@@ -69,7 +69,51 @@ const NESTED_RE = /^\s+- (.+)$/;
|
|
|
69
69
|
// v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
|
|
70
70
|
// without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
|
|
71
71
|
const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
|
|
72
|
-
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout"]);
|
|
72
|
+
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins"]);
|
|
73
|
+
const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw : draft.list === "pins" ? draft.pinsRaw : draft.gates;
|
|
74
|
+
// v2.5.8 T7: one declared pin item — "literal: <exact text> | glob: <search glob>" (split at the LAST
|
|
75
|
+
// " | glob: " so the text may itself hold a bar) or "fixture: <path set>". A declaration missing the
|
|
76
|
+
// payload its own kind requires refuses the compile naming the task and the item.
|
|
77
|
+
const PIN_GLOB_SEP = "| glob:";
|
|
78
|
+
// v2.5.8 T8 (OBS-1064): "test: <title> | suite: <path>" — split at the LAST " | suite: " so the title
|
|
79
|
+
// stays the verbatim leaf the gate matches and the landing path never enters it.
|
|
80
|
+
const LANDING_SEP = "| suite:";
|
|
81
|
+
function parsePin(task, raw, index, pathSet) {
|
|
82
|
+
const bad = (detail) => invalid(task, "pins", `item ${index + 1} (${JSON.stringify(raw)}) ${detail}`);
|
|
83
|
+
const typed = raw.match(/^(\w+):\s*(.*)$/);
|
|
84
|
+
if (typed?.[1] === "fixture") {
|
|
85
|
+
const paths = pathSet(typed[2]);
|
|
86
|
+
if (!paths.length)
|
|
87
|
+
bad("is a fixture pin missing its path set");
|
|
88
|
+
return { kind: "fixture", paths };
|
|
89
|
+
}
|
|
90
|
+
if (typed?.[1] === "literal") {
|
|
91
|
+
const at = typed[2].lastIndexOf(PIN_GLOB_SEP);
|
|
92
|
+
const text = (at < 0 ? typed[2] : typed[2].slice(0, at)).trim();
|
|
93
|
+
const glob = at < 0 ? "" : typed[2].slice(at + PIN_GLOB_SEP.length).trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
bad("is a literal pin missing its text");
|
|
96
|
+
if (!glob)
|
|
97
|
+
bad(`is a literal pin missing its search glob — write "literal: <text> ${PIN_GLOB_SEP} <glob>"`);
|
|
98
|
+
return { kind: "literal", text, glob };
|
|
99
|
+
}
|
|
100
|
+
return bad('must be "literal: <text> | glob: <glob>" or "fixture: <path set>"');
|
|
101
|
+
}
|
|
102
|
+
function parseTestOracle(task, body, index) {
|
|
103
|
+
const at = body.lastIndexOf(LANDING_SEP);
|
|
104
|
+
if (at < 0)
|
|
105
|
+
return { oracle: "test", test: body.trim() };
|
|
106
|
+
const test = body.slice(0, at).trim();
|
|
107
|
+
const landing = body.slice(at + LANDING_SEP.length).trim();
|
|
108
|
+
const bad = (detail) => invalid(task, "acceptance", `item ${index + 1} (${JSON.stringify(body)}) ${detail}`);
|
|
109
|
+
if (!test)
|
|
110
|
+
bad("is a test oracle missing its title before the landing");
|
|
111
|
+
if (!landing)
|
|
112
|
+
bad(`is a test oracle missing its landing path — write "test: <title> ${LANDING_SEP} <path>"`);
|
|
113
|
+
if (!picomatch(COLLECTABLE_TESTS, { dot: true })(landing))
|
|
114
|
+
bad(`declares landing ${JSON.stringify(landing)} which no runner collects (${COLLECTABLE_TESTS})`);
|
|
115
|
+
return { oracle: "test", test, landing };
|
|
116
|
+
}
|
|
73
117
|
function invalid(task, field, detail) {
|
|
74
118
|
throw new CompileError(`Task ${task} field "${field}" ${detail}`);
|
|
75
119
|
}
|
|
@@ -376,7 +420,7 @@ export function compileNative(file, options = {}) {
|
|
|
376
420
|
for (const [index, line] of content.split("\n").entries()) {
|
|
377
421
|
const heading = line.match(HEAD_RE);
|
|
378
422
|
if (heading) {
|
|
379
|
-
drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, list: null, itemOpen: false, continuationField: null });
|
|
423
|
+
drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, pinsRaw: [], list: null, itemOpen: false, continuationField: null });
|
|
380
424
|
continue;
|
|
381
425
|
}
|
|
382
426
|
const draft = drafts.at(-1);
|
|
@@ -411,7 +455,7 @@ export function compileNative(file, options = {}) {
|
|
|
411
455
|
invalid(draft.id, field[1], "is unknown");
|
|
412
456
|
const value = field[2].trim();
|
|
413
457
|
draft.itemOpen = false;
|
|
414
|
-
if (name === "acceptance" || name === "gates") {
|
|
458
|
+
if (name === "acceptance" || name === "gates" || name === "pins") {
|
|
415
459
|
if (value)
|
|
416
460
|
invalid(draft.id, field[1], "must be a nested list");
|
|
417
461
|
draft.list = name;
|
|
@@ -459,7 +503,7 @@ export function compileNative(file, options = {}) {
|
|
|
459
503
|
const value = nested[1].trim();
|
|
460
504
|
if (!value)
|
|
461
505
|
invalid(draft.id, draft.list, "must not contain empty entries");
|
|
462
|
-
(draft
|
|
506
|
+
listItems(draft).push(value);
|
|
463
507
|
draft.itemOpen = true;
|
|
464
508
|
continue;
|
|
465
509
|
}
|
|
@@ -468,7 +512,7 @@ export function compileNative(file, options = {}) {
|
|
|
468
512
|
// 1.87.0 dropped these lines silently: 53/78 of run-551's criteria compiled to first-line
|
|
469
513
|
// stubs and every falsifier tail was invisible to the judge.
|
|
470
514
|
if (draft.list && draft.itemOpen && (line.startsWith(" ") || line.startsWith("\t")) && line.trim()) {
|
|
471
|
-
const items = draft
|
|
515
|
+
const items = listItems(draft);
|
|
472
516
|
items[items.length - 1] += ` ${line.trim()}`;
|
|
473
517
|
continue;
|
|
474
518
|
}
|
|
@@ -489,14 +533,14 @@ export function compileNative(file, options = {}) {
|
|
|
489
533
|
// OBS-488: typed-oracle prefixes parse on the COMPLETE joined item text, never its first
|
|
490
534
|
// physical line — a wrapped `command:` body or falsifier tail is part of the criterion.
|
|
491
535
|
for (const draft of drafts) {
|
|
492
|
-
for (const raw of draft.acceptanceRaw) {
|
|
536
|
+
for (const [index, raw] of draft.acceptanceRaw.entries()) {
|
|
493
537
|
const typed = raw.match(ORACLE_RE);
|
|
494
538
|
if (typed) {
|
|
495
539
|
const [, kind, body] = typed;
|
|
496
540
|
if (!body.trim())
|
|
497
541
|
invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
|
|
498
542
|
draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
|
|
499
|
-
: kind === "test" ?
|
|
543
|
+
: kind === "test" ? parseTestOracle(draft.id, body, index)
|
|
500
544
|
: { oracle: "judge", text: body.trim() });
|
|
501
545
|
}
|
|
502
546
|
else {
|
|
@@ -561,6 +605,9 @@ export function compileNative(file, options = {}) {
|
|
|
561
605
|
return trimmed;
|
|
562
606
|
};
|
|
563
607
|
const csv = (value) => value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => stripAnnotation(item.trim())).filter(Boolean) : [];
|
|
608
|
+
// A fixture pin's path set is split only — never annotation-stripped: "fixtures/output (old)" is a
|
|
609
|
+
// filename, and csv() would silently retarget the declared obligation to "fixtures/output".
|
|
610
|
+
const pinPaths = (value) => splitTop(value).map((item) => item.trim()).filter(Boolean);
|
|
564
611
|
// OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
|
|
565
612
|
// paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
|
|
566
613
|
// mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
|
|
@@ -677,6 +724,7 @@ export function compileNative(file, options = {}) {
|
|
|
677
724
|
...(timeoutMinutes !== undefined ? { timeoutMinutes } : {}),
|
|
678
725
|
...(routingHints ? { routingHints } : {}),
|
|
679
726
|
...(draft.hasGates ? { gates: draft.gates } : {}),
|
|
727
|
+
...(draft.pinsRaw.length ? { pins: draft.pinsRaw.map((raw, i) => parsePin(draft.id, raw, i, pinPaths)) } : {}),
|
|
680
728
|
};
|
|
681
729
|
});
|
|
682
730
|
const result = validateGraph({
|
|
@@ -780,9 +828,19 @@ acceptance is required on every task (a nested list of observable outcomes).
|
|
|
780
828
|
acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
|
|
781
829
|
- command: <shell> (oracle: command — exit code)
|
|
782
830
|
- test: <name> (oracle: test — named test)
|
|
831
|
+
- test: <name> | suite: <path> (same, plus the tests/**/*.test.ts file it lands in;
|
|
832
|
+
the path lives in the item's landing field, never in the title)
|
|
783
833
|
- judge: <rubric> (oracle: judge — LLM-judged, free text)
|
|
784
834
|
A judge criterion carries ONE claim; a semicolon-joined criterion warns — split its clauses.
|
|
785
835
|
- <plain text> (compat: compiles as judge oracle, warns)
|
|
836
|
+
pins: nested list (optional) of retired-literal and fixture pin obligations:
|
|
837
|
+
- literal: <exact text> | glob: <search glob> (every file matching the glob that holds
|
|
838
|
+
the text is an obligated file)
|
|
839
|
+
- fixture: <comma-separated path set> (every matching file is itself obligated:
|
|
840
|
+
byte-pinned output the change will move;
|
|
841
|
+
it carries no literal text)
|
|
842
|
+
LAW: a pins declaration is a LIMITED AUTHORING CONTRACT, not an assertion analyzer —
|
|
843
|
+
tickmarkr holds you to the obligations you DECLARE; it does not discover the pins you forgot.
|
|
786
844
|
|
|
787
845
|
HARD BOUNDS — these FAIL the compile, they do not warn:
|
|
788
846
|
- at most 6 acceptance items per task (no exception path)
|
|
@@ -799,6 +857,9 @@ acceptance is required on every task (a nested list of observable outcomes).
|
|
|
799
857
|
describe() is allowed (OBS-511: the gate matches the criterion as the trailing segment of the
|
|
800
858
|
runner-visible full name), but the leaf title itself must equal the criterion: a shortened,
|
|
801
859
|
decorated, or paraphrased leaf selects ZERO tests and the gate parks even though the suite is green.
|
|
860
|
+
To say WHICH suite the test lands in, append " | suite: <tests/**/*.test.ts path>" — the path is
|
|
861
|
+
parsed off into the item's landing field and the title stays the bare leaf; a landing outside the
|
|
862
|
+
collectable glob refuses the compile.
|
|
802
863
|
Measured before the widening: two parks across two runs, one full attempt lost in each.
|
|
803
864
|
- NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
|
|
804
865
|
"no file references X" is not a criterion — it passes in a repo where the feature was never built.
|