tickmarkr 1.85.0 → 1.87.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 (78) 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 +108 -0
  5. package/dist/adapters/catalog.js +189 -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 +34 -27
  15. package/dist/adapters/registry.js +215 -112
  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/plan.js +11 -1
  25. package/dist/cli/commands/resume.js +7 -1
  26. package/dist/cli/commands/status.js +44 -18
  27. package/dist/compile/gsd.d.ts +2 -1
  28. package/dist/compile/gsd.js +68 -2
  29. package/dist/compile/native.d.ts +14 -0
  30. package/dist/compile/native.js +168 -12
  31. package/dist/config/config.d.ts +20 -5
  32. package/dist/config/config.js +96 -64
  33. package/dist/config/fleet-overlay.d.ts +25 -20
  34. package/dist/config/fleet-overlay.js +195 -77
  35. package/dist/config/fleet-why.d.ts +23 -0
  36. package/dist/config/fleet-why.js +42 -0
  37. package/dist/drivers/herdr.d.ts +1 -0
  38. package/dist/drivers/herdr.js +70 -19
  39. package/dist/gates/acceptance.js +7 -2
  40. package/dist/gates/llm.d.ts +0 -1
  41. package/dist/gates/llm.js +5 -30
  42. package/dist/gates/review.d.ts +2 -1
  43. package/dist/gates/review.js +9 -7
  44. package/dist/gates/run-gates.d.ts +1 -0
  45. package/dist/gates/run-gates.js +21 -2
  46. package/dist/gates/verdict-cause.d.ts +4 -0
  47. package/dist/gates/verdict-cause.js +63 -0
  48. package/dist/graph/schema.d.ts +6 -0
  49. package/dist/graph/schema.js +8 -5
  50. package/dist/route/preference.d.ts +1 -1
  51. package/dist/route/preference.js +8 -1
  52. package/dist/route/router.d.ts +0 -5
  53. package/dist/route/router.js +16 -20
  54. package/dist/run/consult.d.ts +6 -0
  55. package/dist/run/consult.js +49 -26
  56. package/dist/run/daemon.js +81 -20
  57. package/dist/run/journal.js +87 -7
  58. package/dist/tui/cockpit/capture.d.ts +12 -0
  59. package/dist/tui/cockpit/capture.js +37 -1
  60. package/dist/tui/cockpit/components.js +8 -8
  61. package/dist/tui/cockpit/theme.d.ts +32 -26
  62. package/dist/tui/cockpit/theme.js +11 -5
  63. package/dist/tui/ink/components.d.ts +0 -15
  64. package/dist/tui/ink/components.js +0 -17
  65. package/dist/tui/ink/fleet-app.d.ts +4 -1
  66. package/dist/tui/ink/fleet-app.js +134 -13
  67. package/fixtures/gateway-models.json +1 -0
  68. package/fixtures/sample.native.md +1 -1
  69. package/package.json +1 -1
  70. package/skills/tickmarkr-overseer/SKILL.md +464 -34
  71. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +86 -0
  72. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  73. package/dist/tui/ink/studio-app.d.ts +0 -59
  74. package/dist/tui/ink/studio-app.js +0 -320
  75. package/dist/tui/save.d.ts +0 -38
  76. package/dist/tui/save.js +0 -96
  77. package/dist/tui/staging.d.ts +0 -29
  78. package/dist/tui/staging.js +0 -78
@@ -1,19 +1,29 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
3
+ import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
4
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
5
5
  import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
