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.
- package/README.md +4 -2
- package/dist/adapters/catalog-remote.d.ts +64 -0
- package/dist/adapters/catalog-remote.js +287 -0
- package/dist/adapters/catalog.d.ts +96 -0
- package/dist/adapters/catalog.js +176 -0
- package/dist/adapters/claude-code.js +5 -3
- package/dist/adapters/fake.js +33 -4
- package/dist/adapters/model-lints.d.ts +25 -5
- package/dist/adapters/model-lints.js +184 -50
- package/dist/adapters/model-windows.d.ts +31 -0
- package/dist/adapters/model-windows.js +69 -0
- package/dist/adapters/prompt.d.ts +5 -1
- package/dist/adapters/prompt.js +13 -4
- package/dist/adapters/registry.d.ts +25 -26
- package/dist/adapters/registry.js +173 -110
- package/dist/adapters/types.js +15 -3
- package/dist/brand.d.ts +5 -1
- package/dist/brand.js +18 -2
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +43 -21
- package/dist/cli/commands/fleet.d.ts +7 -0
- package/dist/cli/commands/fleet.js +94 -74
- package/dist/cli/commands/init.js +118 -5
- package/dist/cli/commands/status.js +44 -18
- package/dist/compile/gsd.d.ts +2 -1
- package/dist/compile/gsd.js +68 -2
- package/dist/compile/native.d.ts +14 -0
- package/dist/compile/native.js +161 -12
- package/dist/config/config.d.ts +20 -5
- package/dist/config/config.js +96 -64
- package/dist/config/fleet-overlay.d.ts +25 -20
- package/dist/config/fleet-overlay.js +195 -77
- package/dist/config/fleet-why.d.ts +23 -0
- package/dist/config/fleet-why.js +42 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +70 -19
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/llm.d.ts +0 -1
- package/dist/gates/llm.js +5 -30
- package/dist/gates/review.d.ts +2 -1
- package/dist/gates/review.js +9 -7
- package/dist/gates/verdict-cause.d.ts +4 -0
- package/dist/gates/verdict-cause.js +63 -0
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +8 -5
- package/dist/route/router.d.ts +0 -5
- package/dist/route/router.js +16 -20
- package/dist/run/consult.d.ts +6 -0
- package/dist/run/consult.js +35 -25
- package/dist/run/daemon.js +71 -15
- package/dist/run/journal.js +87 -7
- package/dist/tui/cockpit/capture.d.ts +12 -0
- package/dist/tui/cockpit/capture.js +37 -1
- package/dist/tui/cockpit/components.js +8 -8
- package/dist/tui/cockpit/theme.d.ts +32 -26
- package/dist/tui/cockpit/theme.js +11 -5
- package/dist/tui/ink/components.d.ts +0 -15
- package/dist/tui/ink/components.js +0 -17
- package/dist/tui/ink/fleet-app.d.ts +4 -1
- package/dist/tui/ink/fleet-app.js +134 -13
- package/fixtures/sample.native.md +1 -1
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +354 -34
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
- package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
- package/dist/tui/ink/studio-app.d.ts +0 -59
- package/dist/tui/ink/studio-app.js +0 -320
- package/dist/tui/save.d.ts +0 -38
- package/dist/tui/save.js +0 -96
- package/dist/tui/staging.d.ts +0 -29
- 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
|
|
4
|
+
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse } from "../../adapters/registry.js";
|
|
5
5
|
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
6
|
-
import { fleetEditableFromConfig, fleetEditableEquals,
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
199
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 (
|
|
315
|
-
return clause
|
|
316
|
-
return `${clause
|
|
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
|
-
|
|
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.
|
|
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,
|
|
672
|
-
// aligned under the
|
|
673
|
-
// and activity phrases live on line 2 only, so they can never squeeze the
|
|
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
|
|
683
|
-
const
|
|
684
|
-
const indent = " ".repeat(idW + 5); // line 2 starts under the
|
|
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
|
|
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
|
-
|
|
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`)}`);
|
package/dist/compile/gsd.d.ts
CHANGED
|
@@ -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;
|