tickmarkr 1.50.0 → 1.52.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/codex.js +3 -0
- package/dist/adapters/model-lints.js +2 -2
- package/dist/adapters/registry.js +56 -37
- package/dist/adapters/types.d.ts +1 -0
- package/dist/cli/commands/fleet.js +93 -33
- package/dist/cli/commands/plan.d.ts +1 -1
- package/dist/cli/commands/plan.js +49 -6
- package/dist/cli/commands/run.js +31 -12
- package/dist/compile/native.js +18 -2
- package/dist/config/config.d.ts +37 -10
- package/dist/config/config.js +175 -38
- 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/route/router.d.ts +1 -0
- package/dist/route/router.js +6 -6
- package/dist/run/daemon.d.ts +16 -0
- package/dist/run/daemon.js +55 -11
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +10 -1
- package/package.json +1 -1
package/dist/compile/native.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { GATE_NAMES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
2
|
+
import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
3
3
|
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
4
4
|
export const LEGACY_PREFIX = ["dro", "vr"].join("");
|
|
5
5
|
export const TICKMARKR_NATIVE_MARKER = /^<!--\s*tickmarkr:spec(?:\s+v1)?\s*-->\s*$/m;
|
|
@@ -20,6 +20,7 @@ export function compileNative(file) {
|
|
|
20
20
|
const content = readFileSync(file, "utf8");
|
|
21
21
|
const drafts = [];
|
|
22
22
|
let plainCount = 0; // v1.19: plain-string acceptance items compiled as judge oracles (compat) — warn once
|
|
23
|
+
let specMode;
|
|
23
24
|
for (const line of content.split("\n")) {
|
|
24
25
|
const heading = line.match(HEAD_RE);
|
|
25
26
|
if (heading) {
|
|
@@ -27,8 +28,18 @@ export function compileNative(file) {
|
|
|
27
28
|
continue;
|
|
28
29
|
}
|
|
29
30
|
const draft = drafts.at(-1);
|
|
30
|
-
if (!draft)
|
|
31
|
+
if (!draft) {
|
|
32
|
+
// v1.51 T2: spec front-matter — a top-level `mode: <name>` line before the first task heading
|
|
33
|
+
// declares the engagement's routing mode (loses only to an explicit run flag).
|
|
34
|
+
const fm = line.match(/^mode:\s*(\S+)\s*$/);
|
|
35
|
+
if (fm) {
|
|
36
|
+
if (!GRAPH_ROUTING_MODES.includes(fm[1])) {
|
|
37
|
+
throw new CompileError(`spec front-matter mode must be one of ${GRAPH_ROUTING_MODES.join(", ")} (got ${JSON.stringify(fm[1])})`);
|
|
38
|
+
}
|
|
39
|
+
specMode = fm[1];
|
|
40
|
+
}
|
|
31
41
|
continue;
|
|
42
|
+
}
|
|
32
43
|
const field = line.match(FIELD_RE);
|
|
33
44
|
if (field) {
|
|
34
45
|
const name = field[1].toLowerCase();
|
|
@@ -151,6 +162,7 @@ export function compileNative(file) {
|
|
|
151
162
|
});
|
|
152
163
|
const result = validateGraph({
|
|
153
164
|
version: 1,
|
|
165
|
+
...(specMode ? { mode: specMode } : {}),
|
|
154
166
|
spec: { source: "native", paths: [file], hash: sha256(content) },
|
|
155
167
|
tasks,
|
|
156
168
|
});
|
|
@@ -184,6 +196,10 @@ Each task is a "## Tn: Title" heading with "- field: value" bullets.
|
|
|
184
196
|
acceptance is required on every task (a nested list of observable outcomes).
|
|
185
197
|
|
|
186
198
|
<!--
|
|
199
|
+
Spec front-matter (top-level, before the first task heading):
|
|
200
|
+
mode: partner-led | risk-based | staff-led — this engagement's routing mode
|
|
201
|
+
(loses only to an explicit \`run --mode\` flag)
|
|
202
|
+
|
|
187
203
|
Fields available per task:
|
|
188
204
|
goal: outcome the task must achieve (defaults to the title if omitted)
|
|
189
205
|
shape: plan | spec | implement | tests | docs | migration | ui | refactor | chore
|
package/dist/config/config.d.ts
CHANGED
|
@@ -7,16 +7,20 @@ declare const TierEnum: z.ZodEnum<{
|
|
|
7
7
|
export type Tier = z.infer<typeof TierEnum>;
|
|
8
8
|
export declare const TIER_RANK: Record<Tier, number>;
|
|
9
9
|
export declare const DEFAULT_DIFF_CAP = 60000;
|
|
10
|
+
export declare const ROUTING_MODES: readonly ["partner-led", "risk-based", "staff-led"];
|
|
11
|
+
declare const ModeEnum: z.ZodEnum<{
|
|
12
|
+
"partner-led": "partner-led";
|
|
13
|
+
"risk-based": "risk-based";
|
|
14
|
+
"staff-led": "staff-led";
|
|
15
|
+
}>;
|
|
16
|
+
export type RoutingMode = z.infer<typeof ModeEnum>;
|
|
17
|
+
export declare const INTEGRITY_FLOOR_SHAPES: readonly ["plan", "spec", "migration", "ui"];
|
|
10
18
|
export declare const MapEntrySchema: z.ZodObject<{
|
|
11
19
|
pin: z.ZodOptional<z.ZodObject<{
|
|
12
20
|
via: z.ZodString;
|
|
13
21
|
model: z.ZodString;
|
|
14
22
|
}, z.core.$strip>>;
|
|
15
|
-
tier: z.ZodOptional<z.
|
|
16
|
-
cheap: "cheap";
|
|
17
|
-
mid: "mid";
|
|
18
|
-
frontier: "frontier";
|
|
19
|
-
}>>;
|
|
23
|
+
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
20
24
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
21
25
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
22
26
|
}, z.core.$strip>;
|
|
@@ -60,16 +64,17 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
60
64
|
contextWarnTokens: z.ZodNumber;
|
|
61
65
|
setup: z.ZodOptional<z.ZodString>;
|
|
62
66
|
routing: z.ZodObject<{
|
|
67
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
68
|
+
"partner-led": "partner-led";
|
|
69
|
+
"risk-based": "risk-based";
|
|
70
|
+
"staff-led": "staff-led";
|
|
71
|
+
}>>;
|
|
63
72
|
map: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
64
73
|
pin: z.ZodOptional<z.ZodObject<{
|
|
65
74
|
via: z.ZodString;
|
|
66
75
|
model: z.ZodString;
|
|
67
76
|
}, z.core.$strip>>;
|
|
68
|
-
tier: z.ZodOptional<z.
|
|
69
|
-
cheap: "cheap";
|
|
70
|
-
mid: "mid";
|
|
71
|
-
frontier: "frontier";
|
|
72
|
-
}>>;
|
|
77
|
+
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
73
78
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
74
79
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
75
80
|
}, z.core.$strip>>;
|
|
@@ -195,9 +200,31 @@ export declare function globalConfigDir(): string;
|
|
|
195
200
|
export declare function overlayPreferShapes(repoRoot: string, opts?: {
|
|
196
201
|
globalDir?: string;
|
|
197
202
|
}): ReadonlySet<string>;
|
|
203
|
+
export type ModeResolution = {
|
|
204
|
+
mode: RoutingMode;
|
|
205
|
+
/** floor shape → "mode <name>" | "config floors" */
|
|
206
|
+
provenance: Record<string, string>;
|
|
207
|
+
/** plan lints: shadowed mode deltas + operator floors below the integrity minimum (standing) */
|
|
208
|
+
lints: string[];
|
|
209
|
+
};
|
|
210
|
+
/** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
|
|
211
|
+
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
212
|
+
export declare function loadConfigWithMode(repoRoot: string, opts?: {
|
|
213
|
+
globalDir?: string;
|
|
214
|
+
repoOverlayText?: string;
|
|
215
|
+
}): {
|
|
216
|
+
cfg: TickmarkrConfig;
|
|
217
|
+
mode: ModeResolution;
|
|
218
|
+
};
|
|
198
219
|
export declare function loadConfig(repoRoot: string, opts?: {
|
|
199
220
|
globalDir?: string;
|
|
200
221
|
}): TickmarkrConfig;
|
|
222
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
223
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
224
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
225
|
+
export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
|
|
226
|
+
globalDir?: string;
|
|
227
|
+
}): string | null;
|
|
201
228
|
export type InitConfigOverlay = {
|
|
202
229
|
concurrency?: number;
|
|
203
230
|
driver?: TickmarkrConfig["driver"];
|
package/dist/config/config.js
CHANGED
|
@@ -10,9 +10,31 @@ 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"];
|
|
23
|
+
// v1.52 T5: tier is no longer a map-entry band authority — routing.floors is the only tier
|
|
24
|
+
// source left (v1.51 T3 deprecated this field with a promise to remove it here). Only the legacy
|
|
25
|
+
// `tier: null` tombstone still parses (and normalizes to absent); any real value fails config
|
|
26
|
+
// load fail-closed, naming routing.floors as the move target, so a band intent set here can never
|
|
27
|
+
// silently vanish. The field stays in the schema/type as an always-undefined marker (not deleted
|
|
28
|
+
// outright) so a map entry can never carry a real tier value again, structurally.
|
|
13
29
|
export const MapEntrySchema = z.object({
|
|
14
30
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
15
|
-
tier:
|
|
31
|
+
tier: z
|
|
32
|
+
.null({
|
|
33
|
+
error: (iss) => `routing.map entry tier ${JSON.stringify(iss.input)} is no longer a band authority — move this value to routing.floors.<shape> (only "tier: null" tombstones still parse)`,
|
|
34
|
+
})
|
|
35
|
+
.optional()
|
|
36
|
+
.transform(() => undefined)
|
|
37
|
+
.optional(),
|
|
16
38
|
prefer: z.array(z.string()).optional(),
|
|
17
39
|
escalate: z.boolean().optional(),
|
|
18
40
|
});
|
|
@@ -80,6 +102,8 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
80
102
|
contextWarnTokens: z.number().int().positive(),
|
|
81
103
|
setup: z.string().optional(),
|
|
82
104
|
routing: z.object({
|
|
105
|
+
// v1.51 T1: preset expanded into floors by loadConfig — absent ⇒ risk-based ⇒ byte-identical routing.
|
|
106
|
+
mode: ModeEnum.optional(),
|
|
83
107
|
map: z.record(z.string(), MapEntrySchema),
|
|
84
108
|
floors: z.record(z.string(), TierEnum),
|
|
85
109
|
learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
|
|
@@ -155,12 +179,15 @@ export const DEFAULT_CONFIG = {
|
|
|
155
179
|
taskTimeoutMinutes: 30,
|
|
156
180
|
contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
|
|
157
181
|
routing: {
|
|
182
|
+
// v1.51 T3 (round-2 consult) / v1.52 T5: map entries carry preferences/pins only — band
|
|
183
|
+
// policy lives in routing.floors, the single tier authority. A map entry can no longer carry
|
|
184
|
+
// a real tier value at all: MapEntrySchema fails config load fail-closed on one, naming
|
|
185
|
+
// routing.floors as the move target; only the legacy `tier: null` tombstone still parses.
|
|
158
186
|
map: {
|
|
159
187
|
plan: { pin: { via: "claude-code", model: "fable" } },
|
|
160
188
|
spec: { pin: { via: "claude-code", model: "fable" } },
|
|
161
|
-
implement: {
|
|
162
|
-
tests: {
|
|
163
|
-
docs: { tier: "cheap" },
|
|
189
|
+
implement: { prefer: ["cursor-agent", "codex"] },
|
|
190
|
+
tests: { prefer: ["opencode"] },
|
|
164
191
|
},
|
|
165
192
|
floors: {
|
|
166
193
|
plan: "frontier", spec: "frontier", migration: "frontier",
|
|
@@ -278,7 +305,9 @@ function deepMerge(base, over) {
|
|
|
278
305
|
delete out[k]; // v1.1 tombstone: an explicit null in an overlay removes the key (e.g. stale tiers model ids)
|
|
279
306
|
continue;
|
|
280
307
|
}
|
|
281
|
-
|
|
308
|
+
// OBS-75 class: merge fresh-landing objects onto {} so nested tombstone nulls are pruned even when
|
|
309
|
+
// no lower layer set the key (a fleet-cleared deny writes {adapters: null} — the schema rejects raw null)
|
|
310
|
+
out[k] = k in out ? deepMerge(out[k], v) : deepMerge({}, v);
|
|
282
311
|
}
|
|
283
312
|
return out;
|
|
284
313
|
}
|
|
@@ -304,14 +333,104 @@ export function overlayPreferShapes(repoRoot, opts = {}) {
|
|
|
304
333
|
}
|
|
305
334
|
return shapes;
|
|
306
335
|
}
|
|
307
|
-
|
|
336
|
+
const lowerTier = (t) => (t === "frontier" ? "mid" : "cheap");
|
|
337
|
+
const maxTier = (a, b) => (TIER_RANK[a] >= TIER_RANK[b] ? a : b);
|
|
338
|
+
const integrityMin = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape) ? "frontier" : "cheap";
|
|
339
|
+
// partner-led = frontier everywhere; staff-led = one band down, integrity-clamped (net effect on the
|
|
340
|
+
// defaults: implement/refactor → cheap, ui → frontier); risk-based = identity, so an absent mode key
|
|
341
|
+
// resolves byte-identically to pre-v1.51 routing.
|
|
342
|
+
function presetFloor(mode, shape, dflt) {
|
|
343
|
+
if (mode === "partner-led")
|
|
344
|
+
return "frontier";
|
|
345
|
+
if (mode === "staff-led")
|
|
346
|
+
return maxTier(lowerTier(dflt), integrityMin(shape));
|
|
347
|
+
return dflt;
|
|
348
|
+
}
|
|
349
|
+
/** Which floor shapes the operator wrote in an overlay layer (explicit beats mode; null tombstones stay removed). */
|
|
350
|
+
function overlayFloorEdits(layers) {
|
|
351
|
+
const explicit = new Set();
|
|
352
|
+
const tombstoned = new Set();
|
|
353
|
+
for (const layer of layers) {
|
|
354
|
+
const floors = layer?.routing?.floors;
|
|
355
|
+
if (!floors || typeof floors !== "object" || Array.isArray(floors))
|
|
356
|
+
continue;
|
|
357
|
+
for (const [shape, v] of Object.entries(floors)) {
|
|
358
|
+
if (v === null) {
|
|
359
|
+
explicit.delete(shape);
|
|
360
|
+
tombstoned.add(shape);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
tombstoned.delete(shape);
|
|
364
|
+
explicit.add(shape);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { explicit, tombstoned };
|
|
369
|
+
}
|
|
370
|
+
// Mutates cfg.routing.floors (and explore under partner-led) to the mode-resolved values.
|
|
371
|
+
function resolveRoutingMode(cfg, layers) {
|
|
372
|
+
const mode = cfg.routing.mode ?? "risk-based";
|
|
373
|
+
const { explicit, tombstoned } = overlayFloorEdits(layers);
|
|
374
|
+
const provenance = {};
|
|
375
|
+
const lints = [];
|
|
376
|
+
for (const shape of SHAPES) {
|
|
377
|
+
if (tombstoned.has(shape))
|
|
378
|
+
continue; // operator removed the floor — a mode never resurrects it
|
|
379
|
+
const dflt = DEFAULT_CONFIG.routing.floors[shape];
|
|
380
|
+
const moded = presetFloor(mode, shape, dflt);
|
|
381
|
+
if (explicit.has(shape)) {
|
|
382
|
+
const val = cfg.routing.floors[shape];
|
|
383
|
+
provenance[shape] = "config floors";
|
|
384
|
+
if (moded !== val && moded !== dflt) {
|
|
385
|
+
lints.push(`floors.${shape}: ${val} (config floors) overrides mode ${mode} — shadowed delta: ${shape} ${dflt}→${moded}`);
|
|
386
|
+
}
|
|
387
|
+
if (TIER_RANK[val] < TIER_RANK[integrityMin(shape)]) {
|
|
388
|
+
lints.push(`floors.${shape}: ${val} is below integrity minimum frontier — integrity class ${INTEGRITY_FLOOR_SHAPES.join("/")} holds regardless of mode`);
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
cfg.routing.floors[shape] = moded;
|
|
393
|
+
provenance[shape] = `mode ${mode}`;
|
|
394
|
+
}
|
|
395
|
+
for (const shape of Object.keys(cfg.routing.floors)) {
|
|
396
|
+
if (!(shape in provenance))
|
|
397
|
+
provenance[shape] = "config floors"; // overlay floors on non-shape keys
|
|
398
|
+
}
|
|
399
|
+
// partner-led resolves exploration off (probes are the wrong spend under a premium declaration);
|
|
400
|
+
// staff-led and risk-based leave explore untouched — the round-2 fence rides through as configured.
|
|
401
|
+
if (mode === "partner-led")
|
|
402
|
+
cfg.routing.explore = { ...cfg.routing.explore, mode: "off" };
|
|
403
|
+
return { mode, provenance, lints };
|
|
404
|
+
}
|
|
405
|
+
/** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
|
|
406
|
+
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
407
|
+
export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
308
408
|
const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
|
|
309
|
-
|
|
409
|
+
// v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
|
|
410
|
+
// write guard validates EXACTLY what it is about to write through this one loader path.
|
|
411
|
+
const repoCfg = opts.repoOverlayText === undefined
|
|
412
|
+
? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
|
|
413
|
+
: parse(opts.repoOverlayText);
|
|
310
414
|
const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
|
|
311
415
|
const r = TickmarkrConfigSchema.safeParse(merged);
|
|
312
416
|
if (!r.success)
|
|
313
417
|
throw new ConfigError(z.prettifyError(r.error));
|
|
314
|
-
return r.data;
|
|
418
|
+
return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
|
|
419
|
+
}
|
|
420
|
+
export function loadConfig(repoRoot, opts = {}) {
|
|
421
|
+
return loadConfigWithMode(repoRoot, opts).cfg;
|
|
422
|
+
}
|
|
423
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
424
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
425
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
426
|
+
export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
|
|
427
|
+
try {
|
|
428
|
+
loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
return e.message;
|
|
433
|
+
}
|
|
315
434
|
}
|
|
316
435
|
export function configTemplate(overlay) {
|
|
317
436
|
const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
@@ -321,10 +440,15 @@ export function configTemplate(overlay) {
|
|
|
321
440
|
# contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
|
|
322
441
|
# setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
|
|
323
442
|
# routing:
|
|
324
|
-
#
|
|
325
|
-
#
|
|
443
|
+
# mode: risk-based # v1.51: partner-led | risk-based | staff-led — a preset compiled into floors at
|
|
444
|
+
# # load (partner-led: every shape frontier + explore off; staff-led: implement/refactor
|
|
445
|
+
# # cheap; integrity set plan/spec/migration/ui never resolves below frontier under any
|
|
446
|
+
# # mode). Absent = risk-based = byte-identical routing. Explicit floors below beat mode deltas (linted).
|
|
447
|
+
# map: # per-shape preferences/pins; band policy lives in routing.floors
|
|
448
|
+
# implement: { prefer: [cursor-agent, codex] }
|
|
326
449
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
327
|
-
#
|
|
450
|
+
# tests: { tier: null } # tombstone: null removes a legacy map tier (a real tier value now fails config load — move the band to routing.floors.tests)
|
|
451
|
+
# floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
|
|
328
452
|
# migration: frontier
|
|
329
453
|
# 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
454
|
# learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
|
|
@@ -561,7 +685,9 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
561
685
|
}
|
|
562
686
|
}
|
|
563
687
|
if (Object.keys(modelDelta).length) {
|
|
564
|
-
|
|
688
|
+
// spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
|
|
689
|
+
// makes the overlay unloadable for any adapter without a default seed (reload-guard class)
|
|
690
|
+
tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
|
|
565
691
|
tiersTouched = true;
|
|
566
692
|
}
|
|
567
693
|
}
|
|
@@ -587,47 +713,58 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
587
713
|
return "";
|
|
588
714
|
const lines = [];
|
|
589
715
|
const today = new Date().toISOString().slice(0, 10);
|
|
716
|
+
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
717
|
+
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
718
|
+
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
719
|
+
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
590
720
|
const routing = overlay.routing;
|
|
591
721
|
if (routing) {
|
|
592
722
|
lines.push("routing:");
|
|
593
723
|
const deny = routing.deny;
|
|
594
|
-
if (deny) {
|
|
724
|
+
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
595
725
|
lines.push(" deny:");
|
|
596
|
-
if (deny.adapters)
|
|
597
|
-
lines.push(
|
|
598
|
-
if (deny.models)
|
|
599
|
-
lines.push(
|
|
600
|
-
}
|
|
601
|
-
if (routing.map) {
|
|
602
|
-
lines.push(" map:");
|
|
603
|
-
for (const [shape, entry] of Object.entries(routing.map)) {
|
|
604
|
-
lines.push(` ${shape}:`);
|
|
605
|
-
const body = stringify(entry, { indent: 2 }).trim().split("\n");
|
|
606
|
-
lines.push(...body.map((l) => ` ${l}`));
|
|
607
|
-
}
|
|
726
|
+
if (deny.adapters !== undefined)
|
|
727
|
+
lines.push(...block({ adapters: deny.adapters }, " "));
|
|
728
|
+
if (deny.models !== undefined)
|
|
729
|
+
lines.push(...block({ models: deny.models }, " "));
|
|
608
730
|
}
|
|
731
|
+
if (routing.map)
|
|
732
|
+
lines.push(...block({ map: routing.map }, " "));
|
|
609
733
|
if (routing.floors)
|
|
610
|
-
lines.push(
|
|
734
|
+
lines.push(...block({ floors: routing.floors }, " "));
|
|
611
735
|
}
|
|
612
736
|
const tiers = overlay.tiers;
|
|
613
|
-
if (tiers) {
|
|
737
|
+
if (tiers && Object.keys(tiers).length) {
|
|
614
738
|
lines.push("tiers:");
|
|
615
739
|
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
616
|
-
|
|
740
|
+
const body = [];
|
|
617
741
|
if (entry.vendor)
|
|
618
|
-
|
|
742
|
+
body.push(` vendor: ${entry.vendor}`);
|
|
619
743
|
if (entry.channel)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
744
|
+
body.push(` channel: ${entry.channel}`);
|
|
745
|
+
if (entry.windows)
|
|
746
|
+
body.push(...block({ windows: entry.windows }, " "));
|
|
747
|
+
const models = Object.entries(entry.models ?? {});
|
|
748
|
+
if (models.length) {
|
|
749
|
+
body.push(" models:");
|
|
750
|
+
for (const [model, tier] of models) {
|
|
751
|
+
if (tier === null)
|
|
752
|
+
body.push(` ${model}: null`);
|
|
753
|
+
else {
|
|
754
|
+
const note = provenance[adapter]?.[model];
|
|
755
|
+
const suffix = note ? ` # ${note} — fleet ${today}` : "";
|
|
756
|
+
body.push(` ${model}: ${tier}${suffix}`);
|
|
757
|
+
}
|
|
629
758
|
}
|
|
630
759
|
}
|
|
760
|
+
else if (entry.models) {
|
|
761
|
+
body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
|
|
762
|
+
}
|
|
763
|
+
// a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
|
|
764
|
+
if (body.length)
|
|
765
|
+
lines.push(` ${adapter}:`, ...body);
|
|
766
|
+
else
|
|
767
|
+
lines.push(` ${adapter}: {}`);
|
|
631
768
|
}
|
|
632
769
|
}
|
|
633
770
|
return `${lines.join("\n")}\n`;
|
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/route/router.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface ExploreContext {
|
|
|
32
32
|
}
|
|
33
33
|
export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
34
34
|
export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
35
|
+
export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
|
|
35
36
|
export declare const raiseTier: (tier: Tier) => Tier;
|
|
36
37
|
export declare class RoutingError extends Error {
|
|
37
38
|
constructor(msg: string);
|
package/dist/route/router.js
CHANGED
|
@@ -4,6 +4,9 @@ import { disallowedBy } from "./preference.js";
|
|
|
4
4
|
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
|
|
5
5
|
export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
6
6
|
export const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
7
|
+
// OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
|
|
8
|
+
// these from child env so gate/baseline/tip-verify children are hermetic by construction.
|
|
9
|
+
export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
|
|
7
10
|
const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
8
11
|
const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
|
|
9
12
|
export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
|
|
@@ -160,15 +163,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
160
163
|
maybeSlaLint(lints, task, profile, slaMinutes, c);
|
|
161
164
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
162
165
|
}
|
|
163
|
-
const baseTier =
|
|
166
|
+
const baseTier = floor ?? "cheap";
|
|
164
167
|
let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
|
|
165
168
|
if (quality && advisoryFloor) {
|
|
166
169
|
const raised = raiseTier(advisoryFloor);
|
|
167
170
|
if (TIER_RANK[raised] > TIER_RANK[minTier])
|
|
168
171
|
minTier = raised;
|
|
169
172
|
}
|
|
170
|
-
if (entry?.tier)
|
|
171
|
-
lintFloor(entry.tier, "map tier");
|
|
172
173
|
if (prefActive)
|
|
173
174
|
for (const p of prefer ?? [])
|
|
174
175
|
preflightPrefer(p);
|
|
@@ -253,9 +254,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
253
254
|
}
|
|
254
255
|
const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
|
|
255
256
|
(taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
"tier cheap (default)");
|
|
257
|
+
floor ? `floor ${floor} (config floors)` :
|
|
258
|
+
"tier cheap (default)");
|
|
259
259
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
260
260
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
261
261
|
const preferVia = preferFromAuto(task.shape, preferCtx)
|
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;
|