tickmarkr 1.85.0 → 1.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.js +5 -3
  7. package/dist/adapters/fake.js +33 -4
  8. package/dist/adapters/model-lints.d.ts +25 -5
  9. package/dist/adapters/model-lints.js +184 -50
  10. package/dist/adapters/model-windows.d.ts +31 -0
  11. package/dist/adapters/model-windows.js +69 -0
  12. package/dist/adapters/prompt.d.ts +5 -1
  13. package/dist/adapters/prompt.js +13 -4
  14. package/dist/adapters/registry.d.ts +25 -26
  15. package/dist/adapters/registry.js +173 -110
  16. package/dist/adapters/types.js +15 -3
  17. package/dist/brand.d.ts +5 -1
  18. package/dist/brand.js +18 -2
  19. package/dist/cli/commands/doctor.d.ts +3 -0
  20. package/dist/cli/commands/doctor.js +43 -21
  21. package/dist/cli/commands/fleet.d.ts +7 -0
  22. package/dist/cli/commands/fleet.js +94 -74
  23. package/dist/cli/commands/init.js +118 -5
  24. package/dist/cli/commands/status.js +44 -18
  25. package/dist/compile/gsd.d.ts +2 -1
  26. package/dist/compile/gsd.js +68 -2
  27. package/dist/compile/native.d.ts +14 -0
  28. package/dist/compile/native.js +161 -12
  29. package/dist/config/config.d.ts +20 -5
  30. package/dist/config/config.js +96 -64
  31. package/dist/config/fleet-overlay.d.ts +25 -20
  32. package/dist/config/fleet-overlay.js +195 -77
  33. package/dist/config/fleet-why.d.ts +23 -0
  34. package/dist/config/fleet-why.js +42 -0
  35. package/dist/drivers/herdr.d.ts +1 -0
  36. package/dist/drivers/herdr.js +70 -19
  37. package/dist/gates/acceptance.js +7 -2
  38. package/dist/gates/llm.d.ts +0 -1
  39. package/dist/gates/llm.js +5 -30
  40. package/dist/gates/review.d.ts +2 -1
  41. package/dist/gates/review.js +9 -7
  42. package/dist/gates/verdict-cause.d.ts +4 -0
  43. package/dist/gates/verdict-cause.js +63 -0
  44. package/dist/graph/schema.d.ts +6 -0
  45. package/dist/graph/schema.js +8 -5
  46. package/dist/route/router.d.ts +0 -5
  47. package/dist/route/router.js +16 -20
  48. package/dist/run/consult.d.ts +6 -0
  49. package/dist/run/consult.js +35 -25
  50. package/dist/run/daemon.js +71 -15
  51. package/dist/run/journal.js +87 -7
  52. package/dist/tui/cockpit/capture.d.ts +12 -0
  53. package/dist/tui/cockpit/capture.js +37 -1
  54. package/dist/tui/cockpit/components.js +8 -8
  55. package/dist/tui/cockpit/theme.d.ts +32 -26
  56. package/dist/tui/cockpit/theme.js +11 -5
  57. package/dist/tui/ink/components.d.ts +0 -15
  58. package/dist/tui/ink/components.js +0 -17
  59. package/dist/tui/ink/fleet-app.d.ts +4 -1
  60. package/dist/tui/ink/fleet-app.js +134 -13
  61. package/fixtures/sample.native.md +1 -1
  62. package/package.json +1 -1
  63. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  64. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  65. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  66. package/dist/tui/ink/studio-app.d.ts +0 -59
  67. package/dist/tui/ink/studio-app.js +0 -320
  68. package/dist/tui/save.d.ts +0 -38
  69. package/dist/tui/save.js +0 -96
  70. package/dist/tui/staging.d.ts +0 -29
  71. package/dist/tui/staging.js +0 -78
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
4
+ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse } from "../../adapters/registry.js";
5
5
  import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