6
6
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
7
- import { declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
8
- import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
7
+ import { catalogModelAdvisory, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
8
+ import { loadConfig, overlayPreferShapes } from "../../config/config.js";
9
9
  import { HerdrDriver } from "../../drivers/herdr.js";
10
10
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
11
11
  import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
12
+ import { readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
12
13
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
13
14
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
14
15
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
15
16
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
17
+ if (_argv.length === 1 && _argv[0] === "--refresh-catalog") {
18
+ const refreshed = await refreshCatalogCommand({ repoRoot: cwd, now: opts.catalogNow });
19
+ return refreshed.updated
20
+ ? `tickmarkr doctor --refresh-catalog: model catalog refreshed${refreshed.warning ? `; ${refreshed.warning}` : ""}`
21
+ : `tickmarkr doctor --refresh-catalog: catalog refresh unavailable — ${refreshed.warning ?? "unknown failure"}; retained ${refreshed.catalog.source} catalog`;
22
+ }
16
23
  const cfg = loadConfig(cwd);
24
+ // T17: synchronous/cache-only by operator ruling. The only network-bearing catalog function is the
25
+ // separately invoked refreshCatalogCommand; ordinary doctor never calls it.
26
+ const catalog = opts.catalog ?? readCachedCatalog(cwd, { now: opts.catalogNow });
17
27
  // banner at START — the logo greets the operator before the ~60s probe wait, never trailing it (operator report 2026-07-17)
18
28
  if (opts.banner !== false && visual())
19
29
  process.stdout.write(BANNER);
@@ -72,8 +82,12 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
72
82
  const healthy = h.installed && (a.id !== kimi.id || h.authed);
73
83
  return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
74
84
  });
75
- // v1.48 T1: advisory sweep for known agent CLIs with no adapter — never written to doctor.json health.
76
- rows.push(...detectCandidateClis().map(({ binary, version }) => alignedStatusRow("warn", binary, `detected: ${version ?? "version unknown"} (no tickmarkr adapter — not routable)`)));
85
+ if (catalog.warning) {
86
+ rows.push(attentionRow(`model catalog cache unreadable — ${catalog.warning}; using vendored fallback (advisory — routing unchanged)`));
87
+ }
88
+ // v1.48 T1 / v1.86 T12: advisory sweep for known agent CLIs with no drive contract. Presence is
89
+ // resolved through the worker's login shell; advisory targets are never executed or written to health.
90
+ rows.push(...detectCandidateClis({ cwd }).map(({ binary, path }) => alignedStatusRow("warn", binary, `detected at ${path} (no drive contract — not routable)`)));
77
91
  const herdr = HerdrDriver.available();
78
92
  rows.push(alignedStatusRow(herdr ? "pass" : "fail", "herdr", herdr ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"));
79
93
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
@@ -143,10 +157,23 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
143
157
  }
144
158
  }
145
159
  }
160
+ const resolvedCatalogModel = (adapter, model) => {
161
+ if (adapter !== "claude-code" || !identityResolver || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS))
162
+ return undefined;
163
+ try {
164
+ return identityResolver(cwd, model);
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ };
146
170
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
147
171
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
148
172
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
149
- const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
173
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd), {
174
+ catalog,
175
+ resolvedModel: resolvedCatalogModel,
176
+ });
150
177
  let drift = "";
