tickmarkr 2.5.7 → 2.5.9
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/qwen.js +30 -3
- package/dist/cli/commands/approve.js +23 -4
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +18 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/plan.d.ts +5 -0
- package/dist/cli/commands/plan.js +28 -23
- package/dist/cli/commands/resume.js +1 -1
- package/dist/cli/commands/run.js +1 -1
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +16 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/compile/native.js +68 -7
- package/dist/compile/retired-literals.d.ts +22 -0
- package/dist/compile/retired-literals.js +271 -0
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/drivers/index.d.ts +4 -2
- package/dist/drivers/index.js +54 -6
- package/dist/gates/baseline.d.ts +65 -0
- package/dist/gates/baseline.js +163 -9
- package/dist/gates/review.d.ts +6 -4
- package/dist/gates/review.js +62 -21
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +25 -13
- package/dist/gates/test-manifest.d.ts +20 -1
- package/dist/gates/test-manifest.js +50 -22
- package/dist/gates/test-reporter.js +22 -1
- package/dist/graph/schema.d.ts +28 -0
- package/dist/graph/schema.js +13 -1
- package/dist/run/daemon.d.ts +19 -0
- package/dist/run/daemon.js +363 -118
- package/dist/run/journal.d.ts +54 -3
- package/dist/run/journal.js +142 -11
- package/dist/run/merge.d.ts +15 -2
- package/dist/run/merge.js +74 -11
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/repair-disposition.d.ts +41 -0
- package/dist/run/repair-disposition.js +77 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/cockpit/home-view.js +45 -30
- package/dist/tui/cockpit/live-store.d.ts +18 -0
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/schema/rungraph.schema.json +54 -0
- package/skills/tickmarkr-overseer/SKILL.md +168 -36
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/cli/help.d.ts
CHANGED
|
@@ -176,6 +176,8 @@ export declare const COMMAND_HELP: {
|
|
|
176
176
|
description: string;
|
|
177
177
|
options: {
|
|
178
178
|
"--seat <identity>": string;
|
|
179
|
+
"--new-arm": string;
|
|
180
|
+
"--loop": string;
|
|
179
181
|
"--arm-id <identity>": string;
|
|
180
182
|
"--pct <0..100>": string;
|
|
181
183
|
"--threshold-pct <0..100>": string;
|
package/dist/cli/help.js
CHANGED
|
@@ -140,14 +140,16 @@ export const COMMAND_HELP = {
|
|
|
140
140
|
},
|
|
141
141
|
beat: {
|
|
142
142
|
usage: "beat <orchestrator|orchestrator-context|overseer|overseer-context|watch> --seat <identity> [options]",
|
|
143
|
-
description: "Write one supervision beat.
|
|
143
|
+
description: "Write one supervision beat using the recorded arm. Stood-down arms stay DISARMED until explicitly re-armed; stopped beats become STALE.",
|
|
144
144
|
options: {
|
|
145
145
|
"--seat <identity>": "Required: identify the supervising pane or agent.",
|
|
146
|
-
"--arm
|
|
146
|
+
"--new-arm": "Create a new durable arm, allowing beats to resume after stand-down; the hand-off step for a seat taking over a stood-down tier (combine with --arm-id and --pct on a context tier).",
|
|
147
|
+
"--loop": "Create a new durable arm and beat in this process every 10 seconds; exit within one interval after stand-down.",
|
|
148
|
+
"--arm-id <identity>": "Name a newly created arm; with --pct, identify the context observation arm. Never refuses a tick.",
|
|
147
149
|
"--pct <0..100>": "Report current context consumption percentage.",
|
|
148
150
|
"--threshold-pct <0..100>": "Set the context warning threshold (default 75).",
|
|
149
|
-
"--stand-down": "Record an explicit handoff and stand down this tier.",
|
|
150
|
-
}, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --stand-down"],
|
|
151
|
+
"--stand-down": "Record an explicit handoff and stand down this tier. A later one-shot tick on the old arm exits non-zero; context observations still raise and discharge the clear duty.",
|
|
152
|
+
}, examples: ["beat overseer --seat supervisor", "beat overseer --seat supervisor --loop", "beat overseer --seat supervisor --stand-down", "beat overseer --seat supervisor --new-arm"],
|
|
151
153
|
},
|
|
152
154
|
version: {
|
|
153
155
|
usage: "version [--dist]", description: "Print the installed package version. Top-level aliases: --version and -v.",
|
package/dist/compile/native.js
CHANGED
|
@@ -69,7 +69,51 @@ const NESTED_RE = /^\s+- (.+)$/;
|
|
|
69
69
|
// v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
|
|
70
70
|
// without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
|
|
71
71
|
const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
|
|
72
|
-
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout"]);
|
|
72
|
+
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins"]);
|
|
73
|
+
const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw : draft.list === "pins" ? draft.pinsRaw : draft.gates;
|
|
74
|
+
// v2.5.8 T7: one declared pin item — "literal: <exact text> | glob: <search glob>" (split at the LAST
|
|
75
|
+
// " | glob: " so the text may itself hold a bar) or "fixture: <path set>". A declaration missing the
|
|
76
|
+
// payload its own kind requires refuses the compile naming the task and the item.
|
|
77
|
+
const PIN_GLOB_SEP = "| glob:";
|
|
78
|
+
// v2.5.8 T8 (OBS-1064): "test: <title> | suite: <path>" — split at the LAST " | suite: " so the title
|
|
79
|
+
// stays the verbatim leaf the gate matches and the landing path never enters it.
|
|
80
|
+
const LANDING_SEP = "| suite:";
|
|
81
|
+
function parsePin(task, raw, index, pathSet) {
|
|
82
|
+
const bad = (detail) => invalid(task, "pins", `item ${index + 1} (${JSON.stringify(raw)}) ${detail}`);
|
|
83
|
+
const typed = raw.match(/^(\w+):\s*(.*)$/);
|
|
84
|
+
if (typed?.[1] === "fixture") {
|
|
85
|
+
const paths = pathSet(typed[2]);
|
|
86
|
+
if (!paths.length)
|
|
87
|
+
bad("is a fixture pin missing its path set");
|
|
88
|
+
return { kind: "fixture", paths };
|
|
89
|
+
}
|
|
90
|
+
if (typed?.[1] === "literal") {
|
|
91
|
+
const at = typed[2].lastIndexOf(PIN_GLOB_SEP);
|
|
92
|
+
const text = (at < 0 ? typed[2] : typed[2].slice(0, at)).trim();
|
|
93
|
+
const glob = at < 0 ? "" : typed[2].slice(at + PIN_GLOB_SEP.length).trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
bad("is a literal pin missing its text");
|
|
96
|
+
if (!glob)
|
|
97
|
+
bad(`is a literal pin missing its search glob — write "literal: <text> ${PIN_GLOB_SEP} <glob>"`);
|
|
98
|
+
return { kind: "literal", text, glob };
|
|
99
|
+
}
|
|
100
|
+
return bad('must be "literal: <text> | glob: <glob>" or "fixture: <path set>"');
|
|
101
|
+
}
|
|
102
|
+
function parseTestOracle(task, body, index) {
|
|
103
|
+
const at = body.lastIndexOf(LANDING_SEP);
|
|
104
|
+
if (at < 0)
|
|
105
|
+
return { oracle: "test", test: body.trim() };
|
|
106
|
+
const test = body.slice(0, at).trim();
|
|
107
|
+
const landing = body.slice(at + LANDING_SEP.length).trim();
|
|
108
|
+
const bad = (detail) => invalid(task, "acceptance", `item ${index + 1} (${JSON.stringify(body)}) ${detail}`);
|
|
109
|
+
if (!test)
|
|
110
|
+
bad("is a test oracle missing its title before the landing");
|
|
111
|
+
if (!landing)
|
|
112
|
+
bad(`is a test oracle missing its landing path — write "test: <title> ${LANDING_SEP} <path>"`);
|
|
113
|
+
if (!picomatch(COLLECTABLE_TESTS, { dot: true })(landing))
|
|
114
|
+
bad(`declares landing ${JSON.stringify(landing)} which no runner collects (${COLLECTABLE_TESTS})`);
|
|
115
|
+
return { oracle: "test", test, landing };
|
|
116
|
+
}
|
|
73
117
|
function invalid(task, field, detail) {
|
|
74
118
|
throw new CompileError(`Task ${task} field "${field}" ${detail}`);
|
|
75
119
|
}
|
|
@@ -376,7 +420,7 @@ export function compileNative(file, options = {}) {
|
|
|
376
420
|
for (const [index, line] of content.split("\n").entries()) {
|
|
377
421
|
const heading = line.match(HEAD_RE);
|
|
378
422
|
if (heading) {
|
|
379
|
-
drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, list: null, itemOpen: false, continuationField: null });
|
|
423
|
+
drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, pinsRaw: [], list: null, itemOpen: false, continuationField: null });
|
|
380
424
|
continue;
|
|
381
425
|
}
|
|
382
426
|
const draft = drafts.at(-1);
|
|
@@ -411,7 +455,7 @@ export function compileNative(file, options = {}) {
|
|
|
411
455
|
invalid(draft.id, field[1], "is unknown");
|
|
412
456
|
const value = field[2].trim();
|
|
413
457
|
draft.itemOpen = false;
|
|
414
|
-
if (name === "acceptance" || name === "gates") {
|
|
458
|
+
if (name === "acceptance" || name === "gates" || name === "pins") {
|
|
415
459
|
if (value)
|
|
416
460
|
invalid(draft.id, field[1], "must be a nested list");
|
|
417
461
|
draft.list = name;
|
|
@@ -459,7 +503,7 @@ export function compileNative(file, options = {}) {
|
|
|
459
503
|
const value = nested[1].trim();
|
|
460
504
|
if (!value)
|
|
461
505
|
invalid(draft.id, draft.list, "must not contain empty entries");
|
|
462
|
-
(draft
|
|
506
|
+
listItems(draft).push(value);
|
|
463
507
|
draft.itemOpen = true;
|
|
464
508
|
continue;
|
|
465
509
|
}
|
|
@@ -468,7 +512,7 @@ export function compileNative(file, options = {}) {
|
|
|
468
512
|
// 1.87.0 dropped these lines silently: 53/78 of run-551's criteria compiled to first-line
|
|
469
513
|
// stubs and every falsifier tail was invisible to the judge.
|
|
470
514
|
if (draft.list && draft.itemOpen && (line.startsWith(" ") || line.startsWith("\t")) && line.trim()) {
|
|
471
|
-
const items = draft
|
|
515
|
+
const items = listItems(draft);
|
|
472
516
|
items[items.length - 1] += ` ${line.trim()}`;
|
|
473
517
|
continue;
|
|
474
518
|
}
|
|
@@ -489,14 +533,14 @@ export function compileNative(file, options = {}) {
|
|
|
489
533
|
// OBS-488: typed-oracle prefixes parse on the COMPLETE joined item text, never its first
|
|
490
534
|
// physical line — a wrapped `command:` body or falsifier tail is part of the criterion.
|
|
491
535
|
for (const draft of drafts) {
|
|
492
|
-
for (const raw of draft.acceptanceRaw) {
|
|
536
|
+
for (const [index, raw] of draft.acceptanceRaw.entries()) {
|
|
493
537
|
const typed = raw.match(ORACLE_RE);
|
|
494
538
|
if (typed) {
|
|
495
539
|
const [, kind, body] = typed;
|
|
496
540
|
if (!body.trim())
|
|
497
541
|
invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
|
|
498
542
|
draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
|
|
499
|
-
: kind === "test" ?
|
|
543
|
+
: kind === "test" ? parseTestOracle(draft.id, body, index)
|
|
500
544
|
: { oracle: "judge", text: body.trim() });
|
|
501
545
|
}
|
|
502
546
|
else {
|
|
@@ -561,6 +605,9 @@ export function compileNative(file, options = {}) {
|
|
|
561
605
|
return trimmed;
|
|
562
606
|
};
|
|
563
607
|
const csv = (value) => value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => stripAnnotation(item.trim())).filter(Boolean) : [];
|
|
608
|
+
// A fixture pin's path set is split only — never annotation-stripped: "fixtures/output (old)" is a
|
|
609
|
+
// filename, and csv() would silently retarget the declared obligation to "fixtures/output".
|
|
610
|
+
const pinPaths = (value) => splitTop(value).map((item) => item.trim()).filter(Boolean);
|
|
564
611
|
// OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
|
|
565
612
|
// paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
|
|
566
613
|
// mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
|
|
@@ -677,6 +724,7 @@ export function compileNative(file, options = {}) {
|
|
|
677
724
|
...(timeoutMinutes !== undefined ? { timeoutMinutes } : {}),
|
|
678
725
|
...(routingHints ? { routingHints } : {}),
|
|
679
726
|
...(draft.hasGates ? { gates: draft.gates } : {}),
|
|
727
|
+
...(draft.pinsRaw.length ? { pins: draft.pinsRaw.map((raw, i) => parsePin(draft.id, raw, i, pinPaths)) } : {}),
|
|
680
728
|
};
|
|
681
729
|
});
|
|
682
730
|
const result = validateGraph({
|
|
@@ -780,9 +828,19 @@ acceptance is required on every task (a nested list of observable outcomes).
|
|
|
780
828
|
acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
|
|
781
829
|
- command: <shell> (oracle: command — exit code)
|
|
782
830
|
- test: <name> (oracle: test — named test)
|
|
831
|
+
- test: <name> | suite: <path> (same, plus the tests/**/*.test.ts file it lands in;
|
|
832
|
+
the path lives in the item's landing field, never in the title)
|
|
783
833
|
- judge: <rubric> (oracle: judge — LLM-judged, free text)
|
|
784
834
|
A judge criterion carries ONE claim; a semicolon-joined criterion warns — split its clauses.
|
|
785
835
|
- <plain text> (compat: compiles as judge oracle, warns)
|
|
836
|
+
pins: nested list (optional) of retired-literal and fixture pin obligations:
|
|
837
|
+
- literal: <exact text> | glob: <search glob> (every file matching the glob that holds
|
|
838
|
+
the text is an obligated file)
|
|
839
|
+
- fixture: <comma-separated path set> (every matching file is itself obligated:
|
|
840
|
+
byte-pinned output the change will move;
|
|
841
|
+
it carries no literal text)
|
|
842
|
+
LAW: a pins declaration is a LIMITED AUTHORING CONTRACT, not an assertion analyzer —
|
|
843
|
+
tickmarkr holds you to the obligations you DECLARE; it does not discover the pins you forgot.
|
|
786
844
|
|
|
787
845
|
HARD BOUNDS — these FAIL the compile, they do not warn:
|
|
788
846
|
- at most 6 acceptance items per task (no exception path)
|
|
@@ -799,6 +857,9 @@ acceptance is required on every task (a nested list of observable outcomes).
|
|
|
799
857
|
describe() is allowed (OBS-511: the gate matches the criterion as the trailing segment of the
|
|
800
858
|
runner-visible full name), but the leaf title itself must equal the criterion: a shortened,
|
|
801
859
|
decorated, or paraphrased leaf selects ZERO tests and the gate parks even though the suite is green.
|
|
860
|
+
To say WHICH suite the test lands in, append " | suite: <tests/**/*.test.ts path>" — the path is
|
|
861
|
+
parsed off into the item's landing field and the title stays the bare leaf; a landing outside the
|
|
862
|
+
collectable glob refuses the compile.
|
|
802
863
|
Measured before the widening: two parks across two runs, one full attempt lost in each.
|
|
803
864
|
- NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
|
|
804
865
|
"no file references X" is not a criterion — it passes in a repo where the feature was never built.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Task } from "../graph/schema.js";
|
|
2
|
+
/** The file-system seam: tests substitute it to stage an unreadable file or a truncated read. */
|
|
3
|
+
export interface PinScanIo {
|
|
4
|
+
size(abs: string): number;
|
|
5
|
+
read(abs: string): Buffer;
|
|
6
|
+
/** stat-following; throws like statSync (ENOENT for a broken link). */
|
|
7
|
+
isDirectory(abs: string): boolean;
|
|
8
|
+
/** stat-following: a regular file (or a link resolving to one) — never a FIFO, socket or device. */
|
|
9
|
+
isFile(abs: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface LiteralHit {
|
|
12
|
+
path: string;
|
|
13
|
+
line: number;
|
|
14
|
+
}
|
|
15
|
+
/** Each line of `content` holding `text`, 1-based. */
|
|
16
|
+
export declare function literalHits(path: string, content: string, text: string): LiteralHit[];
|
|
17
|
+
/**
|
|
18
|
+
* One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
|
|
19
|
+
* by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
|
|
20
|
+
* named in the diagnostic and is no substitute.
|
|
21
|
+
*/
|
|
22
|
+
export declare function retiredLiteralErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "pins">>, repoRoot: string, io?: PinScanIo): string[];
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { filesGlob, literalParens } from "../graph/files-glob.js";
|
|
4
|
+
const NODE_IO = {
|
|
5
|
+
size: (abs) => statSync(abs).size,
|
|
6
|
+
read: (abs) => readFileSync(abs),
|
|
7
|
+
isDirectory: (abs) => statSync(abs).isDirectory(),
|
|
8
|
+
isFile: (abs) => statSync(abs).isFile(),
|
|
9
|
+
};
|
|
10
|
+
const cause = (error) => (error instanceof Error ? error.message : String(error));
|
|
11
|
+
const patternsOf = (pin) => (pin.kind === "literal" ? [pin.glob] : pin.paths);
|
|
12
|
+
/** Leading segments with no glob syntax at all (never the last): every match of the pattern sits under them. */
|
|
13
|
+
const literalBase = (pattern) => {
|
|
14
|
+
const segs = literalParens(pattern).split("/");
|
|
15
|
+
const wild = segs.findIndex((s) => !/^[\w.-]+$/.test(s) || s === "..");
|
|
16
|
+
return segs.slice(0, Math.min(wild < 0 ? segs.length : wild, segs.length - 1)).join("/");
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* picomatch's brace semantics are expansion (`{a,b/c}/x` ≡ `a/x`, `b/c/x`; `\,` and `\{` are literal):
|
|
20
|
+
* every top-level comma-holding brace is expanded until none remains, so a slash inside a brace is an
|
|
21
|
+
* honest segment boundary. A brace with no top-level comma (`{a}`, `{1..3}`) is left for filesGlob.
|
|
22
|
+
*/
|
|
23
|
+
function expandBraces(pattern) {
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let open = -1;
|
|
26
|
+
let alts = [];
|
|
27
|
+
let last = -1;
|
|
28
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
29
|
+
const c = pattern[i];
|
|
30
|
+
if (c === "\\")
|
|
31
|
+
i++;
|
|
32
|
+
else if (c === "{") {
|
|
33
|
+
if (depth++ === 0) {
|
|
34
|
+
open = i;
|
|
35
|
+
last = i + 1;
|
|
36
|
+
alts = [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (c === "," && depth === 1) {
|
|
40
|
+
alts.push(pattern.slice(last, i));
|
|
41
|
+
last = i + 1;
|
|
42
|
+
}
|
|
43
|
+
else if (c === "}" && depth > 0 && --depth === 0) {
|
|
44
|
+
if (!alts.length)
|
|
45
|
+
continue; // no top-level comma: a literal or range brace, not an alternation
|
|
46
|
+
alts.push(pattern.slice(last, i));
|
|
47
|
+
const head = pattern.slice(0, open);
|
|
48
|
+
const tail = pattern.slice(i + 1);
|
|
49
|
+
return alts.flatMap((a) => expandBraces(head + a + tail));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [pattern];
|
|
53
|
+
}
|
|
54
|
+
/** Split on "/" where it is a segment boundary; null when a slash is escaped or sits inside brackets. */
|
|
55
|
+
function segmentsOf(pattern) {
|
|
56
|
+
const out = [""];
|
|
57
|
+
let depth = 0;
|
|
58
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
59
|
+
const c = pattern[i];
|
|
60
|
+
if (c === "\\") {
|
|
61
|
+
if (pattern[i + 1] === "/")
|
|
62
|
+
return null;
|
|
63
|
+
out[out.length - 1] += c + (pattern[++i] ?? "");
|
|
64
|
+
}
|
|
65
|
+
else if (c === "/") {
|
|
66
|
+
if (depth)
|
|
67
|
+
return null;
|
|
68
|
+
out.push("");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
if (c === "[")
|
|
72
|
+
depth++;
|
|
73
|
+
else if (c === "]" && depth)
|
|
74
|
+
depth--;
|
|
75
|
+
out[out.length - 1] += c;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Does `alt` (brace-free) match EVERY path beneath `d`? Only a trailing globstar right after a matched prefix
|
|
82
|
+
* proves it; a globstar before that may swallow any run of `d`'s segments (`**\/cache/**` covers fixtures/cache/loop).
|
|
83
|
+
*/
|
|
84
|
+
function coversBeneath(alt, d) {
|
|
85
|
+
const segs = segmentsOf(alt);
|
|
86
|
+
if (!segs)
|
|
87
|
+
return false;
|
|
88
|
+
const covers = (si, di) => {
|
|
89
|
+
if (si >= segs.length)
|
|
90
|
+
return false;
|
|
91
|
+
if (segs[si] === "**") {
|
|
92
|
+
if (si === segs.length - 1)
|
|
93
|
+
return true;
|
|
94
|
+
for (let k = di; k <= d.length; k++)
|
|
95
|
+
if (covers(si + 1, k))
|
|
96
|
+
return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (di >= d.length)
|
|
100
|
+
return false;
|
|
101
|
+
// the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
|
|
102
|
+
return filesGlob(segs.slice(0, si + 1).join("/"))(d.slice(0, di + 1).join("/")) && covers(si + 1, di + 1);
|
|
103
|
+
};
|
|
104
|
+
return covers(0, 0);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Could `pattern` match a path strictly beneath directory `dir`? Matcher-consistent by construction:
|
|
108
|
+
* braces are expanded the way picomatch reads them, then every segment prefix is decided by filesGlob itself,
|
|
109
|
+
* and only an exact `**` segment is a globstar (`**.txt` is one ordinary segment). A leading `!` matches
|
|
110
|
+
* every path its body does not, so it reaches beneath `dir` unless some branch of the body provably covers
|
|
111
|
+
* all of it. What still cannot be split soundly (an escaped or bracketed slash) answers yes rather than certify blind.
|
|
112
|
+
*/
|
|
113
|
+
function reachesBeneath(pattern, dir) {
|
|
114
|
+
const p = pattern.replace(/^\.\//, "");
|
|
115
|
+
const d = dir ? dir.split("/") : [];
|
|
116
|
+
if (p.startsWith("!"))
|
|
117
|
+
return !expandBraces(literalParens(p.slice(1))).some((alt) => coversBeneath(alt, d));
|
|
118
|
+
return expandBraces(literalParens(p)).some((alt) => {
|
|
119
|
+
const segs = segmentsOf(alt);
|
|
120
|
+
if (!segs)
|
|
121
|
+
return true;
|
|
122
|
+
for (let i = 0; i < d.length; i++) {
|
|
123
|
+
if (i >= segs.length)
|
|
124
|
+
return false;
|
|
125
|
+
if (segs[i] === "**")
|
|
126
|
+
return true;
|
|
127
|
+
// the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
|
|
128
|
+
if (!filesGlob(segs.slice(0, i + 1).join("/"))(d.slice(0, i + 1).join("/")))
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return segs.length > d.length;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Every non-directory path at or under `base`, decided later by filesGlob alone. NOTHING is pruned:
|
|
136
|
+
* not .git, not node_modules, not ignored files, not the state dir — the declared glob is the only
|
|
137
|
+
* narrowing (the literal base is exact: no match can sit outside it). A directory symlink is
|
|
138
|
+
* followed so matches behind it are listed under the symlink's own path; a link back into its own
|
|
139
|
+
* ancestry has no finite listing, so it refuses by name instead of being skipped.
|
|
140
|
+
* ponytail: a rootless glob such as ** / *.txt walks node_modules too; narrow the glob if that is slow.
|
|
141
|
+
*/
|
|
142
|
+
function walkAll(repoRoot, base, io) {
|
|
143
|
+
const out = { paths: [], causes: [] };
|
|
144
|
+
const visit = (rel, chain) => {
|
|
145
|
+
const abs = join(repoRoot, rel);
|
|
146
|
+
let isDir;
|
|
147
|
+
try {
|
|
148
|
+
isDir = io.isDirectory(abs);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
try {
|
|
152
|
+
// only a confirmed ENOENT is a known non-directory: a broken link stays a candidate whose read names the cause
|
|
153
|
+
if (error.code === "ENOENT") {
|
|
154
|
+
if (lstatSync(abs, { throwIfNoEntry: false }))
|
|
155
|
+
out.paths.push(rel);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* the lstat failed too: fall through to the refusal */ }
|
|
160
|
+
// anything else may be a directory hiding obligations: never a candidate that a glob could filter away
|
|
161
|
+
out.causes.push({ at: rel, why: `unstattable path ${rel || "."} (${cause(error)})`, maybeFile: true });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!isDir) {
|
|
165
|
+
if (rel)
|
|
166
|
+
out.paths.push(rel);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const real = realpathSync(abs);
|
|
171
|
+
if (chain.includes(real)) {
|
|
172
|
+
out.causes.push({ at: rel, why: `symlink cycle at ${rel} cannot be enumerated`, maybeFile: false });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const name of readdirSync(abs).sort())
|
|
176
|
+
visit(rel ? `${rel}/${name}` : name, [...chain, real]);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
out.causes.push({ at: rel, why: `unlistable directory ${rel || "."} (${cause(error)})`, maybeFile: false });
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
visit(base, []);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** Whole-file read, or a thrown cause: a short read is never evidence of absence. */
|
|
186
|
+
function readWhole(repoRoot, rel, io) {
|
|
187
|
+
const abs = join(repoRoot, rel);
|
|
188
|
+
let size;
|
|
189
|
+
let bytes;
|
|
190
|
+
try {
|
|
191
|
+
// a FIFO, socket or device has no finite whole to read: refuse it by kind before any read could block
|
|
192
|
+
if (!io.isFile(abs))
|
|
193
|
+
throw new Error(`unsupported file ${rel} (not a regular file)`);
|
|
194
|
+
size = io.size(abs);
|
|
195
|
+
bytes = io.read(abs);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
throw new Error(`unreadable file ${rel} (${cause(error)})`);
|
|
199
|
+
}
|
|
200
|
+
if (bytes.length !== size)
|
|
201
|
+
throw new Error(`truncated read of ${rel} (${bytes.length} of ${size} bytes)`);
|
|
202
|
+
return bytes.toString("utf8");
|
|
203
|
+
}
|
|
204
|
+
/** Each line of `content` holding `text`, 1-based. */
|
|
205
|
+
export function literalHits(path, content, text) {
|
|
206
|
+
return content.split("\n").flatMap((line, i) => (line.includes(text) ? [{ path, line: i + 1 }] : []));
|
|
207
|
+
}
|
|
208
|
+
const pinName = (pin) => pin.kind === "literal" ? `literal ${JSON.stringify(pin.text)} in ${pin.glob}` : `fixture pin ${pin.paths.join(", ")}`;
|
|
209
|
+
/**
|
|
210
|
+
* One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
|
|
211
|
+
* by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
|
|
212
|
+
* named in the diagnostic and is no substitute.
|
|
213
|
+
*/
|
|
214
|
+
export function retiredLiteralErrors(tasks, repoRoot, io = NODE_IO) {
|
|
215
|
+
const declaring = tasks.filter((t) => t.pins?.length);
|
|
216
|
+
if (!declaring.length)
|
|
217
|
+
return [];
|
|
218
|
+
const walks = new Map();
|
|
219
|
+
const walked = (base) => walks.get(base) ?? walks.set(base, walkAll(repoRoot, base, io)).get(base);
|
|
220
|
+
const owns = new Map(tasks.map((t) => [t.id, filesGlob(t.files.map((f) => f.replace(/^\.\//, "")))]));
|
|
221
|
+
const errors = [];
|
|
222
|
+
for (const task of declaring) {
|
|
223
|
+
const owner = (path) => {
|
|
224
|
+
const others = tasks.filter((t) => t.id !== task.id && owns.get(t.id)(path)).map((t) => t.id);
|
|
225
|
+
return others.length
|
|
226
|
+
? `owned by ${others.join(", ")}, which is no substitute: add it to ${task.id}.files[]`
|
|
227
|
+
: `owned by no task: add it to ${task.id}.files[]`;
|
|
228
|
+
};
|
|
229
|
+
for (const pin of task.pins) {
|
|
230
|
+
const matches = filesGlob(patternsOf(pin));
|
|
231
|
+
const found = [...new Set(patternsOf(pin).map((p) => literalBase(p.replace(/^\.\//, ""))))].map(walked);
|
|
232
|
+
// a cause refuses only a pin that could match at or beneath it: a cycle under fixtures/sub never refuses fixtures/*.txt
|
|
233
|
+
const affects = (c) => (c.maybeFile && matches(c.at)) || patternsOf(pin).some((p) => reachesBeneath(p, c.at));
|
|
234
|
+
const causes = [...new Set(found.flatMap((w) => w.causes).filter(affects).map((c) => c.why))];
|
|
235
|
+
for (const why of causes)
|
|
236
|
+
errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${why}`);
|
|
237
|
+
const refused = causes.length > 0;
|
|
238
|
+
const surface = [...new Set(found.flatMap((w) => w.paths))].filter(matches).sort();
|
|
239
|
+
if (!surface.length) {
|
|
240
|
+
if (refused)
|
|
241
|
+
continue; // the refusal above already names why nothing was certified
|
|
242
|
+
errors.push(`${task.id}: ${pinName(pin)} refuses certification: empty search surface (the pattern matches no file)`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
for (const path of surface) {
|
|
246
|
+
const owned = owns.get(task.id)(path);
|
|
247
|
+
// both kinds certify a whole-file read first: an owned fixture nobody could read is not evidence
|
|
248
|
+
let content;
|
|
249
|
+
try {
|
|
250
|
+
content = readWhole(repoRoot, path, io);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${cause(error)}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (pin.kind === "fixture") {
|
|
257
|
+
if (!owned)
|
|
258
|
+
errors.push(`${task.id}: ${pinName(pin)} obligates ${path}, outside ${task.id}.files[] (${owner(path)})`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const hits = literalHits(path, content, pin.text);
|
|
262
|
+
if (owned)
|
|
263
|
+
continue;
|
|
264
|
+
for (const hit of hits) {
|
|
265
|
+
errors.push(`${task.id}: ${pinName(pin)} still appears at ${hit.path}:${hit.line}, outside ${task.id}.files[] (${owner(path)})`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return errors;
|
|
271
|
+
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -62,6 +62,46 @@ export declare const TierEntrySchema: z.ZodObject<{
|
|
|
62
62
|
windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
63
63
|
}, z.core.$strip>;
|
|
64
64
|
export type TierEntry = z.infer<typeof TierEntrySchema>;
|
|
65
|
+
export declare const DenyBlockSchema: z.ZodObject<{
|
|
66
|
+
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
67
|
+
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
68
|
+
workers: z.ZodOptional<z.ZodObject<{
|
|
69
|
+
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
70
|
+
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
71
|
+
}, z.core.$strip>>;
|
|
72
|
+
}, z.core.$strip>;
|
|
73
|
+
type DenyKeysOf<T, P extends string> = {
|
|
74
|
+
[K in keyof T & string]: NonNullable<T[K]> extends string[] ? `${P}${Capitalize<K>}` : DenyKeysOf<NonNullable<T[K]>, `${P}${Capitalize<K>}`>;
|
|
75
|
+
}[keyof T & string];
|
|
76
|
+
export type DenyScopeKey = DenyKeysOf<z.infer<typeof DenyBlockSchema>, "deny">;
|
|
77
|
+
export type DenyScope = {
|
|
78
|
+
/** the FleetEditable field: deny + the path segments capitalized (denyWorkersModels) */
|
|
79
|
+
key: DenyScopeKey;
|
|
80
|
+
/** the overlay path: ["routing", "deny", ...segments] */
|
|
81
|
+
path: string[];
|
|
82
|
+
/** the dotted config path the exclusion collector reports (routing.deny.workers.models) */
|
|
83
|
+
dotted: string;
|
|
84
|
+
};
|
|
85
|
+
export declare function denyScopesOf(schema: z.ZodObject<z.ZodRawShape>, segments?: string[]): Array<{
|
|
86
|
+
key: string;
|
|
87
|
+
path: string[];
|
|
88
|
+
dotted: string;
|
|
89
|
+
}>;
|
|
90
|
+
export declare const DENY_SCOPES: readonly DenyScope[];
|
|
91
|
+
/** the staged-set key the fleet browser uses for a scope: its key minus the `deny` prefix (denyWorkersAdapters → workersAdapters) */
|
|
92
|
+
export type StagedDenyKey = DenyScopeKey extends `deny${infer Rest}` ? Uncapitalize<Rest> : never;
|
|
93
|
+
export declare const stagedDenyKeyOf: (scope: DenyScope) => StagedDenyKey;
|
|
94
|
+
/** the FleetApp prop that seeds one scope's staged set (initialDenyWorkersAdapters). Lives here, not in
|
|
95
|
+
* fleet-app.tsx, so the fleet command needs no runtime import of the Ink module (the print path must
|
|
96
|
+
* never load Ink, and chalk reads FORCE_COLOR at import time). */
|
|
97
|
+
export type InitialDenyProps = {
|
|
98
|
+
[K in DenyScopeKey as `initial${Capitalize<K>}`]?: string[];
|
|
99
|
+
};
|
|
100
|
+
export declare const initialDenyPropOf: (scope: DenyScope) => keyof InitialDenyProps;
|
|
101
|
+
/** the authored list at one deny scope of a routing block, undefined when the scope is absent */
|
|
102
|
+
export declare function denyEntriesAt(routing: TickmarkrConfig["routing"], scope: DenyScope): string[] | undefined;
|
|
103
|
+
/** a routing.deny block built from per-scope lists (absent or empty lists are left out) */
|
|
104
|
+
export declare function denyBlockFrom(lists: Partial<Record<DenyScopeKey, string[]>>): NonNullable<TickmarkrConfig["routing"]["deny"]>;
|
|
65
105
|
export declare const ModelPricingSchema: z.ZodObject<{
|
|
66
106
|
inPerMtok: z.ZodNumber;
|
|
67
107
|
outPerMtok: z.ZodNumber;
|
|
@@ -371,11 +411,9 @@ export type FleetTierAssignment = {
|
|
|
371
411
|
tier: Tier;
|
|
372
412
|
provenance?: string;
|
|
373
413
|
};
|
|
374
|
-
export type FleetEditable = {
|
|
414
|
+
export type FleetEditable = Partial<Record<DenyScopeKey, string[]>> & {
|
|
375
415
|
denyAdapters: string[];
|
|
376
416
|
denyModels: string[];
|
|
377
|
-
denyWorkersAdapters?: string[];
|
|
378
|
-
denyWorkersModels?: string[];
|
|
379
417
|
allowOut?: string[];
|
|
380
418
|
tiers: Record<string, Record<string, FleetTierAssignment | null>>;
|
|
381
419
|
map: Record<string, MapEntry>;
|
package/dist/config/config.js
CHANGED
|
@@ -87,9 +87,50 @@ const PrefBlockSchema = z.object({
|
|
|
87
87
|
adapters: z.array(z.string()).optional(),
|
|
88
88
|
models: z.array(z.string()).optional(),
|
|
89
89
|
});
|
|
90
|
-
|
|
90
|
+
// exported so a test can extend the PRODUCTION deny shape and watch the enumeration grow (OBS-1099 add.1)
|
|
91
|
+
export const DenyBlockSchema = PrefBlockSchema.extend({
|
|
91
92
|
workers: PrefBlockSchema.optional(),
|
|
92
93
|
});
|
|
94
|
+
export function denyScopesOf(schema, segments = []) {
|
|
95
|
+
const unwrap = (t) => t instanceof z.ZodOptional ? unwrap(t.unwrap()) : t;
|
|
96
|
+
return Object.entries(schema.shape).flatMap(([name, type]) => {
|
|
97
|
+
const inner = unwrap(type);
|
|
98
|
+
const below = [...segments, name];
|
|
99
|
+
if (inner instanceof z.ZodObject)
|
|
100
|
+
return denyScopesOf(inner, below);
|
|
101
|
+
if (!(inner instanceof z.ZodArray))
|
|
102
|
+
return [];
|
|
103
|
+
const path = ["routing", "deny", ...below];
|
|
104
|
+
return [{ key: `deny${below.map((s) => s[0].toUpperCase() + s.slice(1)).join("")}`, path, dotted: path.join(".") }];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export const DENY_SCOPES = denyScopesOf(DenyBlockSchema);
|
|
108
|
+
export const stagedDenyKeyOf = (scope) => (scope.key[4].toLowerCase() + scope.key.slice(5));
|
|
109
|
+
export const initialDenyPropOf = (scope) => `initial${scope.key[0].toUpperCase()}${scope.key.slice(1)}`;
|
|
110
|
+
/** the authored list at one deny scope of a routing block, undefined when the scope is absent */
|
|
111
|
+
export function denyEntriesAt(routing, scope) {
|
|
112
|
+
let cur = routing;
|
|
113
|
+
for (const segment of scope.path.slice(1)) {
|
|
114
|
+
if (cur === undefined || cur === null || typeof cur !== "object")
|
|
115
|
+
return undefined;
|
|
116
|
+
cur = cur[segment];
|
|
117
|
+
}
|
|
118
|
+
return Array.isArray(cur) ? cur : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** a routing.deny block built from per-scope lists (absent or empty lists are left out) */
|
|
121
|
+
export function denyBlockFrom(lists) {
|
|
122
|
+
const block = {};
|
|
123
|
+
for (const scope of DENY_SCOPES) {
|
|
124
|
+
const entries = lists[scope.key];
|
|
125
|
+
if (!entries?.length)
|
|
126
|
+
continue;
|
|
127
|
+
let cur = block;
|
|
128
|
+
for (const segment of scope.path.slice(2, -1))
|
|
129
|
+
cur = (cur[segment] ??= {});
|
|
130
|
+
cur[scope.path[scope.path.length - 1]] = [...entries];
|
|
131
|
+
}
|
|
132
|
+
return block;
|
|
133
|
+
}
|
|
93
134
|
// v1.20 REC-02: operator-maintained price table for the usage/cost report (src/report/cost.ts). Two
|
|
94
135
|
// channel economics, never conflated (spec cost model): API = tokens × per-Mtok rate; sub = flat plan
|
|
95
136
|
// amortized over a windows/month RANGE (+ an API-equivalent counterfactual when the model has a rate).
|
|
@@ -924,8 +965,8 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
924
965
|
tiers[adapter][model] = { tier };
|
|
925
966
|
}
|
|
926
967
|
}
|
|
927
|
-
|
|
928
|
-
const
|
|
968
|
+
// every enumerated deny scope rides verbatim (sorted) under its own key
|
|
969
|
+
const denyLists = Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, [...(denyEntriesAt(cfg.routing, scope) ?? [])].sort()]));
|
|
929
970
|
let allowOut;
|
|
930
971
|
if (universe !== undefined) {
|
|
931
972
|
const { allow } = cfg.routing;
|
|
@@ -947,13 +988,8 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
947
988
|
allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
|
|
948
989
|
}
|
|
949
990
|
return {
|
|
950
|
-
|
|
951
|
-
denyModels,
|
|
991
|
+
...denyLists,
|
|
952
992
|
...(allowOut !== undefined ? { allowOut } : {}),
|
|
953
|
-
// OBS-994: workers-only deny is never folded into the all-seats complement above — it is
|
|
954
|
-
// returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
|
|
955
|
-
denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
|
|
956
|
-
denyWorkersModels: [...(cfg.routing.deny?.workers?.models ?? [])].sort(),
|
|
957
993
|
tiers,
|
|
958
994
|
map: structuredClone(cfg.routing.map),
|
|
959
995
|
floors: { ...cfg.routing.floors },
|
|
@@ -992,14 +1028,9 @@ export function formatFleetPrint(repoRoot, opts = {}) {
|
|
|
992
1028
|
node.comment = ` ${fleetKeyLayer(repoRoot, dotted, opts)}`;
|
|
993
1029
|
}
|
|
994
1030
|
};
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
annotate(["routing", "deny", "models"]);
|
|
999
|
-
if (effective.routing.deny?.workers?.adapters !== undefined)
|
|
1000
|
-
annotate(["routing", "deny", "workers", "adapters"]);
|
|
1001
|
-
if (effective.routing.deny?.workers?.models !== undefined)
|
|
1002
|
-
annotate(["routing", "deny", "workers", "models"]);
|
|
1031
|
+
for (const scope of DENY_SCOPES)
|
|
1032
|
+
if (denyEntriesAt(effective.routing, scope) !== undefined)
|
|
1033
|
+
annotate(scope.path);
|
|
1003
1034
|
for (const shape of Object.keys(effective.routing.map))
|
|
1004
1035
|
annotate(["routing", "map", shape]);
|
|
1005
1036
|
annotate(["routing", "floors"]);
|