- import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, harvestFleetProvenance, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
6
+ import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, overlayBytesLoadError, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
7
+ import { projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
7
8
  import { SHAPES, TIERS } from "../../graph/schema.js";
8
9
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
9
10
  import { route } from "../../route/router.js";
@@ -36,26 +37,30 @@ function currentRepoOverlayText(repoRoot) {
36
37
  const p = repoOverlayPath(repoRoot);
37
38
  return existsSync(p) ? readFileSync(p, "utf8") : "";
38
39
  }
39
- function provenanceMap(editable) {
40
- const out = {};
41
- for (const [adapter, models] of Object.entries(editable.tiers)) {
42
- for (const [model, v] of Object.entries(models)) {
43
- if (v?.provenance) {
44
- out[adapter] ??= {};
45
- out[adapter][model] = v.provenance;
46
- }
40
+ /** Atomic sibling-temp writer. Reading and serializing happen before `${path}.tmp` exists, and
41
+ * any pre-rename interruption unlinks only that exact candidate while the original remains intact. */
42
+ export function writeFleetOverlay(path, serialize, hooks = {}) {
43
+ const prior = hooks.readPrior
44
+ ? hooks.readPrior(path)
45
+ : (existsSync(path) ? readFileSync(path, "utf8") : "");
46
+ const bytes = serialize(prior);
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ const tmp = `${path}.tmp`;
49
+ try {
50
+ writeFileSync(tmp, bytes);
51
+ hooks.beforeRename?.();
52
+ renameSync(tmp, path);
53
+ }
54
+ catch (error) {
55
+ try {
56
+ if (existsSync(tmp))
57
+ unlinkSync(tmp);
47
58
  }
59
+ catch {
60
+ // Preserve the write failure; cleanup is constrained to the exact sibling candidate.
61
+ }
62
+ throw error;
48
63
  }
49
- return out;
50
- }
51
- // v1.51 T4: serializeFleetOverlay predates routing.mode — splice the mode line under routing:
52
- // so a repo-declared mode survives fleet writes and a mode selection lands as routing.mode.
53
- function withModeLine(yaml, mode) {
54
- if (!mode)
55
- return yaml;
56
- if (/^routing:$/m.test(yaml))
57
- return yaml.replace(/^routing:$/m, `routing:\n mode: ${mode}`);
58
- return `routing:\n mode: ${mode}\n${yaml}`;
59
64
  }
60
65
  function formatFleetSteering(cfg) {
61
66
  const blocks = [];
@@ -76,12 +81,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
76
81
  args: argv,
77
82
  options: {
78
83
  print: { type: "boolean" },
84
+ why: { type: "boolean" },
79
85
  "global-dir": { type: "string" },
80
86
  fresh: { type: "boolean" },
81
87
  },
82
88
  });
83
89
  const globalDir = values["global-dir"] ?? globalConfigDir();
84
90
  const print = values.print ?? false;
91
+ const why = values.why ?? false;
85
92
  const input = io.input ?? process.stdin;
86
93
  const output = io.output ?? process.stdout;
87
94
  const interactive = input.isTTY === true && output.isTTY === true;
@@ -95,7 +102,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
95
102
  // not from another parse of either raw overlay.
96
103
  return `${body.slice(0, nl)}\n# mode: ${rm.mode.mode} (${rm.source})${body.slice(nl)}${formatFleetSteering(rm.cfg)}`;
97
104
  }
98
- if (!interactive)
105
+ if (!why && !interactive)
99
106
  return { out: NON_TTY_MSG, code: 1 };
100
107
  const fresh = values.fresh ?? false;
101
108
  const { reuse, health: cached } = initDoctorReuse(cwd, fresh);
@@ -107,10 +114,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
107
114
  }
108
115
  const rm = resolveRunMode(cwd, { globalDir });
109
116
  const cfg = rm.cfg;
110
- // OBS-88: harvest existing `# note` comments from the overlay bytes at session load — the
111
- // session must know about every prior note, not only its own edits, or the next write strips them
112
- const harvested = harvestFleetProvenance(currentRepoOverlayText(cwd));
113
- const initial = fleetEditableFromConfig(cfg, harvested.tiers);
117
+ const initial = fleetEditableFromConfig(cfg);
114
118
  const editable = structuredClone(initial);
115
119
  const health = cached;
116
120
  const modelGroups = adapters
@@ -183,21 +187,52 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
183
187
  : [" (no floor changes)"]),
