tickmarkr 2.8.5 → 2.8.7

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 (38) hide show
  1. package/dist/adapters/codex-commit-check.d.ts +4 -3
  2. package/dist/adapters/codex-commit-check.js +42 -28
  3. package/dist/adapters/codex.d.ts +2 -2
  4. package/dist/adapters/codex.js +38 -32
  5. package/dist/adapters/grok.js +5 -9
  6. package/dist/adapters/model-lints.js +4 -3
  7. package/dist/adapters/prompt.js +9 -1
  8. package/dist/adapters/types.d.ts +10 -1
  9. package/dist/adapters/types.js +12 -0
  10. package/dist/cli/commands/approve.js +9 -28
  11. package/dist/cli/commands/init.d.ts +10 -0
  12. package/dist/cli/commands/init.js +38 -20
  13. package/dist/cli/commands/status.js +9 -6
  14. package/dist/cli/commands/version.d.ts +1 -1
  15. package/dist/cli/commands/version.js +36 -2
  16. package/dist/cli/help.d.ts +1 -0
  17. package/dist/cli/help.js +6 -3
  18. package/dist/compile/gsd.js +4 -3
  19. package/dist/compile/native.js +8 -6
  20. package/dist/drivers/subprocess.js +9 -4
  21. package/dist/gates/baseline.js +51 -10
  22. package/dist/gates/cache.js +2 -4
  23. package/dist/graph/schema.js +3 -1
  24. package/dist/run/daemon.js +147 -35
  25. package/dist/run/git-trust.d.ts +11 -0
  26. package/dist/run/git-trust.js +1 -1
  27. package/dist/run/git.d.ts +18 -1
  28. package/dist/run/git.js +73 -2
  29. package/dist/run/journal.d.ts +15 -1
  30. package/dist/run/journal.js +43 -6
  31. package/dist/run/repair-disposition.d.ts +1 -1
  32. package/dist/run/repair-disposition.js +2 -2
  33. package/dist/run/stall.d.ts +2 -0
  34. package/dist/run/stall.js +12 -4
  35. package/package.json +1 -1
  36. package/skills/tickmarkr-auto/SKILL.md +10 -8
  37. package/skills/tickmarkr-loop/SKILL.md +10 -8
  38. package/skills/tickmarkr-overseer/SKILL.md +10 -8
@@ -1,4 +1,3 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
@@ -11,7 +10,8 @@ import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
11
10
  import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
12
11
  import { stateDirName, tickmarkrDir } from "../../graph/graph.js";
13
12
  import { orcaHostDetected } from "../../drivers/index.js";
14
- import { WORKTREES_DIR } from "../../run/git.js";
13
+ import { gitSync, WORKTREES_DIR } from "../../run/git.js";
14
+ import { GitTrustRefusal } from "../../run/git-trust.js";
15
15
  import { Journal } from "../../run/journal.js";
16
16
  import { runLockOwner, runLockRunId, runStatusLine } from "../../run/lock.js";
17
17
  import { doctor } from "./doctor.js";
@@ -107,7 +107,11 @@ Outside multi-agent environments, run the loop directly.
107
107
 
108
108
  ### Version preflight
109
109
 