151
178
  if (frag) {
152
179
  if (visual()) {
@@ -191,24 +218,19 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
191
218
  : "";
192
219
  rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} ${dim("denied=")}${denied} ${dim("prefer=")}${pref}`);
193
220
  }
194
- if (unclassified.length)
221
+ if (unclassified.length) {
222
+ // Keep the historical count as an index, but it is no longer the whole report: every model gets
223
+ // a cache-backed evidence or explicit uncovered line. Neither branch changes cfg, health, or route().
195
224
  rows.push(` ${dim(`(${unclassified.length} more listed, unclassified)`)}`);
225
+ for (const model of unclassified) {
226
+ const advisory = catalogModelAdvisory(cfg, catalog, a.id, model, resolvedCatalogModel(a.id, model));
227
+ rows.push(` ${dim(`catalog · ${advisory.display}`)}`);
228
+ }
229
+ }
196
230
  return rows;
197
231
  });
198
- const autoPrefer = readAutoPrefer(cwd);
199
- const preferStatus = autoPrefer
200
- ? Object.keys(cfg.routing.map).flatMap((shape) => {
201
- const auto = autoPrefer[shape];
202
- if (!Array.isArray(auto))
203
- return [];
204
- const seed = DEFAULT_CONFIG.routing.map[shape]?.prefer ?? [];
205
- if (!auto.length && !seed.length)
206
- return []; // nothing derived, nothing seeded — an empty line is noise
207
- return [` ${dim(`prefer ${shape} (auto):`)} ${auto.join(" > ")} ${dim("— seed was")} [${seed.join(", ")}]`];
208
- })
209
- : [];
210
- const modelSummary = modelStatus.length || preferStatus.length
211
- ? `\n${legend("model status:")}\n${[...modelStatus, ...preferStatus].join("\n")}`
232
+ const modelSummary = modelStatus.length
233
+ ? `\n${legend("model status:")}\n${modelStatus.join("\n")}`
212
234
  : "";
213
235
  const header = visual()
214
236
  ? `${statusRow("pass", `${title("tickmarkr doctor")} ${legend("· capability matrix")}`)}\n${rule()}`
@@ -15,6 +15,13 @@ export type FleetIO = {
15
15
  debug?: boolean;
16
16
  reloadGuard?: (bytes: string) => string | null;
17
17
  };
18
+ export type FleetWriteHooks = {
19
+ readPrior?: (path: string) => string;
20
+ beforeRename?: () => void;
21
+ };
22
+ /** Atomic sibling-temp writer. Reading and serializing happen before `${path}.tmp` exists, and
23
+ * any pre-rename interruption unlinks only that exact candidate while the original remains intact. */
24
+ export declare function writeFleetOverlay(path: string, serialize: (priorBytes: string) => string, hooks?: FleetWriteHooks): void;
18
25
  export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[], io?: FleetIO): Promise<string | {
19
26
  out: string;
20
27
  code: number;
@@ -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
@@ -127,7 +127,17 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
127
127
  lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
128
128
  // v1.54 T3: dead-steering sweep — advisory only, renders with the routing lints, never alters routing.
129
129
  lints.push(...preferEntryLints(cfg, health, overlayPreferShapes(cwd)));
130
- if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
130
+ // The guard here was `channels.length && !fleetCanCrossVendorReview(channels)`, so the review lint went
131
+ // SILENT at zero channels — the first-run state, where review.required is set and nothing can route at all.
132
+ // Deleting that clause outright is worse than the silence: it fires "no cross-vendor reviewer pair in fleet"
133
+ // when there IS no fleet. An empty fleet and a single-vendor fleet are different faults with different
134
+ // repairs — install or auth ANY adapter vs. add a second VENDOR — so they are two lints, not one clause.
135
+ // The pair text stays byte-identical: tests/fixtures/brand-surfaces/plan-lints.txt pins it and this task
136
+ // does not own that fixture, so the pair lint keeps naming the waiver as the repair it can offer inline.
137
+ if (cfg.review.required && channels.length === 0) {
138
+ lints.push("review: review.required is set but no channel can route — the fleet is empty; install or authenticate an adapter");
139
+ }
140
+ else if (cfg.review.required && !fleetCanCrossVendorReview(channels)) {
131
141
  lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
132
142
  }
133
143
  let cost = 0;
@@ -1,5 +1,6 @@
1
1
  import { loadConfig } from "../../config/config.js";
2
2
  import { pickDriver } from "../../drivers/index.js";
3
+ import { loadGraph } from "../../graph/graph.js";
3
4
  import { formatSummary, runDaemon } from "../../run/daemon.js";
4
5
  import { formatJournalNarration } from "../../run/journal.js";
5
6
  import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
@@ -15,7 +16,12 @@ export async function resume(argv, cwd = process.cwd()) {
15
16
  const graphChanged = argv.includes("--graph-changed");
16
17
  const retryFailed = argv.includes("--retry-failed");
17
18
  const cfg = loadConfig(cwd);
18
- const collisions = denyPreferCollisions(cfg);
19
+ // v1.87 T3 (OBS-162, twice-carried workaround): the preflight runs AFTER the graph is read and
20
+ // sees only the shapes the resumed graph carries. A deny∩prefer collision on a shape no resumed
21
+ // task uses is a config fact the run would never resolve — it must not refuse the only
22
+ // crash-recovery path. doctor still walks the whole map.
23
+ const graph = loadGraph(cwd);
24
+ const collisions = denyPreferCollisions(cfg, graph.tasks.map((t) => t.shape));
19
25
  if (collisions.length) {
20
26
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
21
27
  }