184
188
  ];
185
189
  };
186
- const autoPrefer = readAutoPrefer(cwd);
187
- const overlayShapes = overlayPreferShapes(cwd, { globalDir });
188
- const routedShapeRows = (mode, map) => SHAPES.map((shape) => {
189
- let now;
190
- try {
191
- const routed = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
192
- const assignment = routed.assignment;
193
- now = `${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
194
- }
195
- catch (error) {
196
- now = error.message;
190
+ const whyDeclaration = (shape, mode, map, mapProducedValue) => {
191
+ const mapChanged = JSON.stringify(map[shape] ?? {}) !== JSON.stringify(editable.map[shape] ?? {});
192
+ if (mapProducedValue) {
193
+ return mapChanged
194
+ ? { operatorPinned: true }
195
+ : { declaredAt: `routing.map.${shape}` };
197
196
  }
198
- const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
199
- return { id: shape, label: `${shape} → ${now}${auto}` };
200
- });
197
+ const floorSource = modeCfgs[mode].mode.provenance[shape];
198
+ if (floorSource === undefined)
199
+ return {};
200
+ if (floorSource === "config floors")
201
+ return { declaredAt: `routing.floors.${shape}` };
202
+ if (mode !== rm.mode.mode)
203
+ return { operatorPinned: true };
204
+ return rm.source === "default"
205
+ ? { declaredAt: `routing.floors.${shape}` }
206
+ : { declaredAt: "routing.mode" };
207
+ };
208
+ const projectedShapeRows = (mode, map) => {
209
+ const values = SHAPES.map((shape) => {
210
+ try {
211
+ const routed = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
212
+ const assignment = routed.assignment;
213
+ const effective = `${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
214
+ const mapProducedValue = map[shape]?.pin !== undefined || routed.provenance.includes("via prefer");
215
+ return {
216
+ id: shape,
217
+ effective,
218
+ ...whyDeclaration(shape, mode, map, mapProducedValue),
219
+ };
220
+ }
221
+ catch (error) {
222
+ const mapProducedValue = map[shape]?.pin !== undefined || (map[shape]?.prefer?.length ?? 0) > 0;
223
+ return {
224
+ id: shape,
225
+ effective: error.message,
226
+ ...whyDeclaration(shape, mode, map, mapProducedValue),
227
+ setupCommand: "tickmarkr fleet",
228
+ };
229
+ }
230
+ });
231
+ return projectFleetWhy(values, { repoRoot: cwd, globalDir });
232
+ };
233
+ const routedShapeRows = (mode, map) => projectedShapeRows(mode, map).map(({ id, label }) => ({ id, label }));
234
+ if (why)
235
+ return renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map));
201
236
  const candidatesForShape = (shape, mode, map) => shapeCandidates(previewTask(shape), previewCfg(mode, map), channels, profile).map((candidate) => ({
202
237
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
203
238
  label: candidateRow(candidate, cfg.pricing),
@@ -220,6 +255,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
220
255
  const discovered = which === "review" ? [...reviewAdapters, ...seats] : seats;
221
256
  return [...discovered, ...current.filter((entry) => !discovered.includes(entry))];
222
257
  };
258
+ let pendingWrite = null;
223
259
  const reviewOverlay = (state) => {
224
260
  const staged = structuredClone(initial);
225
261
  staged.denyAdapters = state.denyAdapters;
@@ -237,42 +273,24 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
237
273
  // the diff and asks for confirmation, but owns neither filesystem access nor a writer.
238
274
  const before = currentRepoOverlayText(cwd);
239
275
  const path = repoOverlayPath(cwd);
240
- const existing = readOverlayFile(path);
241
276
  const modeChanged = state.selectedMode !== rm.mode.mode;
242
- const writeMode = modeChanged
243
- ? state.selectedMode
244
- : existing.routing?.mode;
245
- const merged = fleetEditableEquals(initial, staged)
246
- ? structuredClone(existing)
247
- : fleetRepoOverlayFromDelta(initial, staged, existing);
248
- let steeringChanged = false;
249
- for (const key of ["review", "consult"]) {
250
- if (JSON.stringify(state.steering[key]) === JSON.stringify(initialSteering[key]))
251
- continue;
252
- steeringChanged = true;
253
- const block = { ...merged[key] };
254
- if (state.steering[key])
255
- block.prefer = state.steering[key];
256
- else
257
- delete block.prefer;
258
- if (Object.keys(block).length)
259
- merged[key] = block;
260
- else
261
- delete merged[key];
262
- }
263
- const tierNotes = structuredClone(harvested.tiers);
264
- for (const [adapter, models] of Object.entries(provenanceMap(staged))) {
265
- for (const [model, note] of Object.entries(models)) {
266
- (tierNotes[adapter] ??= {})[model] = note;
267
- }
277
+ const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
278
+ if (!modeChanged && !steeringChanged && fleetEditableEquals(initial, staged)) {
279
+ pendingWrite = null;
280
+ return { kind: "empty" };
268
281
  }
269
- const after = withModeLine(repoOverlayYaml(merged, tierNotes, {
270
- adapters: harvested.denyAdapters,
271
- models: harvested.denyModels,
272
- }), writeMode);
273
- if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, staged)) || before === after) {
282
+ const write = {
283
+ initial,
284
+ edited: staged,
285
+ ...(modeChanged ? { mode: state.selectedMode } : {}),
286
+ steering: { initial: initialSteering, edited: state.steering },
287
+ };
288
+ const after = renderFleetOverlayWrite(before, write);
289
+ if (before === after) {
290
+ pendingWrite = null;
274
291
  return { kind: "empty" };
275
292
  }
293
+ pendingWrite = write;
276
294
  return {
277
295
  kind: "diff",
278
296
  before,
@@ -348,7 +366,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
348
366
  return "fleet: discarded overlay changes";
349
367
  // The command remains the single config actuator. Every interactive edit reaches this
350
368
  // one write only after the component-rendered diff confirm and the production reload guard.
351
- mkdirSync(dirname(result.review.path), { recursive: true });
352
- writeFileSync(result.review.path, result.review.after);
369
+ const write = pendingWrite;
370
+ if (!write)
371
+ throw new Error("fleet write reached confirmation without a staged overlay mutation");
372
+ writeFleetOverlay(result.review.path, (prior) => renderFleetOverlayWrite(prior, write));
353
373
  return `fleet: wrote ${result.review.path}`;
354
374
  }
@@ -109,6 +109,43 @@ A run is green only when the run-end event exists in the journal AND tip verify
109
109
  ### Verified handoffs
110
110
 
111
111
  When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the target pane afterward; never report "relayed" without read-back.
112
+
113
+ ### Orient before you act — this block may be the ONLY guidance your host loaded
114
+
115
+ These same bytes are written into EVERY repository guidance file this project has, because hosts disagree
116
+ about which one they read: some load only \`AGENTS.md\`, some also load a repo-level guidance file, some load
117
+ a user-level one instead. **Anything stated in only one file is invisible to some agent.** So do not assume
118
+ you were handed the whole picture — list the repository root, open every guidance file present, and then:
119
+
120
+ - **Read your host's PROJECT MEMORY before starting.** Hosts that keep one store it under a per-project
121
+ state directory keyed by the absolute working-directory path; find it and read its index plus every entry
122
+ whose name concerns METHOD or DISCIPLINE. It holds rules that cost real defects to learn. Entries may
123
+ predate a project rename, so **search by CONCEPT, not by the current product name.** A memory nobody opens
124
+ is worse than none: every seat assumes the lesson is recorded somewhere and no seat looks.
125
+ - **The gates are the product.** Seven, defined in \`src/graph/schema.ts\`:
126
+ \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order** —
127
+ the first five run as a battery that stops at its first red, then \`acceptance\` and \`review\` run
128
+ CONCURRENTLY (\`run-gates.ts:39\`, *"judge ‖ review"*). The first five are MANDATORY; only \`acceptance\` and
129
+ \`review\` may be omitted per task. Implementations are in \`src/gates/\` — \`baseline.ts\` (build/test/lint,
130
+ diffed against a recorded baseline so pre-existing failures are forgiven), \`evidence.ts\`, \`scope.ts\`,
131
+ \`acceptance.ts\` (its judge reads the DIFF and every criterion must cite a changed hunk), \`review.ts\`
132
+ (cross-vendor). \`run-gates.ts\` drives them. **A declared gate is not a passed gate, and a gate that
133
+ returned zero findings is not the same as a gate that ran.**
134
+ - **Spec-authoring law ships in the spec template** that \`tickmarkr init\` writes: the hard bounds and which
135
+ direction each moves, what makes a criterion real, and why an absence or a source-text grep is never a
136
+ criterion. Read it before authoring or repairing acceptance items.
137
+ - **Editing \`src/gates/\`, \`src/compile/\` or \`src/graph/\`?** Read \`docs/codebase/ARCHITECTURE.md\` first.
138
+ - **EVERY fix gets a ship/no-ship decision, recorded, at the moment it is made.** Ask one question of each
139
+ one: *does a user hit this defect?* If yes, the fix belongs in \`src/**\` or \`skills/**\` — the only trees
140
+ the package carries (\`files: [dist, schema, skills, fixtures]\`). A script, overlay, config entry or
141
+ operator-side workaround that resolves the symptom **locally is not the fix; it is a decision to leave
142
+ every other user broken**, and it must say so in writing and name the condition that removes it.
143
+ **The default answer is SHIP.** A local remedy is the exception and carries the burden of proof.
144
+ Watch for the three shapes this hides in: a fix applied where you happened to be standing rather than
145
+ where the defect lives; an observation filed with a product fix named in its own text and queued
146
+ nowhere; and a local tool that quietly grows into a product feature nobody shipped. **A defect and its
147
+ fix must be recorded in the same place, or the queue silently becomes a list of things everyone assumed
148
+ someone else had shipped.**
112
149
  ${DOCS_END}
113
150
  `;
114
151
  // Every applicable host location gets the skills, each paired with its own repository guidance
@@ -120,6 +157,54 @@ const hostTargets = (cwd) => {
120
157
  targets.push({ skillsDir: join(cwd, ".claude", "skills"), docPath: join(cwd, "CLAUDE.md") });
121
158
  return targets;
122
159
  };
160
+ const packagedSkillDir = (skill) => fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url));
161
+ // Every file the package ships for a skill, DERIVED from the tree rather than listed. OBS-373: a script
162
+ // was added to the shipped overseer skill long after repos had installed it, and because the SKILL.md
163
+ // that MANDATES that script was already on disk, every existence check reported "installed" while the
164
+ // thing the skill tells you to run was absent. A literal list here would be the same closed-set-restated
165
+ // defect one layer down, so this walks instead.
166
+ function skillFileList(root, prefix = "") {
167
+ return readdirSync(root, { withFileTypes: true })
168
+ .sort((a, b) => a.name.localeCompare(b.name))
169
+ .flatMap((entry) => {
170
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
171
+ return entry.isDirectory() ? skillFileList(join(root, entry.name), rel) : [rel];
172
+ });
173
+ }
174
+ // Drift is only computed for skills that ARE installed. A skill never installed is not drift — `--agent`
175
+ // installs it and says so, and nagging about it would bury the case this exists for.
176
+ function skillDrift(cwd) {
177
+ const drifted = [];
178
+ for (const { skillsDir } of hostTargets(cwd)) {
179
+ for (const skill of AGENT_SKILLS) {
180
+ const dir = join(skillsDir, skill);
181
+ if (!existsSync(join(dir, "SKILL.md")))
182
+ continue;
183
+ const src = packagedSkillDir(skill);
184
+ const missing = [];
185
+ const modified = [];
186
+ for (const rel of skillFileList(src)) {
187
+ // readFileSync resolves symlinks, so a symlinked mirror compares equal to a copied one.
188
+ if (!existsSync(join(dir, rel)))
189
+ missing.push(rel);
190
+ else if (!readFileSync(join(dir, rel)).equals(readFileSync(join(src, rel))))
191
+ modified.push(rel);
192
+ }
193
+ if (missing.length || modified.length)
194
+ drifted.push({ skill, dir, missing, modified });
195
+ }
196
+ }
197
+ return drifted;
198
+ }
199
+ // Name what drifted. "stale" alone sends the reader to diff three directories by hand, and the file that
200
+ // matters is usually the one they never knew shipped.
201
+ const describeDrift = (d) => [
202
+ d.missing.length ? `missing ${d.missing.join(", ")}` : "",
203
+ d.modified.length ? `modified ${d.modified.join(", ")}` : "",
204
+ ].filter(Boolean).join(", ");
205
+ // PRESENCE, deliberately — the wizard's question is "install these?", and a repo that already has them
206
+ // should not be asked again just because a copy drifted. Staleness is a different question with a
207
+ // different answer (`--agent --force`), and it is reported by name on every init path below.
123
208
  const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