110
- Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, read \`package.json\` version, and stop if the versions differ anywhere (major, minor, or patch); the binary and repository must agree on the entire version, so binary \`2.1.0\` versus repository \`2.1.1\` is a stop. Tell the operator to update or link the correct binary. Never proceed on hope — stale binaries silently skip daemon gates. Also verify no run is live in this repository before starting one: lead with this repository's \`.tickmarkr/graph.lock\`, read its holder pid, and treat it as live until \`kill -0 <pid>\` proves that holder dead. Never require a machine-wide process pattern to be empty: a lawful run in another repository — or the probing shell's own argv — can match. If you use a process probe as secondary evidence, exclude the probing process, resolve every candidate's own cwd (for example with \`lsof -a -p <pid> -d cwd\`), and count only candidates whose cwd is this repository root.
110
+ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version --check\` and stop on a non-zero exit. In the tickmarkr source repository (\`package.json\` named \`tickmarkr\`), stop if the versions differ anywhere (major, minor, or patch); the binary and repository must agree on the entire version, so binary \`2.1.0\` versus repository \`2.1.1\` is a stop. In a consumer repository, the check compares installed agent guidance with this binary's packaged block and skills, never the app's manifest version; refresh drifted guidance with \`tickmarkr init --agent --force\`. Tell the operator to update or link the correct binary when the source version differs. Never proceed on hope — stale binaries silently skip daemon gates. Also verify no run is live in this repository before starting one: lead with this repository's \`.tickmarkr/graph.lock\`, read its holder pid, and treat it as live until \`kill -0 <pid>\` proves that holder dead. Never require a machine-wide process pattern to be empty: a lawful run in another repository — or the probing shell's own argv — can match. If you use a process probe as secondary evidence, exclude the probing process, resolve every candidate's own cwd (for example with \`lsof -a -p <pid> -d cwd\`), and count only candidates whose cwd is this repository root.
111
+
112
+ ### Seat hygiene
113
+
114
+ In a multi-tier session, the tiers clear each other at 50% context or more, after writing a handoff. A new spec or run is a mission boundary: write the handoff and clear before taking the next mission. Copy this duty into every brief and handoff. On a single-tier plain host, write your handoff and ask the operator to clear the session.
111
115
 
112
116
  ### Tip-verify-before-green
113
117
 
@@ -184,7 +188,7 @@ function skillFileList(root, prefix = "") {
184
188
  }
185
189
  // Drift is only computed for skills that ARE installed. A skill never installed is not drift — `--agent`
186
190
  // installs it and says so, and nagging about it would bury the case this exists for.
187
- function skillDrift(cwd) {
191
+ export function skillDrift(cwd) {
188
192
  const drifted = [];
189
193
  for (const { skillsDir } of hostTargets(cwd)) {
190
194
  for (const skill of AGENT_SKILLS) {
@@ -215,7 +219,7 @@ function skillDrift(cwd) {
215
219
  // superseded preflight rules (patch-version drift tolerated, a machine-wide process match required)
216
220
  // through the very release that fixed the tracked copy, and a consultation was launched under the stale
217
221
  // text with nobody able to see it. Same rule as skillDrift: a doc that was never written is not drift.
218
- const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter((docPath) => {
222
+ export const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter((docPath) => {
219
223
  if (!existsSync(docPath))
220
224
  return false;
221
225
  const current = readFileSync(docPath, "utf8");
@@ -227,6 +231,12 @@ const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter
227
231
  return true; // a truncated block is stale by definition
228
232
  return current.slice(begin, end + DOCS_END.length) !== AGENT_DOCS.trimEnd();
229
233
  });
234
+ export function installedAgentGuidance(cwd) {
235
+ return hostTargets(cwd).flatMap(({ skillsDir, docPath }) => [
236
+ ...AGENT_SKILLS.map((skill) => join(skillsDir, skill, "SKILL.md")).filter((path) => existsSync(path)),
237
+ ...(existsSync(docPath) && readFileSync(docPath, "utf8").includes(DOCS_BEGIN) ? [docPath] : []),
238
+ ]);
239
+ }
230
240
  const describeDrift = (d) => [
231
241
  d.missing.length ? `missing ${d.missing.join(", ")}` : "",
232
242
  d.modified.length ? `modified ${d.modified.join(", ")}` : "",
@@ -256,13 +266,8 @@ function gitTracked(cwd, path) {
256
266
  const rel = relative(cwd, path);
257
267
  if (rel.startsWith(".."))
258
268
  return false;
259
- try {
260
- execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd, stdio: "ignore" });
261
- return true;
262
- }
263
- catch {
264
- return false;
265
- }
269
+ // queue row 2 item b: protected git; a trust refusal throws, an ordinary failure still reads as untracked
270
+ return gitSync(cwd, ["ls-files", "--error-unmatch", "--", rel]).status === 0;
266
271
  }
267
272
  async function installAgentFiles(cwd, force, docs, notes) {
268
273
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
@@ -375,19 +380,29 @@ function isPathInside(parentDir, candidatePath) {
375
380
  }
376
381
  /**
377
382
  * The ONLY way the --remove path runs git: GIT_DIR, GIT_WORK_TREE and GIT_COMMON_DIR are stripped so the repository acted on
378
- * is always cwd's own, and LC_ALL=C keeps stderr parseable. Throws execFileSync's error (status, stderr) unchanged.
383
+ * is always cwd's own, and LC_ALL=C keeps stderr parseable. Queue row 2 item b: it runs through the protected path (gitSync),
384
+ * whose GitTrustRefusal propagates unchanged; a failed git throws an error carrying its status and stderr.
379
385
  */
380
386
  function removeGit(cwd, args) {
381
387
  const { GIT_DIR: _dir, GIT_WORK_TREE: _workTree, GIT_COMMON_DIR: _commonDir, ...env } = process.env;
382
- return execFileSync("git", args, { cwd, env: { ...env, LC_ALL: "C" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
388
+ const r = gitSync(cwd, args, { env: { ...env, LC_ALL: "C" } });
389
+ if (r.error)
390
+ throw r.error;
391
+ if (r.status !== 0)
392
+ throw Object.assign(new Error(`git ${args.join(" ")} failed (${r.status ?? r.signal})`), { status: r.status, stderr: r.stderr });
393
+ return r.stdout;
383
394
  }
395
+ /** A trust refusal is never one of the --remove path's fail-open answers. */
396
+ const rethrowRefusal = (error) => { if (error instanceof GitTrustRefusal)
397
+ throw error; };
384
398
  function isLinkedWorktree(cwd) {
385
399
  try {
386
400
  const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
387
401
  const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
388
402
  return resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir);
389
403
  }
390
- catch {
404
+ catch (error) {
405
+ rethrowRefusal(error);
391
406
  return false;
392
407
  }
393
408
  }
@@ -405,7 +420,8 @@ function checkLiveLock(cwd) {
405
420
  return mainOwner;
406
421
  }
407
422
  }
408
- catch {
423
+ catch (error) {
424
+ rethrowRefusal(error);
409
425
  // not a git repo
410
426
  }
411
427
  return undefined;
@@ -420,6 +436,7 @@ function isGitRepo(cwd) {
420
436
  return true;
421
437
  }
422
438
  catch (err) {
439
+ rethrowRefusal(err);
423
440
  const e = err;
424
441
  const stderr = String(e.stderr ?? "").trim();
425
442
  if (e.status === 128 && stderr.startsWith("fatal: not a git repository (or any of the parent directories)"))
@@ -433,6 +450,7 @@ function git(cwd, args, message) {
433
450
  return removeGit(cwd, args);
434
451
  }
435
452
  catch (err) {
453
+ rethrowRefusal(err);
436
454
  const stderr = String(err.stderr ?? "").trim();
437
455
  throw new Error(stderr ? `${message}: ${stderr}` : message);
438
456
  }
@@ -522,16 +540,16 @@ function handleRemove(cwd) {
522
540
  const out = removeGit(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/tickmarkr/"]);
523
541
  branches = out.split("\n").map((b) => b.trim()).filter(Boolean);
524
542
  }
525
- catch {
526
- // ignore
543
+ catch (error) {
544
+ rethrowRefusal(error);
527
545
  }
528
546
  let preservedRefs = [];
529
547
  try {
530
548
  const out = removeGit(cwd, ["for-each-ref", "--format=%(refname)", "refs/tickmarkr/preserved/"]);
531
549
  preservedRefs = out.split("\n").map((r) => r.trim()).filter(Boolean);
532
550
  }
533
- catch {
534
- // ignore
551
+ catch (error) {
552
+ rethrowRefusal(error);
535
553
  }
536
554
  for (const b of branches) {
537
555
  notes.push(`left branch: ${b}`);
@@ -13,7 +13,7 @@ import { projectOperatorSummary } from "../../run/operator-summary.js";
13
13
  import { trackJournalRows } from "../../run/protocol.js";
14
14
  import { newestPark, permittedDecisionVerbs } from "./approve.js";
15
15
  import { currentOwed, endToEndFirstPass, firstPassText, leadLines, leadRows, needsYouText, wallText } from "./report.js";
16
- import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, fatalRunEndCause, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, visibleControls, } from "../../run/journal.js";
16
+ import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, fatalRunEndCause, recordedTaskFailureKind, runHasEnded, restoresUpheldFeedback, visibleControls, } from "../../run/journal.js";
17
17
  import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
18
18
  import { normalizeGateOutcome } from "../../run/outcome.js";
19
19
  import { desiredPanes } from "../../run/reconcile.js";
@@ -1070,15 +1070,18 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1070
1070
  }).map(sanitizeTaskText)
1071
1071
  : [];
1072
1072
  // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
1073
- // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
1073
+ // older journal equally readable by asking restoresUpheldFeedback what was funded at that exact
1074
1074
  // restore boundary; current resume-restore rows additionally carry the same task for narration.
1075
1075
  // OBS-1178: both folds read decisions through the one decision fold over the whole journal.
1076
+ // Queue row 225 (D-2111): only each task's NEWEST restore speaks — never a historical set.
1076
1077
  const decided = effectiveEvents(events);
1077
1078
  const preservedRefs = preservedRefsByTask(events);
1078
- const restoredUpheldFeedback = new Set(decided.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1079
- && upheldFeedbackByTask(decided.slice(0, index)).has(event.taskId)
1080
- ? [event.taskId]
1081
- : []));
1079
+ const newestRestores = new Map();
1080
+ decided.forEach((event, index) => { if (event.event === "resume-restore" && event.taskId)
1081
+ newestRestores.set(event.taskId, index); });
1082
+ const restoredUpheldFeedback = new Set([...newestRestores]
1083
+ .filter(([taskId, index]) => restoresUpheldFeedback(decided.slice(0, index), taskId))
1084
+ .map(([taskId]) => taskId));
1082
1085
  // OBS-1178: a failed task's newest task-failed row is the token its recheck binds to. Only an
1083
1086
  // effective decision consumes it — a refused or unsound recheck leaves the task failed and the token shown.
1084
1087
  const failureTokens = new Map();
@@ -1,2 +1,2 @@
1
1
  export declare function distFingerprint(dir?: string): string;
2
- export declare function version(argv?: string[], dir?: string): Promise<string>;
2
+ export declare function version(argv?: string[], dir?: string, cwd?: string): Promise<string>;
@@ -23,7 +23,41 @@ export function distFingerprint(dir = distDir) {
23
23
  hash.update(relative(dir, file)).update("\0").update(readFileSync(file)).update("\0");
24
24
  return hash.digest("hex");
25
25
  }
26
- export async function version(argv = [], dir = distDir) {
26
+ export async function version(argv = [], dir = distDir, cwd = process.cwd()) {
27
27
  const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
28
- return argv.includes("--dist") ? `${v} dist:${resolve(dir)} fingerprint:${distFingerprint(dir)}` : v;
28
+ const stamp = argv.includes("--dist") ? `${v} dist:${resolve(dir)} fingerprint:${distFingerprint(dir)}` : v;
29
+ if (!argv.includes("--check"))
30
+ return stamp;
31
+ let manifest;
32
+ try {
33
+ manifest = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
34
+ }
35
+ catch { /* no source manifest — consumer guidance check */ }
36
+ if (manifest?.name === "tickmarkr") {
37
+ const repoVersion = manifest.version ?? "missing";
38
+ const equal = v === repoVersion;
39
+ const out = `tickmarkr binary ${stamp}: compared tickmarkr source repository package.json version ${repoVersion} — ${equal ? "entire version matches" : "versions differ; update or link the correct binary"}`;
40
+ if (!equal)
41
+ throw new Error(out);
42
+ return out;
43
+ }
44
+ const { docsDrift, installedAgentGuidance, skillDrift } = await import("./init.js");
45
+ const installed = installedAgentGuidance(cwd);
46
+ if (!installed.length)
47
+ return `tickmarkr binary ${stamp}: no installed agent guidance to compare`;
48
+ const drift = [
49
+ ...skillDrift(cwd).flatMap(({ dir: skillDir, missing, modified }) => [
50
+ ...missing.map((file) => `missing ${relative(cwd, join(skillDir, file))}`),
51
+ ...modified.map((file) => `modified ${relative(cwd, join(skillDir, file))}`),
52
+ ]),
53
+ ...docsDrift(cwd).map((file) => `stale agent-docs block in ${relative(cwd, file)}`),
54
+ ];
55
+ const out = [
56
+ `tickmarkr binary ${stamp}: compared installed agent guidance with this binary's packaged block and skills:`,
57
+ ...installed.map((file) => ` ${relative(cwd, file)}`),
58
+ ...(drift.length ? [...drift, "refresh: tickmarkr init --agent --force"] : ["installed guidance matches"]),
59
+ ].join("\n");
60
+ if (drift.length)
61
+ throw new Error(out);
62
+ return out;
29
63
  }
