tickmarkr 1.49.0 → 1.51.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/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +9 -0
- package/dist/brand.d.ts +62 -0
- package/dist/brand.js +74 -2
- package/dist/cli/commands/doctor.js +33 -46
- package/dist/cli/commands/fleet.js +89 -30
- package/dist/cli/commands/init.js +22 -2
- package/dist/cli/commands/plan.d.ts +1 -1
- package/dist/cli/commands/plan.js +67 -7
- package/dist/cli/commands/report.js +13 -1
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +48 -13
- package/dist/cli/commands/status.js +38 -41
- package/dist/compile/native.js +18 -2
- package/dist/config/config.d.ts +28 -0
- package/dist/config/config.js +103 -8
- package/dist/drivers/herdr.js +6 -2
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +6 -0
- package/dist/route/profile.d.ts +12 -0
- package/dist/route/profile.js +31 -0
- package/dist/run/daemon.d.ts +16 -0
- package/dist/run/daemon.js +61 -13
- package/package.json +1 -1
package/dist/config/config.js
CHANGED
|
@@ -10,6 +10,16 @@ const TierEnum = z.enum(TIERS, {
|
|
|
10
10
|
});
|
|
11
11
|
export const TIER_RANK = { cheap: 0, mid: 1, frontier: 2 };
|
|
12
12
|
export const DEFAULT_DIFF_CAP = 60_000;
|
|
13
|
+
// v1.51 T1: routing.mode — a preset COMPILED INTO FLOORS at config load. The router never sees the
|
|
14
|
+
// mode; it receives resolved floors only (the structural defense against the quality-silently-loses
|
|
15
|
+
// class — no fourth runtime authority, no new precedence key).
|
|
16
|
+
export const ROUTING_MODES = ["partner-led", "risk-based", "staff-led"];
|
|
17
|
+
const ModeEnum = z.enum(ROUTING_MODES, {
|
|
18
|
+
error: (iss) => `Invalid option: expected one of "partner-led"|"risk-based"|"staff-led" (got ${JSON.stringify(iss.input)})`,
|
|
19
|
+
});
|
|
20
|
+
// Integrity set (weak-oracle shapes, OBS-68..70 class): no mode may resolve these below frontier.
|
|
21
|
+
// Only an explicit operator floor line can — and it draws a standing plan lint every load.
|
|
22
|
+
export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
|
|
13
23
|
export const MapEntrySchema = z.object({
|
|
14
24
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
15
25
|
tier: TierEnum.optional(),
|
|
@@ -80,6 +90,8 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
80
90
|
contextWarnTokens: z.number().int().positive(),
|
|
81
91
|
setup: z.string().optional(),
|
|
82
92
|
routing: z.object({
|
|
93
|
+
// v1.51 T1: preset expanded into floors by loadConfig — absent ⇒ risk-based ⇒ byte-identical routing.
|
|
94
|
+
mode: ModeEnum.optional(),
|
|
83
95
|
map: z.record(z.string(), MapEntrySchema),
|
|
84
96
|
floors: z.record(z.string(), TierEnum),
|
|
85
97
|
learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
|
|
@@ -155,12 +167,16 @@ export const DEFAULT_CONFIG = {
|
|
|
155
167
|
taskTimeoutMinutes: 30,
|
|
156
168
|
contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
|
|
157
169
|
routing: {
|
|
170
|
+
// v1.51 T3 (round-2 consult): map entries carry preferences/pins only — band policy lives in
|
|
171
|
+
// routing.floors, the single tier authority. The dropped tiers (implement mid, tests cheap,
|
|
172
|
+
// docs cheap) were byte-equal to the floors below, so routing is unchanged. Overlay map tiers
|
|
173
|
+
// still parse but draw a move-to-floors lint in plan/doctor; MapEntrySchema.tier removal itself
|
|
174
|
+
// is deferred to the next version (tombstone grammar: tier: null).
|
|
158
175
|
map: {
|
|
159
176
|
plan: { pin: { via: "claude-code", model: "fable" } },
|
|
160
177
|
spec: { pin: { via: "claude-code", model: "fable" } },
|
|
161
|
-
implement: {
|
|
162
|
-
tests: {
|
|
163
|
-
docs: { tier: "cheap" },
|
|
178
|
+
implement: { prefer: ["cursor-agent", "codex"] },
|
|
179
|
+
tests: { prefer: ["opencode"] },
|
|
164
180
|
},
|
|
165
181
|
floors: {
|
|
166
182
|
plan: "frontier", spec: "frontier", migration: "frontier",
|
|
@@ -304,14 +320,88 @@ export function overlayPreferShapes(repoRoot, opts = {}) {
|
|
|
304
320
|
}
|
|
305
321
|
return shapes;
|
|
306
322
|
}
|
|
307
|
-
|
|
323
|
+
const lowerTier = (t) => (t === "frontier" ? "mid" : "cheap");
|
|
324
|
+
const maxTier = (a, b) => (TIER_RANK[a] >= TIER_RANK[b] ? a : b);
|
|
325
|
+
const integrityMin = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape) ? "frontier" : "cheap";
|
|
326
|
+
// partner-led = frontier everywhere; staff-led = one band down, integrity-clamped (net effect on the
|
|
327
|
+
// defaults: implement/refactor → cheap, ui → frontier); risk-based = identity, so an absent mode key
|
|
328
|
+
// resolves byte-identically to pre-v1.51 routing.
|
|
329
|
+
function presetFloor(mode, shape, dflt) {
|
|
330
|
+
if (mode === "partner-led")
|
|
331
|
+
return "frontier";
|
|
332
|
+
if (mode === "staff-led")
|
|
333
|
+
return maxTier(lowerTier(dflt), integrityMin(shape));
|
|
334
|
+
return dflt;
|
|
335
|
+
}
|
|
336
|
+
/** Which floor shapes the operator wrote in an overlay layer (explicit beats mode; null tombstones stay removed). */
|
|
337
|
+
function overlayFloorEdits(layers) {
|
|
338
|
+
const explicit = new Set();
|
|
339
|
+
const tombstoned = new Set();
|
|
340
|
+
for (const layer of layers) {
|
|
341
|
+
const floors = layer?.routing?.floors;
|
|
342
|
+
if (!floors || typeof floors !== "object" || Array.isArray(floors))
|
|
343
|
+
continue;
|
|
344
|
+
for (const [shape, v] of Object.entries(floors)) {
|
|
345
|
+
if (v === null) {
|
|
346
|
+
explicit.delete(shape);
|
|
347
|
+
tombstoned.add(shape);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
tombstoned.delete(shape);
|
|
351
|
+
explicit.add(shape);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { explicit, tombstoned };
|
|
356
|
+
}
|
|
357
|
+
// Mutates cfg.routing.floors (and explore under partner-led) to the mode-resolved values.
|
|
358
|
+
function resolveRoutingMode(cfg, layers) {
|
|
359
|
+
const mode = cfg.routing.mode ?? "risk-based";
|
|
360
|
+
const { explicit, tombstoned } = overlayFloorEdits(layers);
|
|
361
|
+
const provenance = {};
|
|
362
|
+
const lints = [];
|
|
363
|
+
for (const shape of SHAPES) {
|
|
364
|
+
if (tombstoned.has(shape))
|
|
365
|
+
continue; // operator removed the floor — a mode never resurrects it
|
|
366
|
+
const dflt = DEFAULT_CONFIG.routing.floors[shape];
|
|
367
|
+
const moded = presetFloor(mode, shape, dflt);
|
|
368
|
+
if (explicit.has(shape)) {
|
|
369
|
+
const val = cfg.routing.floors[shape];
|
|
370
|
+
provenance[shape] = "config floors";
|
|
371
|
+
if (moded !== val && moded !== dflt) {
|
|
372
|
+
lints.push(`floors.${shape}: ${val} (config floors) overrides mode ${mode} — shadowed delta: ${shape} ${dflt}→${moded}`);
|
|
373
|
+
}
|
|
374
|
+
if (TIER_RANK[val] < TIER_RANK[integrityMin(shape)]) {
|
|
375
|
+
lints.push(`floors.${shape}: ${val} is below integrity minimum frontier — integrity class ${INTEGRITY_FLOOR_SHAPES.join("/")} holds regardless of mode`);
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
cfg.routing.floors[shape] = moded;
|
|
380
|
+
provenance[shape] = `mode ${mode}`;
|
|
381
|
+
}
|
|
382
|
+
for (const shape of Object.keys(cfg.routing.floors)) {
|
|
383
|
+
if (!(shape in provenance))
|
|
384
|
+
provenance[shape] = "config floors"; // overlay floors on non-shape keys
|
|
385
|
+
}
|
|
386
|
+
// partner-led resolves exploration off (probes are the wrong spend under a premium declaration);
|
|
387
|
+
// staff-led and risk-based leave explore untouched — the round-2 fence rides through as configured.
|
|
388
|
+
if (mode === "partner-led")
|
|
389
|
+
cfg.routing.explore = { ...cfg.routing.explore, mode: "off" };
|
|
390
|
+
return { mode, provenance, lints };
|
|
391
|
+
}
|
|
392
|
+
/** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
|
|
393
|
+
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
394
|
+
export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
308
395
|
const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
|
|
309
396
|
const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
|
|
310
397
|
const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
|
|
311
398
|
const r = TickmarkrConfigSchema.safeParse(merged);
|
|
312
399
|
if (!r.success)
|
|
313
400
|
throw new ConfigError(z.prettifyError(r.error));
|
|
314
|
-
return r.data;
|
|
401
|
+
return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
|
|
402
|
+
}
|
|
403
|
+
export function loadConfig(repoRoot, opts = {}) {
|
|
404
|
+
return loadConfigWithMode(repoRoot, opts).cfg;
|
|
315
405
|
}
|
|
316
406
|
export function configTemplate(overlay) {
|
|
317
407
|
const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
@@ -321,10 +411,15 @@ export function configTemplate(overlay) {
|
|
|
321
411
|
# contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
|
|
322
412
|
# setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
|
|
323
413
|
# routing:
|
|
324
|
-
#
|
|
325
|
-
#
|
|
414
|
+
# mode: risk-based # v1.51: partner-led | risk-based | staff-led — a preset compiled into floors at
|
|
415
|
+
# # load (partner-led: every shape frontier + explore off; staff-led: implement/refactor
|
|
416
|
+
# # cheap; integrity set plan/spec/migration/ui never resolves below frontier under any
|
|
417
|
+
# # mode). Absent = risk-based = byte-identical routing. Explicit floors below beat mode deltas (linted).
|
|
418
|
+
# map: # per-shape preferences/pins; band policy lives in routing.floors
|
|
419
|
+
# implement: { prefer: [cursor-agent, codex] }
|
|
326
420
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
327
|
-
#
|
|
421
|
+
# tests: { tier: null } # tombstone: null removes a legacy map tier (deprecated — move the band to routing.floors.tests; schema removal lands next version)
|
|
422
|
+
# floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
|
|
328
423
|
# migration: frontier
|
|
329
424
|
# learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
|
|
330
425
|
# learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { shq } from "../adapters/types.js";
|
|
2
|
+
import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
|
|
2
3
|
import { createWorktree, sh } from "../run/git.js";
|
|
3
4
|
import { herdrSealShellPrefix } from "./subprocess.js";
|
|
4
5
|
import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
|
|
@@ -115,6 +116,8 @@ export class HerdrDriver {
|
|
|
115
116
|
const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
|
|
116
117
|
if (typeof tabId !== "string" || !tabId)
|
|
117
118
|
throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
|
|
119
|
+
// T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
|
|
120
|
+
const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
|
|
118
121
|
let r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`);
|
|
119
122
|
if (r.code !== 0 && /agent_name_taken/.test(r.stderr + r.stdout)) {
|
|
120
123
|
// DEFECT-01: a prior (killed) process's kept pane still holds this durable name — reclaim it.
|
|
@@ -149,7 +152,7 @@ export class HerdrDriver {
|
|
|
149
152
|
// worker GETS, not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
|
|
150
153
|
// v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
|
|
151
154
|
// the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
|
|
152
|
-
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
|
|
155
|
+
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
|
|
153
156
|
if (seed.code !== 0)
|
|
154
157
|
throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
|
|
155
158
|
return { id, name, cwd, tabId };
|
|
@@ -260,7 +263,8 @@ export class HerdrDriver {
|
|
|
260
263
|
// like a failed cd (return null → caller falls back to a per-slot tab). this.ws is guaranteed set —
|
|
261
264
|
// joinGroup runs only after the first member's tabSlot succeeded, which requires it (VIS-10).
|
|
262
265
|
// v1.22 T3: same env seal as tabSlot — strip control-plane vars after the workspace seed.
|
|
263
|
-
|
|
266
|
+
// T5: same brand identity seed as tabSlot — every group member's banner announces its own name.
|
|
267
|
+
const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
|
|
264
268
|
if (seed.code !== 0) {
|
|
265
269
|
await this.herdr(`pane close ${shq(pane)}`);
|
|
266
270
|
return null;
|
package/dist/graph/schema.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const SHAPES: readonly ["plan", "spec", "implement", "tests", "docs", "migration", "ui", "refactor", "chore"];
|
|
3
|
+
export declare const GRAPH_ROUTING_MODES: readonly ["partner-led", "risk-based", "staff-led"];
|
|
3
4
|
export declare const STATUSES: readonly ["pending", "running", "gated", "failed", "done", "human"];
|
|
4
5
|
export declare const GATE_NAMES: readonly ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
|
|
5
6
|
export declare const TIERS: readonly ["cheap", "mid", "frontier"];
|
|
@@ -94,6 +95,11 @@ export declare const TaskSchema: z.ZodObject<{
|
|
|
94
95
|
}, z.core.$strip>;
|
|
95
96
|
export declare const RunGraphSchema: z.ZodObject<{
|
|
96
97
|
version: z.ZodLiteral<1>;
|
|
98
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
99
|
+
"partner-led": "partner-led";
|
|
100
|
+
"risk-based": "risk-based";
|
|
101
|
+
"staff-led": "staff-led";
|
|
102
|
+
}>>;
|
|
97
103
|
spec: z.ZodObject<{
|
|
98
104
|
source: z.ZodEnum<{
|
|
99
105
|
speckit: "speckit";
|
package/dist/graph/schema.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const SHAPES = ["plan", "spec", "implement", "tests", "docs", "migration", "ui", "refactor", "chore"];
|
|
3
|
+
// v1.51 T2: spec-declared routing mode. Mirrors config ROUTING_MODES literally — config.ts imports this
|
|
4
|
+
// module, so a runtime import back would be circular; parity is pinned by test (tests/cli/mode-sources).
|
|
5
|
+
export const GRAPH_ROUTING_MODES = ["partner-led", "risk-based", "staff-led"];
|
|
3
6
|
export const STATUSES = ["pending", "running", "gated", "failed", "done", "human"];
|
|
4
7
|
export const GATE_NAMES = ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
|
|
5
8
|
const MANDATORY_GATES = ["build", "test", "lint", "evidence", "scope"];
|
|
@@ -88,6 +91,9 @@ export const TaskSchema = z.object({
|
|
|
88
91
|
export const RunGraphSchema = z
|
|
89
92
|
.object({
|
|
90
93
|
version: z.literal(1),
|
|
94
|
+
// v1.51 T2: spec front-matter mode — the spec author's routing-mode declaration. Source
|
|
95
|
+
// precedence: run flag > this > repo config > global config > default (risk-based).
|
|
96
|
+
mode: z.enum(GRAPH_ROUTING_MODES).optional(),
|
|
91
97
|
spec: z.object({
|
|
92
98
|
source: z.enum(["speckit", "gsd", "prd", "native", "taskmaster"]),
|
|
93
99
|
paths: z.array(z.string()),
|
package/dist/route/profile.d.ts
CHANGED
|
@@ -83,6 +83,18 @@ export declare function learnedScoreTerms(profile: RoutingProfile | undefined, s
|
|
|
83
83
|
availWeight?: number;
|
|
84
84
|
slaMinutes?: number;
|
|
85
85
|
}): LearnedScoreTerms;
|
|
86
|
+
export declare const STAFF_LED_MARGIN = 0.1;
|
|
87
|
+
export interface StaffLedEvidence {
|
|
88
|
+
cheapBest: number;
|
|
89
|
+
midBest: number;
|
|
90
|
+
n: number;
|
|
91
|
+
}
|
|
92
|
+
export declare function staffLedEvidence(profile: RoutingProfile | undefined, shape: string, channels: readonly {
|
|
93
|
+
adapter: string;
|
|
94
|
+
model: string;
|
|
95
|
+
channel: string;
|
|
96
|
+
tier: string;
|
|
97
|
+
}[]): StaffLedEvidence | null;
|
|
86
98
|
export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
|
|
87
99
|
availWeight?: number;
|
|
88
100
|
slaMinutes?: number;
|
package/dist/route/profile.js
CHANGED
|
@@ -217,6 +217,37 @@ export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
|
|
|
217
217
|
const overrun = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
|
|
218
218
|
return { quality, perf, avail, overrun };
|
|
219
219
|
}
|
|
220
|
+
// v1.51 T5 staff-led economics guard (round-3 Fable §3.3): ONE comparison over existing warm cells,
|
|
221
|
+
// consumed by plan as an ADVISORY lint only. Evidence display, never a floor input — no routing path
|
|
222
|
+
// reads this (anticipatory raises are refused house law: bands move on declarations, structural
|
|
223
|
+
// signals, or the failure ladder — never on the profile's prediction).
|
|
224
|
+
export const STAFF_LED_MARGIN = 0.1; // "materially below" = two quality quanta (2 × the 0.05 term weights)
|
|
225
|
+
// Best WARM learned score per band (cold cells — n < MIN_SAMPLES — are not evidence; the warm best is
|
|
226
|
+
// the band's incumbent proxy). Null unless BOTH bands are warm AND the mid incumbent leads by at least
|
|
227
|
+
// STAFF_LED_MARGIN: silence is the default, so a cold profile can never fire the lint.
|
|
228
|
+
export function staffLedEvidence(profile, shape, channels) {
|
|
229
|
+
if (!profile)
|
|
230
|
+
return null;
|
|
231
|
+
const best = (tier) => {
|
|
232
|
+
let top = null;
|
|
233
|
+
for (const c of channels) {
|
|
234
|
+
if (c.tier !== tier)
|
|
235
|
+
continue;
|
|
236
|
+
const cell = cellOf(profile, shape, channelKey(c), c.channel);
|
|
237
|
+
if (!cell || cell.n < MIN_SAMPLES)
|
|
238
|
+
continue;
|
|
239
|
+
const score = learnedScore(profile, shape, channelKey(c), c.channel);
|
|
240
|
+
if (!top || score > top.score)
|
|
241
|
+
top = { score, nRaw: cell.nRaw ?? 0 };
|
|
242
|
+
}
|
|
243
|
+
return top;
|
|
244
|
+
};
|
|
245
|
+
const cheap = best("cheap");
|
|
246
|
+
const mid = best("mid");
|
|
247
|
+
if (!cheap || !mid || mid.score - cheap.score < STAFF_LED_MARGIN)
|
|
248
|
+
return null;
|
|
249
|
+
return { cheapBest: cheap.score, midBest: mid.score, n: cheap.nRaw + mid.nRaw };
|
|
250
|
+
}
|
|
220
251
|
// Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
|
|
221
252
|
// compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
|
|
222
253
|
// Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
|
|
2
3
|
import { type ExecutorDriver } from "../drivers/types.js";
|
|
3
4
|
import { type JournalEvent } from "./journal.js";
|
|
4
5
|
export interface RunOptions {
|
|
@@ -9,8 +10,23 @@ export interface RunOptions {
|
|
|
9
10
|
driver?: ExecutorDriver;
|
|
10
11
|
adapters?: WorkerAdapter[];
|
|
11
12
|
globalDir?: string;
|
|
13
|
+
mode?: RoutingMode;
|
|
12
14
|
narrate?: (event: JournalEvent) => void;
|
|
13
15
|
}
|
|
16
|
+
export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
|
|
17
|
+
export interface ResolvedRunMode {
|
|
18
|
+
cfg: TickmarkrConfig;
|
|
19
|
+
/** effective mode + per-floor provenance + standing lints, from the ONE preset compiler in config.ts */
|
|
20
|
+
mode: ModeResolution;
|
|
21
|
+
source: ModeSource;
|
|
22
|
+
/** set when the run flag picked a mode below the spec-declared mode (loud warn; --route-strict refuses) */
|
|
23
|
+
conflict?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveRunMode(repoRoot: string, opts?: {
|
|
26
|
+
flag?: RoutingMode;
|
|
27
|
+
spec?: RoutingMode;
|
|
28
|
+
globalDir?: string;
|
|
29
|
+
}): ResolvedRunMode;
|
|
14
30
|
export interface RunSummary {
|
|
15
31
|
runId: string;
|
|
16
32
|
branch: string;
|
package/dist/run/daemon.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { shq } from "../adapters/types.js";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
6
|
+
import { stringify } from "yaml";
|
|
5
7
|
import { trailerPattern, writePrompt } from "../adapters/prompt.js";
|
|
6
8
|
import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
|
|
7
9
|
import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
|
|
8
|
-
import {
|
|
10
|
+
import { bannerShell } from "../brand.js";
|
|
11
|
+
import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
|
|
9
12
|
import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
|
|
10
13
|
import { formatOwnedName } from "../drivers/types.js";
|
|
11
14
|
import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
|
|
@@ -16,8 +19,41 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
|
|
|
16
19
|
import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
|
|
17
20
|
import { acquireRunLock, releaseRunLock } from "./lock.js";
|
|
18
21
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
19
|
-
import { nextChannel, route } from "../route/router.js";
|
|
22
|
+
import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
|
|
20
23
|
import { desiredPanes } from "./reconcile.js";
|
|
24
|
+
const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
|
|
25
|
+
// An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
|
|
26
|
+
// carrying routing.mode — floors, explore, lints, and provenance all come from config.ts's preset
|
|
27
|
+
// compiler, never duplicated mode math here (the quality-silently-loses defense holds by construction).
|
|
28
|
+
function withOverlayMode(repoRoot, mode, globalDir) {
|
|
29
|
+
const overlay = readOverlayFile(repoOverlayPath(repoRoot));
|
|
30
|
+
const tmp = mkdtempSync(join(tmpdir(), "tickmarkr-mode-"));
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(join(tmp, ".tickmarkr"), { recursive: true });
|
|
33
|
+
writeFileSync(join(tmp, ".tickmarkr", "config.yaml"), stringify({ ...overlay, routing: { ...overlay.routing, mode } }));
|
|
34
|
+
return loadConfigWithMode(tmp, { globalDir });
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function resolveRunMode(repoRoot, opts = {}) {
|
|
41
|
+
const overlayMode = (path) => readOverlayFile(path).routing?.mode;
|
|
42
|
+
const source = opts.flag !== undefined ? "run flag"
|
|
43
|
+
: opts.spec !== undefined ? "spec"
|
|
44
|
+
: overlayMode(repoOverlayPath(repoRoot)) !== undefined ? "repo config"
|
|
45
|
+
: overlayMode(join(opts.globalDir ?? globalConfigDir(), "config.yaml")) !== undefined ? "global config"
|
|
46
|
+
: "default";
|
|
47
|
+
const override = opts.flag ?? opts.spec;
|
|
48
|
+
const base = loadConfigWithMode(repoRoot, { globalDir: opts.globalDir });
|
|
49
|
+
const resolved = override === undefined || override === base.mode.mode
|
|
50
|
+
? base
|
|
51
|
+
: withOverlayMode(repoRoot, override, opts.globalDir);
|
|
52
|
+
const conflict = opts.flag !== undefined && opts.spec !== undefined && MODE_RANK[opts.flag] < MODE_RANK[opts.spec]
|
|
53
|
+
? `mode conflict: run flag ${opts.flag} selects a mode below the spec-declared ${opts.spec} — the run flag wins this run`
|
|
54
|
+
: undefined;
|
|
55
|
+
return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
|
|
56
|
+
}
|
|
21
57
|
// VIS-01: one formatter, four readers (run-end journal event, run/resume CLI, run-end notify).
|
|
22
58
|
// Parity by construction — every caller renders the same complete bucket line.
|
|
23
59
|
export function formatSummary(s) {
|
|
@@ -53,14 +89,11 @@ async function cherryPickCommits(wt, commits) {
|
|
|
53
89
|
return carried;
|
|
54
90
|
}
|
|
55
91
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
56
|
-
|
|
92
|
+
// v1.51 T2: retired --quality env seam. Mode resolution owns premium routing now; an exported
|
|
93
|
+
// TICKMARKR_QUALITY must not resurrect the old one-band floor raise in daemon dispatches.
|
|
94
|
+
delete process.env[QUALITY_ENV];
|
|
57
95
|
const adapters = opts.adapters ?? allAdapters();
|
|
58
96
|
const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
|
|
59
|
-
const channels = discoverChannels(cfg, adapters, health);
|
|
60
|
-
// v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
|
|
61
|
-
// No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
|
|
62
|
-
// for the run, so this run's own telemetry never feeds back into its own routing.
|
|
63
|
-
const profile = loadRoutingProfile(repoRoot, cfg);
|
|
64
97
|
const driver = opts.driver ?? new SubprocessDriver();
|
|
65
98
|
// HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
|
|
66
99
|
// BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
|
|
@@ -71,6 +104,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
71
104
|
const lock = acquireRunLock(repoRoot, runId);
|
|
72
105
|
try {
|
|
73
106
|
let graph = loadGraph(repoRoot);
|
|
107
|
+
// v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
|
|
108
|
+
// > repo > global > default. The resolved cfg carries mode-compiled floors; route() never sees the mode.
|
|
109
|
+
const rm = resolveRunMode(repoRoot, { flag: opts.mode, spec: graph.mode, globalDir: opts.globalDir });
|
|
110
|
+
const cfg = rm.cfg;
|
|
111
|
+
// v1.51 T4: every dispatch provenance line begins with the mode and its source; when a pin won
|
|
112
|
+
// the route (the final "→ " segment is a pin, not a degraded-to-auto tail) it names the mode it bypassed.
|
|
113
|
+
const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
|
|
114
|
+
const channels = discoverChannels(cfg, adapters, health);
|
|
115
|
+
// v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
|
|
116
|
+
// No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
|
|
117
|
+
// for the run, so this run's own telemetry never feeds back into its own routing.
|
|
118
|
+
const profile = loadRoutingProfile(repoRoot, cfg);
|
|
74
119
|
const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
75
120
|
const branchEvent = opts.resume
|
|
76
121
|
? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
|
|
@@ -130,7 +175,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
130
175
|
baseRef = await gitHead(repoRoot);
|
|
131
176
|
baseline = await captureBaseline(repoRoot, commands);
|
|
132
177
|
writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
|
|
133
|
-
journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness
|
|
178
|
+
journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2
|
|
134
179
|
}
|
|
135
180
|
// T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
|
|
136
181
|
// show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
|
|
@@ -388,7 +433,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
388
433
|
lastContextTokens = undefined;
|
|
389
434
|
graph = setStatus(graph, t.id, "running");
|
|
390
435
|
saveGraph(repoRoot, graph);
|
|
391
|
-
journal.append("task-dispatch", t.id, { assignment, attempt, provenance: r.provenance, retryMode });
|
|
436
|
+
journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
|
|
392
437
|
const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
|
|
393
438
|
const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
|
|
394
439
|
const priorWt = worktreePath(repoRoot, taskBranch);
|
|
@@ -489,7 +534,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
489
534
|
if (interactive) {
|
|
490
535
|
// v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
|
|
491
536
|
// The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
|
|
492
|
-
|
|
537
|
+
// T5: the pane announces itself first — same brand header (logo + dim identity) as the
|
|
538
|
+
// judge/review/consult dispatch scripts; one printf one-liner, dispatch mechanics unchanged.
|
|
539
|
+
await driver.run(slot, `${bannerShell()}; ${icmd}; ${exitMarkerCmd}`);
|
|
493
540
|
let paged = false;
|
|
494
541
|
// v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
|
|
495
542
|
// Any other blocked/idle dialog still pages the operator (paged latch below).
|
|
@@ -576,7 +623,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
576
623
|
}
|
|
577
624
|
else {
|
|
578
625
|
const inv = adapter.invoke(t, wt, assignment, { promptFile });
|
|
579
|
-
|
|
626
|
+
// T5: same brand header as the interactive path and the gate/consult dispatch scripts.
|
|
627
|
+
await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
|
|
580
628
|
// OBS-54: headless workers have the same output-inactivity budget as visible panes.
|
|
581
629
|
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
582
630
|
let lastOutput = await driver.read(slot, 500);
|