124
209
  const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
125
210
  async function installAgentFiles(cwd, force, docs, notes) {
@@ -132,12 +217,20 @@ async function installAgentFiles(cwd, force, docs, notes) {
132
217
  return /^(?:y|yes)$/i.test((await prompt.question(`${question} [y/N] `)).trim());
133
218
  };
134
219
  try {
220
+ const drift = skillDrift(cwd);
135
221
  for (const { skillsDir, docPath } of hostTargets(cwd)) {
136
222
  for (const skill of AGENT_SKILLS) {
137
223
  const dest = join(skillsDir, skill, "SKILL.md");
138
224
  const exists = existsSync(dest);
139
- if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
140
- notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
225
+ const stale = drift.find((d) => d.dir === join(skillsDir, skill));
226
+ // An install that already matches the package byte for byte needs no prompt and no write. Before
227
+ // this, every re-init asked to overwrite a current skill and the answer meant nothing either way.
228
+ if (exists && !stale) {
229
+ notes.push(`kept current ${dest}`);
230
+ continue;
231
+ }
232
+ if (exists && !force && !(await confirm(`Overwrite ${dest} (${describeDrift(stale)})?`))) {
233
+ notes.push(`skipped existing ${dest} — ${describeDrift(stale)}; pass --force to overwrite it`);
141
234
  continue;
142
235
  }
143
236
  // whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
@@ -145,9 +238,21 @@ async function installAgentFiles(cwd, force, docs, notes) {
145
238
  notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
146
239
  }
147
240
  const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
148
- if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
149
- || current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
150
- notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
241
+ // The block is CANONICAL guidance ("stated once here, restated nowhere"), so a repo that has
242
+ // run init once must still be able to receive corrections to it. Before v1.86 this branch only
243
+ // ever said "kept", with no lever — not even --force, which IS honoured for skills above. So
244
+ // the block was write-once and every later improvement reached new repos only. Under --force,
245
+ // replace the marked region in place and leave every human-authored line outside it untouched.
246
+ const marked = new RegExp(`<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs begin -->[\\s\\S]*?`
247
+ + `<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs end -->`);
248
+ if (marked.test(current)) {
249
+ if (force) {
250
+ writeFileSync(docPath, current.replace(marked, AGENT_DOCS.trimEnd()));
251
+ notes.push(`refreshed tickmarkr agent docs in ${docPath}`);
252
+ }
253
+ else {
254
+ notes.push(`kept existing tickmarkr agent docs in ${docPath}; pass --force to refresh them`);
255
+ }
151
256
  }
152
257
  else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
153
258
  appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
@@ -249,6 +354,14 @@ export async function init(argv, cwd = process.cwd()) {
249
354
  notes.push(`wrote ${specPath}`);
250
355
  }
251
356
  }
357
+ // Say it on EVERY init, not only the --agent path. The upgrade that produced OBS-373 is silent by
358
+ // shape: the package moves, the installed copy does not, and nothing in the repo changes to hint at it.
359
+ // --agent reports drift per skill below, so this only speaks when that path will not.
360
+ if (!values.agent) {
361
+ for (const d of skillDrift(cwd)) {
362
+ notes.push(`stale ${join(d.dir, "SKILL.md")} — ${describeDrift(d)}; run tickmarkr init --agent --force to refresh it`);
363
+ }
364
+ }
252
365
  const fresh = values.fresh ?? false;
253
366
  const { reuse, ageMs, health } = initDoctorReuse(cwd, fresh);
254
367
  const doc = reuse && health && ageMs !== null
@@ -9,6 +9,7 @@ import { foldActivity } from "../../run/activity.js";
9
9
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
10
10
  import { normalizeStallSnapshot } from "../../run/stall.js";
11
11
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
12
+ import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
12
13
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
13
14
  const REFRESH_MS = 2000;
14
15
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
@@ -132,6 +133,20 @@ const optionValue = (argv, name) => {
132
133
  const value = index >= 0 ? argv[index + 1] : undefined;
133
134
  return value && !value.startsWith("-") ? value : undefined;
134
135
  };
136
+ // The one positional this command takes: an explicit run id. Everything else is a flag or a flag's
137
+ // value (`--webhook <url>`), so the first bare token that is not a --webhook value is the run id.
138
+ const positionalRunId = (argv) => {
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const arg = argv[i];
141
+ if (arg === "--webhook") {
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (!arg.startsWith("-"))
146
+ return arg;
147
+ }
148
+ return undefined;
149
+ };
135
150
  const defaultPostWebhook = (url, event) => fetch(url, {
136
151
  method: "POST",
137
152
  headers: { "content-type": "application/json" },
@@ -307,13 +322,18 @@ const failedSuffix = (states) => {
307
322
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
308
323
  // math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
309
324
  export const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
325
+ // Task titles are graph-authored data. Remove terminal controls and row-breaking whitespace before
326
+ // either width measurement or rendering so zero-cell ECMA-48 bytes cannot escape the task column.
327
+ const ECMA_48_CONTROL = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u009D[\s\S]*?(?:\u0007|\u009C)|\u001B[P^_X][\s\S]*?\u001B\\|[\u0090\u0098\u009E\u009F][\s\S]*?\u009C|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[@-_])/gu;
328
+ const sanitizeTaskText = (text) => text.replace(ECMA_48_CONTROL, "").replace(/\p{Cc}/gu, " ").replace(/\s+/gu, " ").trim();
310
329
  export const shortGoal = (goal, max) => {
311
- const clause = goal.split(/[,;.?!]/, 1)[0].trim();
312
- if (clause.length <= max)
330
+ const clause = sanitizeTaskText(goal).split(/[,;.?!]/, 1)[0].trim();
331
+ const cells = Math.max(0, Math.floor(max));
332
+ if (cellWidth(clause) <= cells)
313
333
  return clause;
314
- if (max <= 3)
315
- return clause.slice(0, Math.max(0, max));
316
- return `${clause.slice(0, max - 3).trimEnd()}...`;
334
+ if (cells <= 3)
335
+ return fitCells(clause, cells).trimEnd();
336
+ return `${fitCells(clause, cells - 3).trimEnd()}...`;
317
337
  };
318
338
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
319
339
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
@@ -487,9 +507,11 @@ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
487
507
  return [];
488
508
  }
489
509
  });
490
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false) => {
510
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
491
511
  const g = loadGraph(cwd);
492
- const runId = Journal.latestRunId(cwd, { withJournal: true });
512
+ // An explicit <runId> is a resolution, not a hint: Journal.open below refuses an id without a
513
+ // readable journal, so status fails loudly naming that id instead of rendering any other run.
514
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
493
515
  const assignments = new Map();
494
516
  let replayed = null;
495
517
  let cockpit;
@@ -619,7 +641,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
619
641
  const chain = gateChain(states, false);
620
642
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
621
643
  const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}`;
622
- return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
644
+ return `${prefix}${shortGoal(t.title, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
623
645
  });
624
646
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
625
647
  const header = runId
@@ -668,9 +690,9 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
668
690
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
669
691
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
670
692
  // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
671
- // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
672
- // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
673
- // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
693
+ // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
694
+ // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
695
+ // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
674
696
  const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
675
697
  const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
676
698
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
@@ -679,9 +701,9 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
679
701
  + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
680
702
  const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
681
703
  const avail = Math.max(8, width - (5 + idW) - 2 - stW);
682
- const goals = cells.map((c) => shortGoal(c.t.goal, avail));
683
- const goalW = Math.max(8, ...goals.map((s) => s.length));
684
- const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
704
+ const titles = cells.map((c) => shortGoal(c.t.title, avail));
705
+ const titleW = Math.max(8, ...titles.map(cellWidth));
706
+ const indent = " ".repeat(idW + 5); // line 2 starts under the title column
685
707
  const rows = cells.map((c, i) => {
686
708
  const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
687
709
  const word = statusWord(c);
@@ -695,7 +717,8 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
695
717
  (isStarved ? dot + fail("starved") : "") +
696
718
  (f.length ? dot + fail(f.join(", ")) : "") +
697
719
  (human ? dot + warn("awaiting approval") : "");
698
- const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
720
+ const taskTitle = titles[i];
721
+ const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
699
722
  const line1 = livePhase
700
723
  ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
701
724
  : ` ${statusRow(taskVerdict(c), taskLabel)}`;
@@ -718,9 +741,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
718
741
  };
719
742
  };
720
743
  export async function status(argv, cwd = process.cwd(), opts = {}) {
744
+ const namedRunId = positionalRunId(argv);
721
745
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
722
746
  if (!argv.includes("--watch")) {
723
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
747
+ const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
724
748
  return visual() ? BANNER + content : content;
725
749
  }
726
750
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
@@ -782,7 +806,9 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
782
806
  let decisionRunId;
783
807
  let journalCursor = 0;
784
808
  const consumeDecisionEvents = () => {
785
- const runId = Journal.latestRunId(cwd, { withJournal: true });
809
+ // A named run is followed, never re-resolved: --watch <runId> keeps reporting that run even as
810
+ // newer runs start. Only the no-argument form tracks latest, as before.
811
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
786
812
  if (!runId)
787
813
  return [];
788
814
  if (decisionRunId !== runId) {
@@ -839,7 +865,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
839
865
  }
840
866
  }
841
867
  else {
842
- frame = renderFrame(cwd, nowMs, i, workerLiveness, true);
868
+ frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
843
869
  if (tty) {
844
870
  updateTitle(frame.hotPhase, nowMs);
845
871
  process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
@@ -1,3 +1,4 @@
1
- import { type RunGraph } from "../graph/schema.js";
1
+ import { type AcceptanceItem, type RunGraph } from "../graph/schema.js";
2
2
  export declare function isGsdPhaseDir(dir: string): boolean;
3
+ export declare function parseTruths(file: string, raw: unknown): AcceptanceItem[] | undefined;
3
4
  export declare function compileGsd(src: string, root?: string): RunGraph;