tickmarkr 2.5.8 → 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 +15 -3
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +11 -0
- package/dist/cli/commands/fleet.js +61 -53
- 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/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- 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 +4 -1
- package/dist/gates/run-gates.js +21 -11
- package/dist/gates/test-manifest.d.ts +11 -1
- package/dist/gates/test-manifest.js +41 -21
- package/dist/run/daemon.js +84 -41
- package/dist/run/journal.d.ts +17 -2
- package/dist/run/journal.js +99 -11
- package/dist/run/merge.d.ts +13 -2
- package/dist/run/merge.js +74 -12
- 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/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/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +61 -36
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
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"]);
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
|
|
2
2
|
import { isMap, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
|
|
3
|
-
import { universeCovers, universeEntryMatches } from "./config.js";
|
|
3
|
+
import { DENY_SCOPES, universeCovers, universeEntryMatches } from "./config.js";
|
|
4
|
+
// OBS-1099 add.1: the writer ranges over the schema-derived scopes. The flat (all-seats) scopes
|
|
5
|
+
// take part in the membership/allow-form write; every nested scope (routing.deny.workers.*) is a
|
|
6
|
+
// literal deny list written on its own. ponytail: "flat" = directly under routing.deny. Read
|
|
7
|
+
// lazily: config.ts imports this module, so the enumeration is not initialized at load time.
|
|
8
|
+
const flatDenyScopes = () => DENY_SCOPES.filter((scope) => scope.path.length === 3);
|
|
9
|
+
const nestedDenyScopes = () => DENY_SCOPES.filter((scope) => scope.path.length > 3);
|
|
10
|
+
const listsOf = (write, scope) => [write.initial[scope.key] ?? [], write.edited[scope.key] ?? []];
|
|
4
11
|
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
5
12
|
export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
|
|
6
13
|
function fleetSubset(obj) {
|
|
@@ -24,7 +31,7 @@ function allowFormFromExclusions(universe, edited) {
|
|
|
24
31
|
// LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
|
|
25
32
|
// adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
|
|
26
33
|
// OBS-1046: the staged allow complement is a reason of its own, beside the authored deny lists.
|
|
27
|
-
const entries = [...
|
|
34
|
+
const entries = [...flatDenyScopes().flatMap((scope) => edited[scope.key] ?? []), ...(edited.allowOut ?? [])];
|
|
28
35
|
const adapters = [];
|
|
29
36
|
const models = [];
|
|
30
37
|
let excluded = false;
|
|
@@ -190,9 +197,9 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
190
197
|
// OBS-1046: each flat scope (and the allow complement) is compared on its own — a scope the
|
|
191
198
|
// session never edited keeps its node byte for byte, whatever its sibling did.
|
|
192
199
|
const changed = (before = [], after = []) => sortedUnique(before).join() !== sortedUnique(after).join();
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
200
|
+
const FLAT_DENY_SCOPES = flatDenyScopes();
|
|
201
|
+
const flatChanged = new Map(FLAT_DENY_SCOPES.map((scope) => [scope.key, changed(...listsOf(write, scope))]));
|
|
202
|
+
if ([...flatChanged.values()].some(Boolean) || changed(initial.allowOut, edited.allowOut)) {
|
|
196
203
|
if (write.universe) {
|
|
197
204
|
// Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
|
|
198
205
|
// so a lower layer can never re-exclude behind the operator's back (workers untouched).
|
|
@@ -230,15 +237,13 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
230
237
|
// LEG2-T3 finding 4: an authored entry the edit admits (staged nowhere any more) must go
|
|
231
238
|
// even from a scope whose own set did not change, or the admitted channel stays excluded
|
|
232
239
|
// behind the preserved bytes.
|
|
233
|
-
const stagedAfter = new Set([...
|
|
234
|
-
const admitted = [...
|
|
240
|
+
const stagedAfter = new Set([...FLAT_DENY_SCOPES.flatMap((scope) => edited[scope.key] ?? []), ...(edited.allowOut ?? [])]);
|
|
241
|
+
const admitted = [...FLAT_DENY_SCOPES.flatMap((scope) => initial[scope.key] ?? []), ...(initial.allowOut ?? [])]
|
|
235
242
|
.filter((entry) => !stagedAfter.has(entry));
|
|
236
|
-
const
|
|
237
|
-
[
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
for (const [scope, before, after, touched] of scopes) {
|
|
241
|
-
const path = ["routing", "deny", scope];
|
|
243
|
+
for (const scope of FLAT_DENY_SCOPES) {
|
|
244
|
+
const [before, after] = listsOf(write, scope);
|
|
245
|
+
const touched = flatChanged.get(scope.key);
|
|
246
|
+
const path = scope.path;
|
|
242
247
|
const authored = authoredEntries(doc, path);
|
|
243
248
|
const stale = authored.some((entry) => admitted.includes(entry));
|
|
244
249
|
if (!touched && !stale)
|
|
@@ -254,28 +259,24 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
254
259
|
}
|
|
255
260
|
}
|
|
256
261
|
else {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
setStringSequencePreservingComments(doc,
|
|
262
|
+
for (const scope of FLAT_DENY_SCOPES) {
|
|
263
|
+
if (!flatChanged.get(scope.key))
|
|
264
|
+
continue;
|
|
265
|
+
const after = edited[scope.key] ?? [];
|
|
266
|
+
setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
}
|
|
265
270
|
// OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
|
|
266
271
|
// membership scope — it never routes through the allow-complement dance above, in either
|
|
267
|
-
// branch. Same tombstone/comment-preserving rules as the flat scopes.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "adapters"], editedWorkersAdapters.length ? sortedUnique(editedWorkersAdapters) : null);
|
|
276
|
-
}
|
|
277
|
-
if (sortedUnique(initialWorkersModels).join() !== sortedUnique(editedWorkersModels).join()) {
|
|
278
|
-
setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "models"], editedWorkersModels.length ? sortedUnique(editedWorkersModels) : null);
|
|
272
|
+
// branch. Same tombstone/comment-preserving rules as the flat scopes. Each nested scope
|
|
273
|
+
// mutates independently — an untouched sibling must not be rewritten (a `null` tombstone over
|
|
274
|
+
// an absent/untouched sibling would mask a lower layer's own workers scope).
|
|
275
|
+
for (const scope of nestedDenyScopes()) {
|
|
276
|
+
const [before, after] = listsOf(write, scope);
|
|
277
|
+
if (!changed(before, after))
|
|
278
|
+
continue;
|
|
279
|
+
setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
|
|
279
280
|
}
|
|
280
281
|
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
281
282
|
const before = initial.map[shape];
|
|
@@ -420,8 +421,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
|
|
|
420
421
|
const out = structuredClone(existingRepo);
|
|
421
422
|
const routing = { ...out.routing };
|
|
422
423
|
let routingTouched = false;
|
|
423
|
-
|
|
424
|
-
|
|
424
|
+
// OBS-1099 add.1: the flat scopes (routing.deny.<leaf>) and the allow complement are one
|
|
425
|
+
// membership write; a list or its tombstone lands at every flat scope the enumeration names
|
|
426
|
+
const changedAt = (scope) => sortedUnique(initial[scope.key] ?? []).join() !== sortedUnique(edited[scope.key] ?? []).join();
|
|
427
|
+
const listOrTombstone = (entries) => entries.length ? entries : null;
|
|
428
|
+
const denyChanged = flatDenyScopes().some(changedAt)
|
|
425
429
|
|| sortedUnique(initial.allowOut ?? []).join() !== sortedUnique(edited.allowOut ?? []).join();
|
|
426
430
|
if (denyChanged) {
|
|
427
431
|
if (universe) {
|
|
@@ -434,44 +438,25 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
|
|
|
434
438
|
}
|
|
435
439
|
else
|
|
436
440
|
delete routing.allow;
|
|
437
|
-
const residualAdapters = residualDeny(universe, edited.denyAdapters);
|
|
438
|
-
const residualModels = residualDeny(universe, edited.denyModels);
|
|
439
441
|
routing.deny = {
|
|
440
442
|
...routing.deny,
|
|
441
|
-
|
|
442
|
-
models: residualModels.length ? residualModels : null,
|
|
443
|
+
...Object.fromEntries(flatDenyScopes().map((scope) => [scope.path[scope.path.length - 1], listOrTombstone(residualDeny(universe, edited[scope.key] ?? []))])),
|
|
443
444
|
};
|
|
444
445
|
}
|
|
445
446
|
else {
|
|
446
|
-
routing.deny =
|
|
447
|
-
adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
|
|
448
|
-
models: edited.denyModels.length ? edited.denyModels : null,
|
|
449
|
-
};
|
|
447
|
+
routing.deny = Object.fromEntries(flatDenyScopes().map((scope) => [scope.path[scope.path.length - 1], listOrTombstone(edited[scope.key] ?? [])]));
|
|
450
448
|
}
|
|
451
449
|
routingTouched = true;
|
|
452
450
|
}
|
|
453
|
-
// OBS-994/FL-1:
|
|
454
|
-
// Each sub-path is included only when it actually changed — an
|
|
455
|
-
// rewritten as a `null` tombstone over whatever the existing repo
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (workersAdaptersChanged || workersModelsChanged) {
|
|
463
|
-
routing.deny = {
|
|
464
|
-
...routing.deny,
|
|
465
|
-
workers: {
|
|
466
|
-
...(routing.deny?.workers),
|
|
467
|
-
...(workersAdaptersChanged
|
|
468
|
-
? { adapters: editedWorkersAdapters.length ? editedWorkersAdapters : null }
|
|
469
|
-
: {}),
|
|
470
|
-
...(workersModelsChanged
|
|
471
|
-
? { models: editedWorkersModels.length ? editedWorkersModels : null }
|
|
472
|
-
: {}),
|
|
473
|
-
},
|
|
474
|
-
};
|
|
451
|
+
// OBS-994/FL-1: a nested scope (routing.deny.workers.*) is a literal list, independent of the
|
|
452
|
+
// universe/allow dance above. Each sub-path is included only when it actually changed — an
|
|
453
|
+
// untouched sibling must not be rewritten as a `null` tombstone over whatever the existing repo
|
|
454
|
+
// overlay already held.
|
|
455
|
+
for (const scope of nestedDenyScopes().filter(changedAt)) {
|
|
456
|
+
let cur = (routing.deny = { ...routing.deny });
|
|
457
|
+
for (const segment of scope.path.slice(2, -1))
|
|
458
|
+
cur = (cur[segment] = { ...cur[segment] });
|
|
459
|
+
cur[scope.path[scope.path.length - 1]] = listOrTombstone(edited[scope.key] ?? []);
|
|
475
460
|
routingTouched = true;
|
|
476
461
|
}
|
|
477
462
|
// pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EvidenceArtifact, type GateEvidenceReceipt, type ShellReceipt } from "../run/protocol.js";
|
|
1
2
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
2
3
|
import type { AcceptanceItem } from "../graph/schema.js";
|
|
3
4
|
import { type RunCapacity, type ShellOptions, type ShResult } from "../run/git.js";
|
|
@@ -13,8 +14,69 @@ import type { GateResult } from "./types.js";
|
|
|
13
14
|
declare module "./types.js" {
|
|
14
15
|
interface GateResult {
|
|
15
16
|
capacity?: RunCapacity;
|
|
17
|
+
evidenceReceipt?: GateEvidenceReceipt;
|
|
18
|
+
evidenceReceipts?: GateEvidenceReceipt[];
|
|
16
19
|
}
|
|
17
20
|
}
|
|
21
|
+
/** Evidence is observational: the command verdict never depends on this store. */
|
|
22
|
+
export interface GateEvidenceOptions {
|
|
23
|
+
artifactDir?: string;
|
|
24
|
+
runId?: string;
|
|
25
|
+
taskId?: string;
|
|
26
|
+
attempt?: number;
|
|
27
|
+
subjectCommit?: string;
|
|
28
|
+
quotaBytes?: number;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** Injectable storage boundary; mandatory manifest/reporter writes do not use it. */
|
|
31
|
+
write?: (path: string, bytes: Buffer) => void;
|
|
32
|
+
}
|
|
33
|
+
export declare const EVIDENCE_TAIL_BYTES: number;
|
|
34
|
+
export declare const EVIDENCE_RUN_QUOTA_BYTES: number;
|
|
35
|
+
export declare function redactGateOutput(text: string, env: NodeJS.ProcessEnv): string;
|
|
36
|
+
/** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
|
|
37
|
+
export declare function resolveEvidenceArtifact(root: string, ref: EvidenceArtifact): EvidenceArtifact;
|
|
38
|
+
export declare function beginGateEvidence(cwd: string, gate: string, command: string, opts?: GateEvidenceOptions, nonce?: string): {
|
|
39
|
+
root: string;
|
|
40
|
+
history: {
|
|
41
|
+
invocationId: string;
|
|
42
|
+
subject: {
|
|
43
|
+
runId: string;
|
|
44
|
+
taskId: string | null;
|
|
45
|
+
attempt: number | null;
|
|
46
|
+
gate: string;
|
|
47
|
+
subjectCommit: string | null;
|
|
48
|
+
};
|
|
49
|
+
termination: {
|
|
50
|
+
kind: "unknown" | "signal" | "not-started" | "exit" | "timeout";
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
signal: string | null;
|
|
53
|
+
timedOut: boolean | null;
|
|
54
|
+
};
|
|
55
|
+
availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
|
|
56
|
+
redaction: {
|
|
57
|
+
material: boolean;
|
|
58
|
+
};
|
|
59
|
+
stdout: {
|
|
60
|
+
path: string;
|
|
61
|
+
availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
|
|
62
|
+
sha256: string | null;
|
|
63
|
+
retainedBytes: number;
|
|
64
|
+
droppedBytes: number;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
};
|
|
67
|
+
stderr: {
|
|
68
|
+
path: string;
|
|
69
|
+
availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
|
|
70
|
+
sha256: string | null;
|
|
71
|
+
retainedBytes: number;
|
|
72
|
+
droppedBytes: number;
|
|
73
|
+
truncated: boolean;
|
|
74
|
+
};
|
|
75
|
+
nonce?: string | undefined;
|
|
76
|
+
}[];
|
|
77
|
+
observe(receipt: ShellReceipt): void;
|
|
78
|
+
finish(stdout?: string, stderr?: string): GateEvidenceReceipt;
|
|
79
|
+
};
|
|
18
80
|
export interface BaselineCommand {
|
|
19
81
|
/**
|
|
20
82
|
* Absent when the capture returned no verdict — see `infra`. A pre-v1.90 baseline can also lack it
|
|
@@ -66,6 +128,8 @@ export interface BaselineFileDuration {
|
|
|
66
128
|
durationMs: number;
|
|
67
129
|
}
|
|
68
130
|
export interface Baseline {
|
|
131
|
+
/** Observational capture receipts stay outside the forgiveness-bearing command entries. */
|
|
132
|
+
evidenceReceipts?: Record<string, GateEvidenceReceipt>;
|
|
69
133
|
commands: Record<string, BaselineCommand>;
|
|
70
134
|
warnings?: BaselineWarning[];
|
|
71
135
|
}
|
|
@@ -158,6 +222,7 @@ export declare const CAPTURE_CEILING_MS = 1800000;
|
|
|
158
222
|
* count is safe to apply as a deficit floor. A null count compares nothing and is safe as-is. */
|
|
159
223
|
export declare function staleFileCountCommands(baseline: Baseline, commands: Record<string, string>, cwd: string): string[];
|
|
160
224
|
export interface BaselineReceiptOptions {
|
|
225
|
+
evidence?: GateEvidenceOptions;
|
|
161
226
|
onReceipt?: ShellOptions["onReceipt"];
|
|
162
227
|
/** Only comparison's build command may carry task-build identity; capture never does. */
|
|
163
228
|
taskBuildAttribution?: ShellOptions["receiptAttribution"];
|
package/dist/gates/baseline.js
CHANGED
|
@@ -1,9 +1,140 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
|
|
4
|
+
import { availableParallelism, loadavg, tmpdir } from "node:os";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
|
|
5
7
|
import { executionSignal } from "../run/execution-budget.js";
|
|
6
8
|
import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
|
|
9
|
+
export const EVIDENCE_TAIL_BYTES = 16 * 1024;
|
|
10
|
+
export const EVIDENCE_RUN_QUOTA_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
export function redactGateOutput(text, env) {
|
|
12
|
+
// One pass over the original bytes: replacing an environment value must not break a token
|
|
13
|
+
// recognizer, and a short environment value must not rewrite the redaction marker itself.
|
|
14
|
+
// Short operational values (0, true, vi, test) occur throughout ordinary diagnostics.
|
|
15
|
+
// Only secret-named keys justify redacting short values.
|
|
16
|
+
const values = [...new Set(Object.entries(env)
|
|
17
|
+
.filter(([key, value]) => !!value && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
|
|
18
|
+
.map(([, value]) => value))]
|
|
19
|
+
.sort((a, b) => b.length - a.length).map(v => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
20
|
+
const tokens = String.raw `\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`;
|
|
21
|
+
const assignments = String.raw `(?:authorization\s*:\s*bearer|(?:api[_-]?key|token|password|secret)\s*[:=])\s*[^\s"']+`;
|
|
22
|
+
return text.replace(new RegExp([tokens, assignments, ...values].join("|"), "gi"), "[REDACTED]");
|
|
23
|
+
}
|
|
24
|
+
/** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
|
|
25
|
+
export function resolveEvidenceArtifact(root, ref) {
|
|
26
|
+
if (ref.availability !== "available")
|
|
27
|
+
return ref;
|
|
28
|
+
if (ref.path.startsWith("/") || ref.path.includes("\\") || ref.path.split("/").includes(".."))
|
|
29
|
+
return { ...ref, availability: "missing" };
|
|
30
|
+
try {
|
|
31
|
+
const bytes = readFileSync(join(root, ref.path));
|
|
32
|
+
return bytes.length === ref.retainedBytes && createHash("sha256").update(bytes).digest("hex") === ref.sha256
|
|
33
|
+
? ref : { ...ref, availability: "missing" };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { ...ref, availability: "missing" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
|
|
40
|
+
let invocationId = nonce ?? randomUUID();
|
|
41
|
+
let artifactId = /^[a-zA-Z0-9-]+$/.test(invocationId) ? invocationId : createHash("sha256").update(invocationId).digest("hex");
|
|
42
|
+
let subjectCommit = opts.subjectCommit ?? null;
|
|
43
|
+
if (!subjectCommit) {
|
|
44
|
+
try {
|
|
45
|
+
subjectCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
46
|
+
}
|
|
47
|
+
catch { /* unknown subject */ }
|
|
48
|
+
}
|
|
49
|
+
const root = opts.artifactDir ?? join(tmpdir(), "tickmarkr-gate-evidence", createHash("sha256").update(cwd).digest("hex"));
|
|
50
|
+
const env = { ...process.env, ...opts.env };
|
|
51
|
+
for (const match of command.matchAll(/(?:^|\s)[A-Za-z_][A-Za-z_0-9]*=(?:'([^']*)'|"([^"$]*)"|([^\s;&|]+))/g)) {
|
|
52
|
+
env[`assignment_${match.index}`] = match[1] ?? match[2] ?? match[3];
|
|
53
|
+
}
|
|
54
|
+
let observed;
|
|
55
|
+
let pendingId;
|
|
56
|
+
const history = [];
|
|
57
|
+
return {
|
|
58
|
+
root, history,
|
|
59
|
+
observe(receipt) {
|
|
60
|
+
if (pendingId) {
|
|
61
|
+
history.push(this.finish());
|
|
62
|
+
invocationId = pendingId;
|
|
63
|
+
artifactId = pendingId;
|
|
64
|
+
pendingId = undefined;
|
|
65
|
+
}
|
|
66
|
+
observed = receipt;
|
|
67
|
+
// A shell-internal pre-spawn retry receives an id before it executes too. Keep the failed
|
|
68
|
+
// attempt current until a later observation proves that the shell actually tried again.
|
|
69
|
+
if (receipt.outcome === "spawn-failed")
|
|
70
|
+
pendingId = randomUUID();
|
|
71
|
+
},
|
|
72
|
+
finish(stdout = "", stderr = "") {
|
|
73
|
+
const termination = {
|
|
74
|
+
kind: !observed ? "unknown" : !observed.confirmedStart ? "not-started" : observed.outcome === "timed-out" ? "timeout" : observed.signal ? "signal" : observed.exitCode != null ? "exit" : "unknown",
|
|
75
|
+
exitCode: observed?.exitCode ?? null, signal: observed?.signal ?? null,
|
|
76
|
+
timedOut: observed ? observed.outcome === "timed-out" : null,
|
|
77
|
+
};
|
|
78
|
+
const clean = [redactGateOutput(stdout, env), redactGateOutput(stderr, env)];
|
|
79
|
+
const refs = clean.map((text, i) => {
|
|
80
|
+
const bytes = Buffer.from(text);
|
|
81
|
+
const tail = bytes.subarray(-EVIDENCE_TAIL_BYTES);
|
|
82
|
+
return { path: `gate-evidence/${artifactId}-${i === 0 ? "stdout" : "stderr"}.log`,
|
|
83
|
+
availability: termination.kind === "not-started" ? "not-started" : "available",
|
|
84
|
+
sha256: termination.kind === "not-started" ? null : createHash("sha256").update(tail).digest("hex"), retainedBytes: tail.length,
|
|
85
|
+
droppedBytes: bytes.length - tail.length, truncated: bytes.length > tail.length };
|
|
86
|
+
});
|
|
87
|
+
let availability = termination.kind === "not-started" ? "not-started" : "available";
|
|
88
|
+
if (availability === "available") {
|
|
89
|
+
let lock;
|
|
90
|
+
try {
|
|
91
|
+
const dir = join(root, "gate-evidence");
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
mkdirSync(join(dir, ".write-lock"));
|
|
94
|
+
lock = join(dir, ".write-lock");
|
|
95
|
+
const quota = opts.quotaBytes !== undefined && Number.isFinite(opts.quotaBytes)
|
|
96
|
+
? Math.max(0, Math.floor(opts.quotaBytes)) : EVIDENCE_RUN_QUOTA_BYTES;
|
|
97
|
+
const files = readdirSync(dir).filter(f => /^[a-zA-Z0-9-]+-(stdout|stderr)\.log$/.test(f)).map(f => ({ path: join(dir, f), stat: statSync(join(dir, f)) }))
|
|
98
|
+
.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.path.localeCompare(b.path));
|
|
99
|
+
let total = files.reduce((sum, f) => sum + f.stat.size, 0);
|
|
100
|
+
const incoming = refs.reduce((sum, ref) => sum + ref.retainedBytes, 0);
|
|
101
|
+
if (incoming > quota)
|
|
102
|
+
throw new Error("evidence quota cannot retain this invocation");
|
|
103
|
+
while (files.length && total + incoming > quota) {
|
|
104
|
+
const oldest = files.shift();
|
|
105
|
+
unlinkSync(oldest.path);
|
|
106
|
+
total -= oldest.stat.size;
|
|
107
|
+
}
|
|
108
|
+
for (const [i, ref] of refs.entries()) {
|
|
109
|
+
(opts.write ?? writeFileSync)(join(root, ref.path), Buffer.from(clean[i]).subarray(-EVIDENCE_TAIL_BYTES));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
availability = "capture-failed";
|
|
114
|
+
for (const ref of refs) {
|
|
115
|
+
ref.availability = "capture-failed";
|
|
116
|
+
ref.sha256 = null;
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(join(root, ref.path));
|
|
119
|
+
}
|
|
120
|
+
catch { /* best effort cleanup */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (lock) {
|
|
125
|
+
try {
|
|
126
|
+
rmdirSync(lock);
|
|
127
|
+
}
|
|
128
|
+
catch { /* observational cleanup */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { invocationId, ...(nonce ? { nonce } : {}),
|
|
133
|
+
subject: { runId: opts.runId ?? "standalone", taskId: opts.taskId ?? null, attempt: opts.attempt ?? null, gate, subjectCommit },
|
|
134
|
+
termination, availability, redaction: { material: clean[0] !== stdout || clean[1] !== stderr }, stdout: refs[0], stderr: refs[1] };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
7
138
|
// incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
|
|
8
139
|
// codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
|
|
9
140
|
// a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
|
|
@@ -493,7 +624,9 @@ export async function captureBaseline(cwd, commands, opts = {}) {
|
|
|
493
624
|
if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
|
|
494
625
|
continue;
|
|
495
626
|
}
|
|
496
|
-
const
|
|
627
|
+
const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
|
|
628
|
+
const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { env: opts.evidence?.env, onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); } });
|
|
629
|
+
(base.evidenceReceipts ??= {})[name] = evidence.finish(r.stdout, r.stderr);
|
|
497
630
|
// ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
|
|
498
631
|
// ponytail: a capture that was itself killed records the ceiling as its "measurement", which
|
|
499
632
|
// scales the next ceiling up — the right direction for a suite that never finished once.
|
|
@@ -634,15 +767,36 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
634
767
|
}
|
|
635
768
|
const entry = baseline.commands[name];
|
|
636
769
|
const ceilingMs = effectiveCeilingMs(entry);
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
770
|
+
const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
|
|
771
|
+
let r;
|
|
772
|
+
try {
|
|
773
|
+
r = await sh(cmd, cwd, ceilingMs, {
|
|
774
|
+
env: opts.evidence?.env,
|
|
775
|
+
onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); },
|
|
776
|
+
...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
const evidenceReceipt = evidence.finish();
|
|
781
|
+
// A launch refusal is infrastructure; cancellation and other thrown control flow still unwind.
|
|
782
|
+
if (evidenceReceipt.termination.kind !== "not-started" || executionSignal()?.aborted)
|
|
783
|
+
throw error;
|
|
784
|
+
results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${String(error)}`,
|
|
785
|
+
meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const evidenceReceipt = evidence.finish(r.stdout, r.stderr);
|
|
789
|
+
if (evidenceReceipt.termination.kind === "not-started") {
|
|
790
|
+
results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${r.stderr}`,
|
|
791
|
+
meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
641
794
|
// T7: every verdict below carries the capacity ITS OWN command ran under, taken off the shell
|
|
642
795
|
// result rather than re-derived after the fact. The skip row above ran no command and therefore
|
|
643
796
|
// states no capacity — a row that never divided the machine must not claim that it did.
|
|
644
797
|
let recoveryBlocked;
|
|
645
798
|
const record = (g) => {
|
|
799
|
+
g = { ...g, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] };
|
|
646
800
|
if (recoveryBlocked)
|
|
647
801
|
g = { ...g, meta: { ...g.meta, recoveryBlocked } };
|
|
648
802
|
const withReap = r.reapedGroup ? { ...g, meta: { ...g.meta, reapedGroup: true } } : g;
|
|
@@ -735,7 +889,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
735
889
|
}
|
|
736
890
|
else {
|
|
737
891
|
const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
|
|
738
|
-
results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance }));
|
|
892
|
+
results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
|
|
739
893
|
continue;
|
|
740
894
|
}
|
|
741
895
|
}
|
|
@@ -754,7 +908,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
754
908
|
// Compatibility with OBS-896, not an infrastructure reclassification: a persistent
|
|
755
909
|
// timeout remains the ordinary conservative verdict after the single bounded remeasure.
|
|
756
910
|
const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
|
|
757
|
-
results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance }));
|
|
911
|
+
results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
|
|
758
912
|
continue;
|
|
759
913
|
}
|
|
760
914
|
}
|
package/dist/gates/review.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface ReviewFinding {
|
|
|
14
14
|
severity: ReviewSeverity;
|
|
15
15
|
defer?: boolean;
|
|
16
16
|
rationale?: string;
|
|
17
|
+
/** Copy an id from the top-level reraised list to identify this restatement. */
|
|
18
|
+
reraised?: string;
|
|
17
19
|
}
|
|
18
20
|
export interface ReviewVerdict {
|
|
19
21
|
approve?: boolean;
|
|
@@ -58,19 +60,19 @@ export { modelProvider };
|
|
|
58
60
|
* comparing both sides with every whitespace run removed (`s.replace(/\s+/g, "")`).
|
|
59
61
|
*/
|
|
60
62
|
export declare function matchClosureId(candidate: unknown, fingerprint: string): boolean;
|
|
61
|
-
export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string>): string | undefined;
|
|
63
|
+
export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string | StructuredFinding>): string | undefined;
|
|
62
64
|
/**
|
|
63
65
|
* Validates closure ids in a review verdict: membership, duplication, and coverage of every prior id
|
|
64
66
|
* all route through matchClosureId.
|
|
65
67
|
*/
|
|
66
|
-
export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
|
|
68
|
+
export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
|
|
67
69
|
/**
|
|
68
70
|
* OBS-1013 add.3: the reviewer ECHOED closure ids and at least one matches no carried fingerprint —
|
|
69
71
|
* it answered about the materials and missed the id (a retyped, truncated or paraphrased fingerprint).
|
|
70
72
|
* That is a no-verdict about the carried work (re-route), not a parse defect. A verdict that omits a
|
|
71
73
|
* list, carries a non-string or duplicates an id stays malformed: its shape, not its ids, is wrong.
|
|
72
74
|
*/
|
|
73
|
-
export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
|
|
75
|
+
export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
|
|
74
76
|
export type ReviewerFloorCause = "author-tier" | "task-floor" | "config" | "prior-reviewer";
|
|
75
77
|
/**
|
|
76
78
|
* RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
|
|
@@ -134,4 +136,4 @@ export declare function renderGoalSection(goal: string, repoRoot?: string): stri
|
|
|
134
136
|
* closure on a typo and that read as malformed — the block is what a closure list is copied from.
|
|
135
137
|
*/
|
|
136
138
|
export declare function renderPriorMaterials(priorMaterials: readonly StructuredFinding[]): string;
|
|
137
|
-
export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[]): Promise<GateResult>;
|
|
139
|
+
export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[], operatorContext?: string): Promise<GateResult>;
|