@@ -195,6 +195,7 @@ export declare const COMMAND_HELP: {
195
195
  description: string;
196
196
  options: {
197
197
  "--dist": string;
198
+ "--check": string;
198
199
  };
199
200
  examples: string[];
200
201
  };
package/dist/cli/help.js CHANGED
@@ -169,9 +169,12 @@ export const COMMAND_HELP = {
169
169
  ],
170
170
  },
171
171
  version: {
172
- usage: "version [--dist]", description: "Print the installed package version. Top-level aliases: --version and -v.",
173
- options: { "--dist": "Also print the resolved build directory and distribution fingerprint." },
174
- examples: ["version", "version --dist"],
172
+ usage: "version [--dist] [--check]", description: "Print the installed package version. Top-level aliases: --version and -v.",
173
+ options: {
174
+ "--dist": "Also print the resolved build directory and distribution fingerprint.",
175
+ "--check": "Compare the entire source version when package.json is named tickmarkr; otherwise check installed agent guidance against this binary. Exit non-zero on drift; no installed guidance exits zero.",
176
+ },
177
+ examples: ["version", "version --dist", "version --check"],
175
178
  },
176
179
  verify: {
177
180
  usage: "verify [--base <ref>] [--criteria <file> | --task <id>] [options]",
@@ -1,9 +1,9 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
2
  import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
3
  import { parse as parseYaml } from "yaml";
5
4
  import { AcceptanceItemSchema, TIERS, validateGraph } from "../graph/schema.js";
6
5
  import { CompileError, assertWriteScope, inferShape, sha256 } from "./common.js";
6
+ import { gitSync } from "../run/git.js";
7
7
  import { classifyContextPath } from "./native.js";
8
8
  // GSD artifact front-end (spec v1.3): one GSD *plan* is one tickmarkr *task* — a plan is
9
9
  // worktree-sized; its inner <task> steps stay in the worker prompt via context[0] = the plan file.
@@ -306,9 +306,10 @@ export function compileGsd(src, root) {
306
306
  // Match native context reachability against the committed tree. This block only obtains the HEAD
307
307
  // snapshot; classifyContextPath remains the single authority for missing, untracked, and glob refs.
308
308
  const gitDir = dirname(files[0]);
309
- const top = spawnSync("git", ["-C", gitDir, "rev-parse", "--show-toplevel"], { encoding: "utf8", maxBuffer: 1 << 28 });
309
+ // queue row 2 item b: protected git; a trust refusal throws instead of reading as "no repository"
310
+ const top = gitSync(gitDir, ["rev-parse", "--show-toplevel"]);
310
311
  // -z (queue row 71): without it a non-ASCII path is C-quoted and a tracked ref reads as untracked
311
- const tree = spawnSync("git", ["-C", gitDir, "ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD"], { encoding: "utf8", maxBuffer: 1 << 28 });
312
+ const tree = gitSync(gitDir, ["ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD"]);
312
313
  const repoRoot = typeof top.stdout === "string" ? top.stdout.trim() : "";
313
314
  // `root` is the repository whose worktrees will consume these refs. A source nested inside some
314
315
  // unrelated repository (as with vendored fixtures) must not be judged against that outer tree.
@@ -1,9 +1,9 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { existsSync, readFileSync } from "node:fs";
3
2
  import { basename, dirname, join, resolve } from "node:path";
4
3
  import { filesGlob } from "../graph/files-glob.js";
5
4
  import picomatch from "picomatch";
6
5
  import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
6
+ import { gitSync } from "../run/git.js";
7
7
  import { criterionReadsOnly } from "./collateral.js";
8
8
  import { CompileError, inferShape, sha256 } from "./common.js";
9
9
  // OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
@@ -18,7 +18,8 @@ import { CompileError, inferShape, sha256 } from "./common.js";
18
18
  // `ls-tree -r HEAD` enumerates that tree once; per-path `cat-file -e HEAD:<path>` is the same oracle
19
19
  // one path at a time, and one spawn beats N.
20
20
  function trackedAtHead(dir) {
21
- const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
21
+ // queue row 2 item b: every compile git runs through the protected path; a trust refusal throws, never fails open
22
+ const git = (...args) => gitSync(dir, args);
22
23
  const top = git("rev-parse", "--show-toplevel");
23
24
  // --full-tree is load-bearing: without it `ls-tree` scopes to the CWD's subtree and prints paths
24
25
  // relative to it, so a spec in specs/ would see only specs/-relative names and misjudge every
@@ -146,7 +147,7 @@ function symlinkScopeWarnings(file, tasks, base) {
146
147
  if (!scoped.length || !repositoryRoot(file))
147
148
  return [];
148
149
  const tree = base ?? "HEAD";
149
- const git = (...args) => spawnSync("git", ["-C", dirname(file), ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
150
+ const git = (...args) => gitSync(dirname(file), args);
150
151
  const failed = (r) => r.error || r.signal || r.status !== 0 || typeof r.stdout !== "string";
151
152
  const why = (r) => r.signal ? `signal ${r.signal}` : r.status === null ? "git did not run" : `git exit ${r.status}`;
152
153
  const advisory = (what, r) => `tickmarkr: advisory: files[] symlink inspection could not read ${what} in the selected git tree ${JSON.stringify(tree)} (${why(r)}) — symlink scope is unverified for this compile.`;
@@ -270,7 +271,7 @@ function testsAtHead(root) {
270
271
  const cached = headTestCache.get(root);
271
272
  if (cached)
272
273
  return cached;
273
- const grep = spawnSync("git", ["-C", root, "grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"], { encoding: "utf8", maxBuffer: 1 << 25 });
274
+ const grep = gitSync(root, ["grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"]);
274
275
  if (grep.status !== 0 || typeof grep.stdout !== "string") {
275
276
  headTestCache.set(root, []);
276
277
  return [];
@@ -389,7 +390,7 @@ function criterionScopeFinding(task, text, criterion, id, tests) {
389
390
  // 20-task spec compiled in seconds and the committed-spec corpus test timed out. Fail-open like
390
391
  // testsAtHead: no git answer means no corpus, and the lint stays quiet rather than wrong.
391
392
  function repoFileList(root) {
392
- const ls = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 1 << 25 });
393
+ const ls = gitSync(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
393
394
  if (ls.status !== 0 || typeof ls.stdout !== "string")
394
395
  return [];
395
396
  return ls.stdout.split("\0").filter(Boolean).sort();
@@ -949,7 +950,8 @@ acceptance is required on every task (a nested list of observable outcomes).
949
950
  mode: partner-led | risk-based | staff-led — this engagement's routing mode
950
951
  (loses only to an explicit \`run --mode\` flag)
951
952
  base: commit-or-ref — optional declared source base; the declaration must begin at column zero
952
- (runtime enforcement happens before a run)
953
+ (proven against the run's starting commit at run and resume start; a drifted
954
+ or unresolvable base is refused before anything is written)
953
955
 
954
956
  Fields available per task:
955
957
  goal: outcome the task must achieve (defaults to the title if omitted)
@@ -49,7 +49,7 @@ export class SubprocessDriver {
49
49
  n = 0;
50
50
  async slot(cwd, name) {
51
51
  const id = `sp-${++this.n}`;
52
- this.slots.set(id, { buf: "", exited: false });
52
+ this.slots.set(id, { buf: "", receivedBytes: 0, exited: false });
53
53
  return { id, name, cwd };
54
54
  }
55
55
  state(slot) {
@@ -72,8 +72,13 @@ export class SubprocessDriver {
72
72
  detached: true,
73
73
  });
74
74
  s.proc = p;
75
- p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
76
- p.stderr.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
75
+ // Queue row 233 (D-2130): count both received streams before retention truncates the tail.
76
+ const receive = (d) => {
77
+ s.receivedBytes += d.length;
78
+ s.buf = (s.buf + d).slice(-MAX_BUF);
79
+ };
80
+ p.stdout.on("data", receive);
81
+ p.stderr.on("data", receive);
77
82
  p.on("close", () => (s.exited = true));
78
83
  p.on("error", (e) => { s.buf = (s.buf + `\n[tickmarkr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
79
84
  // Queue row 108 (D-1622): THIS run's transport state. The slot's `exited` outlives an invocation (a reused slot) and
@@ -117,7 +122,7 @@ export class SubprocessDriver {
117
122
  async transportState(slot) {
118
123
  const s = this.state(slot);
119
124
  const run = s.invocation;
120
- return { closed: run?.closed ?? false, bytes: Buffer.byteLength(s.buf), exitCode: run?.exitCode ?? null, signal: run?.signal ?? null };
125
+ return { closed: run?.closed ?? false, bytes: s.receivedBytes, exitCode: run?.exitCode ?? null, signal: run?.signal ?? null };
121
126
  }
122
127
  async read(slot, lines) {
123
128
  return this.state(slot).buf.split("\n").slice(-lines).join("\n");
@@ -1,11 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { execFileSync } from "node:child_process";
3
2
  import { EVICTION_TOMBSTONE_LIMIT, EVICTION_TOMBSTONES_FILE, parseEvictionTombstones } from "../run/receipt-resolver.js";
4
3
  import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
5
4
  import { availableParallelism, loadavg, tmpdir } from "node:os";
6
5
  import { basename, join } from "node:path";
7
6
  import { DEFAULT_EVIDENCE_QUOTA_BYTES } from "../config/config.js";
8
- import { dependencyLinkRefusal, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
7
+ import { dependencyLinkRefusal, gitSync, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
9
8
  import { executionSignal } from "../run/execution-budget.js";
10
9
  import { isVitestTestCommand, manifestChildEnv, manifestFileCount } from "./test-manifest.js";
11
10
  export const EVIDENCE_TAIL_BYTES = 16 * 1024;
@@ -121,7 +120,9 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
121
120
  let subjectCommit = opts.subjectCommit ?? null;
122
121
  if (!subjectCommit) {
123
122
  try {
124
- subjectCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
123
+ const result = gitSync(cwd, ["rev-parse", "HEAD"]);
124
+ if (result.status === 0 && result.stdout.trim())
125
+ subjectCommit = result.stdout.trim();
125
126
  }
126
127
  catch { /* unknown subject */ }
127
128
  }
@@ -427,11 +428,30 @@ export function classifyFailureOutput(output) {
427
428
  // and `stderr | <task id>` (digits and underscores) when the reporter no longer knows the task
428
429
  // (dist/chunks/index.*.js, `headerText`). The stripper knew only the first form.
429
430
  const VITEST_ECHO_BLOCK_RE = /^\s*std(?:out|err)\s+\|\s+(?:unknown test\s*$|\d+_[\d_]+\s*$|\S+\.(?:test|spec)\.[cm]?[jt]sx?(?:\s*$|\s+>\s+\S))/;
430
- /** Keep runner output while omitting Vitest's echoed test-owned stdout/stderr diagnostic blocks. */
431
+ // Queue row 211 (D-2118): only Jest's complete, column-zero recovery warning is noise. A missing
432
+ // neighbour, changed hint, indented copy or multi-line Watchman error remains failure evidence.
433
+ const JEST_WATCHMAN_HEADER = "jest-haste-map: Watchman crawl failed. Retrying once with node crawler.";
434
+ const JEST_WATCHMAN_HINT = " Usually this happens when watchman isn't running. Create an empty `.watchmanconfig` file in your project's root folder or initialize a git or hg repository in your project.";
435
+ const JEST_WATCHMAN_ERROR_RE = /^ {2}Error: Watchman error: [^\r\n]+\. Make sure watchman is running for this project\. See https:\/\/facebook\.github\.io\/watchman\/docs\/troubleshooting\.$/;
436
+ /** Queue row 211 (D-2118): display readers retain raw Vitest echo lines while dropping this warning. */
437
+ const withoutJestWatchmanBlocks = (output) => {
438
+ const kept = [];
439
+ const lines = output.split("\n");
440
+ for (let i = 0; i < lines.length; i++) {
441
+ const line = lines[i];
442
+ if (line === JEST_WATCHMAN_HEADER && lines[i + 1] === JEST_WATCHMAN_HINT && JEST_WATCHMAN_ERROR_RE.test(lines[i + 2] ?? "")) {
443
+ i += 2;
444
+ continue;
445
+ }
446
+ kept.push(line);
447
+ }
448
+ return kept;
449
+ };
450
+ /** Queue row 211 (D-2118): omit the recovered Jest warning alongside Vitest's test-owned echo blocks. */
431
451
  const withoutVitestEchoBlocks = (output) => {
432
452
  const outside = [];
433
453
  let inVitestEchoBlock = false;
434
- for (const line of output.split("\n")) {
454
+ for (const line of withoutJestWatchmanBlocks(output)) {
435
455
  const clean = line.replace(ANSI_RE, "");
436
456
  const runnerLine = stripTurboPrefix(clean) ?? clean;
437
457
  if (inVitestEchoBlock) {
@@ -624,8 +644,7 @@ function fingerprintOccurrences(output) {
624
644
  }
625
645
  // The diagnostic channel for a runner we cannot read: raw output lines, never fingerprints, never a
626
646
  // verdict. They exist so the operator sees SOMETHING when a green command turns red unrecognizably.
627
- const unrecognizedEvidence = (raw) => raw
628
- .split("\n")
647
+ const unrecognizedEvidence = (raw) => withoutJestWatchmanBlocks(raw)
629
648
  .map((l) => l.replace(ANSI_RE, "").trimEnd())
630
649
  .filter((l) => VOCAB_RE.test(l))
631
650
  .slice(0, 10)
@@ -1027,11 +1046,33 @@ export async function detectVacuousOracles(cwd, tasks) {
1027
1046
  // drop a FAIL line that fingerprint-collides with baseline noise). No headline anchors → byte-identical
1028
1047
  // fallback to today's text (non-vitest runners lose nothing). RED-pinned by tests/gates/baseline.test.ts
1029
1048
  // "HYG-08: details headlines the failing test, not the noise".
1049
+ // Queue row 224(a) (D-2118): Jest identities and their first message are display-only; Console and
1050
+ // column-zero validation bullets are not identities. Keep the existing metadata extraction separate.
1051
+ const JEST_IDENTITY_RE = /^ {2}● (?!Console\s*$)\S.*$/;
1030
1052
  function headlineDetails(raw, fresh, freshLines) {
1031
- const headlines = raw
1053
+ const namedHeadlines = raw
1032
1054
  .split("\n")
1033
1055
  .map((l) => l.replace(ANSI_RE, ""))
1034
1056
  .filter((l) => namesFailureEitherForm(l) || SUMMARY_FAIL_RE.test(l));
1057
+ const outputLines = withoutJestWatchmanBlocks(raw).map((l) => l.replace(ANSI_RE, ""));
1058
+ let inVitestEchoBlock = false;
1059
+ const headlines = outputLines.flatMap((line, index) => {
1060
+ const runnerLine = stripTurboPrefix(line) ?? line;
1061
+ const echoed = inVitestEchoBlock || VITEST_ECHO_BLOCK_RE.test(runnerLine);
1062
+ if (inVitestEchoBlock) {
1063
+ if (runnerLine.trim() === "")
1064
+ inVitestEchoBlock = false;
1065
+ }
1066
+ else if (echoed)
1067
+ inVitestEchoBlock = true;
1068
+ // Queue row 224(a) (D-2118): preserve raw FAIL/summary headlines; only Jest identities exclude echoes.
1069
+ if (namesFailureEitherForm(line) || SUMMARY_FAIL_RE.test(line))
1070
+ return [line];
1071
+ if (echoed || !JEST_IDENTITY_RE.test(line))
1072
+ return [];
1073
+ const message = withoutVitestEchoBlocks(outputLines.slice(index + 1).join("\n")).find((l) => l.trim() !== "");
1074
+ return message === undefined ? [line] : [line, message];
1075
+ });
1035
1076
  // S22: the ORIGINAL runner lines behind v2 comparison keys are the diagnostic; the keys are secondary.
1036
1077
  // A legacy entry's lines ARE its keys, so its details stay byte-identical.
1037
1078
  const lines = [...new Set(freshLines)].join("\n");
@@ -1041,7 +1082,7 @@ function headlineDetails(raw, fresh, freshLines) {
1041
1082
  return { details: same ? `new failures vs baseline:\n${lines}` : `new failures vs baseline:\n${lines}\n\n${keys}` };
1042
1083
  return {
1043
1084
  details: `failing tests:\n${headlines.join("\n")}\n\n${same ? "" : `new failure lines vs baseline:\n${lines}\n\n`}${keys}`,
1044
- meta: { failingTests: headlines.filter(namesFailureEitherForm) },
1085
+ ...(namedHeadlines.length ? { meta: { failingTests: namedHeadlines.filter(namesFailureEitherForm) } } : {}),
1045
1086
  };
1046
1087
  }
1047
1088
  export async function compareToBaseline(cwd, commands, baseline, enabled, opts = {}) {
@@ -1224,7 +1265,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1224
1265
  if (classification === "infra") {
1225
1266
  const evidence = failingLines.length
1226
1267
  ? failingLines.slice(0, 10).join("\n")
1227
- : unrecognizedEvidence(raw) || raw.trim().split("\n").slice(0, 10).join("\n");
1268
+ : unrecognizedEvidence(raw) || withoutJestWatchmanBlocks(raw).join("\n").trim().split("\n").slice(0, 10).join("\n");
1228
1269
  record({
1229
1270
  gate: name,
1230
1271
  pass: false,
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { execSync } from "node:child_process";
3
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
3
  import { dirname, join, relative, resolve } from "node:path";
5
4
  import { tmpdir } from "node:os";
6
5
  import { checkoutIncarnation, describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
7
6
  import { shq } from "../adapters/types.js";
7
+ import { trustedCommonDir } from "../run/git-trust.js";
8
8
  import { titleEnvironment } from "../run/title-environment.js";
9
9
  export const DEFAULT_VERDICT_CACHE_BOUND = 128;
10
10
  let testCacheBound;
@@ -269,9 +269,7 @@ export function resolveStateDir(worktree, artifactDir) {
269
269
  return join(worktree, ".tickmarkr");
270
270
  }
271
271
  try {
272
- const commonDir = execSync("git rev-parse --git-common-dir", { cwd: worktree, encoding: "utf8" }).trim();
273
- const resolved = resolve(worktree, commonDir);
274
- const commonRoot = realpathSync(dirname(resolved));
272
+ const commonRoot = realpathSync(dirname(trustedCommonDir(worktree)));
275
273
  if (existsSync(join(commonRoot, ".tickmarkr"))) {
276
274
  return join(commonRoot, ".tickmarkr");
277
275
  }
@@ -118,7 +118,9 @@ export const RunGraphSchema = z
118
118
  source: z.enum(SPEC_SOURCES),
119
119
  paths: z.array(z.string()),
120
120
  hash: z.string(),
121
- // Q11 compile half: an author-declared ref only. Runtime Git resolution/enforcement is later.
121
+ // Q11 / queue row 220: an author-declared ref, proven at run and resume start against the run's
122
+ // starting commit (HEAD for a run, the recorded run-start base for a resume) by ancestry or patch-id;
123
+ // drifted or unresolvable, the run or resume is refused before it writes anything. Never rebased onto.
122
124
  base: z.string().min(1).optional(),
123
125
  }),
124
126
  tasks: z.array(TaskSchema).min(1),