tickmarkr 2.1.6 → 2.1.8
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/cli/commands/beat.js +37 -10
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +23 -1
- package/dist/cli/commands/plan.d.ts +6 -2
- package/dist/cli/commands/plan.js +94 -1
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/stats.js +4 -14
- package/dist/cli/commands/status.js +73 -26
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +24 -5
- package/dist/compile/native.js +14 -0
- package/dist/compile/ownership.d.ts +35 -0
- package/dist/compile/ownership.js +213 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +7 -6
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +54 -14
- package/dist/gates/acceptance.d.ts +13 -0
- package/dist/gates/acceptance.js +43 -16
- package/dist/gates/baseline.js +12 -1
- package/dist/gates/run-gates.js +26 -23
- package/dist/run/daemon.js +42 -7
- package/dist/run/journal.js +21 -1
- package/dist/run/outcome.js +8 -1
- package/dist/run/supervision.d.ts +15 -1
- package/dist/run/supervision.js +112 -14
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -1
- package/skills/tickmarkr-loop/SKILL.md +2 -1
- package/skills/tickmarkr-overseer/SKILL.md +9 -0
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +46 -32
package/dist/gates/run-gates.js
CHANGED
|
@@ -176,11 +176,11 @@ async function coveringTests(worktree, baseRef) {
|
|
|
176
176
|
}
|
|
177
177
|
const covering = tests.filter((t) => reachOf(t).has(file));
|
|
178
178
|
if (!covering.length)
|
|
179
|
-
|
|
179
|
+
continue; // nothing covers this file — keep every attributable selection already accumulated
|
|
180
180
|
for (const t of covering)
|
|
181
181
|
selected.add(t);
|
|
182
182
|
}
|
|
183
|
-
return [...selected].sort();
|
|
183
|
+
return selected.size ? [...selected].sort() : undefined;
|
|
184
184
|
}
|
|
185
185
|
/**
|
|
186
186
|
* The configured test command narrowed to these files. Mirrors testFiltered's `--` rule (acceptance.ts:104):
|
|
@@ -287,7 +287,7 @@ export async function runGates(task, ctx) {
|
|
|
287
287
|
}
|
|
288
288
|
return { results: sorted, commits };
|
|
289
289
|
};
|
|
290
|
-
const toolGates = ["build", "
|
|
290
|
+
const toolGates = ["build", "lint"].filter(enabled);
|
|
291
291
|
/**
|
|
292
292
|
* v1.87 T5: the shell gates run their commands against the WORKING TREE, while evidence, scope,
|
|
293
293
|
* the judged diff and the merge all read COMMITS. Uncommitted work is therefore visible to
|
|
@@ -337,28 +337,28 @@ export async function runGates(task, ctx) {
|
|
|
337
337
|
+ `Uncommitted at round end:\n${dirt}`,
|
|
338
338
|
meta: { dirtyWorktree: true, dirtyAtRoundEnd: true },
|
|
339
339
|
});
|
|
340
|
-
//
|
|
341
|
-
const runBattery = async (commands, selected) => {
|
|
342
|
-
if (!
|
|
340
|
+
// shell tools vs the shared baseline
|
|
341
|
+
const runBattery = async (commands, selected, gates = toolGates) => {
|
|
342
|
+
if (!gates.length)
|
|
343
343
|
return;
|
|
344
344
|
if (!v185) {
|
|
345
|
-
// ponytail: compareToBaseline batches
|
|
345
|
+
// ponytail: compareToBaseline batches adjacent tools — their starts are emitted at iteration,
|
|
346
346
|
// not at true execution start. They are collectively sub-second (measured), so the debounce
|
|
347
347
|
// suppresses them anyway; split compareToBaseline only if a tool gate ever gets slow.
|
|
348
|
-
// ponytail: legacy runs
|
|
348
|
+
// ponytail: legacy runs adjacent tools in ONE compareToBaseline call, so there is one interval
|
|
349
349
|
// to measure and each of its gates carries it. Split it only if this branch ever stops batching.
|
|
350
350
|
const batchAt = Date.now();
|
|
351
351
|
const batchLoadStart = loadProvider();
|
|
352
|
-
const toolResults = await compareToBaseline(ctx.worktree, commands, ctx.baseline,
|
|
352
|
+
const toolResults = await compareToBaseline(ctx.worktree, commands, ctx.baseline, [...gates]);
|
|
353
353
|
const batch = { durationMs: Date.now() - batchAt, load1Start: batchLoadStart, load1End: loadProvider() };
|
|
354
|
-
for (const g of
|
|
354
|
+
for (const g of gates)
|
|
355
355
|
spans.set(g, batch);
|
|
356
356
|
// The same refusal AFTER the commands, because a green command can dirty the tree the check
|
|
357
357
|
// above just proved clean. Batched, legacy cannot say WHICH command did it, so the refusal
|
|
358
358
|
// lands on the last gate that had one — the round dies there either way. A red battery is
|
|
359
359
|
// reported as the red it is: the round already ends, and the command output is the better lead.
|
|
360
360
|
const dirt = toolResults.every((r) => r.pass) ? await dirtyWorktree() : undefined;
|
|
361
|
-
const blame = dirt ? [...
|
|
361
|
+
const blame = dirt ? [...gates].reverse().find((g) => commands[g]) : undefined;
|
|
362
362
|
for (const r of toolResults) {
|
|
363
363
|
await emitStart(r.gate);
|
|
364
364
|
await record(r.gate === blame ? dirtyRefusal(blame, dirt, commands[blame]) : r);
|
|
@@ -366,8 +366,8 @@ export async function runGates(task, ctx) {
|
|
|
366
366
|
return;
|
|
367
367
|
}
|
|
368
368
|
// T4 (OBS-265): one command at a time, stopping at the first red — a failed build no longer buys
|
|
369
|
-
//
|
|
370
|
-
for (const g of
|
|
369
|
+
// any later tool before anyone reads its verdict.
|
|
370
|
+
for (const g of gates) {
|
|
371
371
|
await emitStart(g);
|
|
372
372
|
const [r] = await measure(g, () => compareToBaseline(ctx.worktree, commands, ctx.baseline, [g]));
|
|
373
373
|
// the screen's interval IS the test gate's first interval, so the split needs no second clock
|
|
@@ -437,9 +437,9 @@ export async function runGates(task, ctx) {
|
|
|
437
437
|
* screen IS the round's verdict: what it produced is journaled (in the order it ran) and the round
|
|
438
438
|
* ends there, so a drive-by out-of-scope edit costs <1s instead of the whole battery.
|
|
439
439
|
*
|
|
440
|
-
* A green screen changes nothing downstream. The
|
|
441
|
-
*
|
|
442
|
-
*
|
|
440
|
+
* A green screen changes nothing downstream. The returned record stays in GATE_NAMES order while
|
|
441
|
+
* the event stream reports the order gates actually ran; resume's GATE_NAMES walk over satisfied
|
|
442
|
+
* records therefore keeps declaration order without making the live stream lie about execution.
|
|
443
443
|
*
|
|
444
444
|
* ponytail: the price of that is re-reading two git checks (~40ms) in their canonical positions
|
|
445
445
|
* rather than teaching every consumer of the gate stream a second order. Both reads see the same
|
|
@@ -447,7 +447,7 @@ export async function runGates(task, ctx) {
|
|
|
447
447
|
* for. Charge it only when there IS a battery command to protect.
|
|
448
448
|
*/
|
|
449
449
|
const screenBlocks = async () => {
|
|
450
|
-
if (!toolGates.some((g) => ctx.commands[g]))
|
|
450
|
+
if (!toolGates.some((g) => ctx.commands[g]) && !(enabled("test") && ctx.commands.test))
|
|
451
451
|
return false;
|
|
452
452
|
const screened = [];
|
|
453
453
|
for (const [gate, compute] of [["evidence", evidenceResult], ["scope", scopeResult]]) {
|
|
@@ -626,12 +626,7 @@ export async function runGates(task, ctx) {
|
|
|
626
626
|
}
|
|
627
627
|
if (v185 && await screenBlocks())
|
|
628
628
|
return done();
|
|
629
|
-
|
|
630
|
-
// pays the full suite anyway, so a selection that misses costs a round and can never merge.
|
|
631
|
-
const selected = v185 && ctx.selectTests && enabled("test") && ctx.commands.test
|
|
632
|
-
? await coveringTests(ctx.worktree, ctx.baseRef)
|
|
633
|
-
: undefined;
|
|
634
|
-
await runBattery(selected ? { ...ctx.commands, test: testCommandForFiles(ctx.commands.test, selected) } : ctx.commands, selected);
|
|
629
|
+
await runBattery(ctx.commands);
|
|
635
630
|
if (failed())
|
|
636
631
|
return done();
|
|
637
632
|
if (enabled("evidence")) {
|
|
@@ -644,6 +639,14 @@ export async function runGates(task, ctx) {
|
|
|
644
639
|
if (failed())
|
|
645
640
|
return done();
|
|
646
641
|
}
|
|
642
|
+
// A non-final round may run only the tests covering its own diff; the merge-candidate round below
|
|
643
|
+
// pays the full suite anyway, so a selection that misses costs a round and can never merge.
|
|
644
|
+
const selected = v185 && ctx.selectTests && enabled("test") && ctx.commands.test
|
|
645
|
+
? await coveringTests(ctx.worktree, ctx.baseRef)
|
|
646
|
+
: undefined;
|
|
647
|
+
await runBattery(selected ? { ...ctx.commands, test: testCommandForFiles(ctx.commands.test, selected) } : ctx.commands, selected, enabled("test") ? ["test"] : []);
|
|
648
|
+
if (failed())
|
|
649
|
+
return done();
|
|
647
650
|
if (v185 && (enabled("acceptance") || enabled("review"))) {
|
|
648
651
|
// Judge and review are launched TOGETHER (96m of serialization over 5 runs). Enforcement is
|
|
649
652
|
// unchanged — it is still the AND of both, both still fail closed, and neither reads the other's
|
package/dist/run/daemon.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { shq } from "../adapters/types.js";
|
|
3
|
-
import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { stringify } from "yaml";
|
|
@@ -15,7 +15,7 @@ import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js
|
|
|
15
15
|
import { formatOwnedName } from "../drivers/types.js";
|
|
16
16
|
import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
|
|
17
17
|
import { runGates } from "../gates/run-gates.js";
|
|
18
|
-
import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest } from "../graph/graph.js";
|
|
18
|
+
import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
19
19
|
import { GATE_NAMES } from "../graph/schema.js";
|
|
20
20
|
import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
|
|
21
21
|
import { runEnvironment } from "./environment.js";
|
|
@@ -23,7 +23,7 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallComm
|
|
|
23
23
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
24
24
|
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
25
25
|
import { isDiffCapPark } from "../gates/review.js";
|
|
26
|
-
import { acquireApprovalSerialization, acquireRunLock, releaseRunLock } from "./lock.js";
|
|
26
|
+
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
27
27
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
28
28
|
import { nextChannel, route } from "../route/router.js";
|
|
29
29
|
import { desiredPanes } from "./reconcile.js";
|
|
@@ -764,6 +764,37 @@ function recordFatalRunEnd(journal, runId, branch, err) {
|
|
|
764
764
|
console.error(`tickmarkr ${runId}: run crashed — no terminal record written; journal sink unwritable at ${journalPath} (${retryErr instanceof Error ? retryErr.message : String(retryErr)}) — original error: ${original}`);
|
|
765
765
|
}
|
|
766
766
|
}
|
|
767
|
+
// OBS-777: the fleet fold cannot distinguish an orphan from another repository's live worker. Give
|
|
768
|
+
// it one repository-scoped snapshot instead: terminal journals are ended, and an open lifecycle is
|
|
769
|
+
// ended only when the shared lock predicate can prove its last recorded daemon pid dead.
|
|
770
|
+
function endedRunIdsForReconcile(repoRoot) {
|
|
771
|
+
const ended = new Set();
|
|
772
|
+
const dir = join(tickmarkrDir(repoRoot), "runs");
|
|
773
|
+
if (!existsSync(dir))
|
|
774
|
+
return ended;
|
|
775
|
+
let runIds;
|
|
776
|
+
try {
|
|
777
|
+
runIds = readdirSync(dir);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return ended;
|
|
781
|
+
}
|
|
782
|
+
for (const runId of runIds) {
|
|
783
|
+
try {
|
|
784
|
+
const events = Journal.open(repoRoot, runId).read();
|
|
785
|
+
if (runHasEnded(events)) {
|
|
786
|
+
ended.add(runId);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const lifecycle = [...events].reverse().find((event) => (event.event === "run-start" || event.event === "run-resume")
|
|
790
|
+
&& Number.isInteger(event.data.pid) && event.data.pid > 0);
|
|
791
|
+
if (lifecycle && !isPidLive(lifecycle.data.pid))
|
|
792
|
+
ended.add(runId);
|
|
793
|
+
}
|
|
794
|
+
catch { /* incomplete or foreign run directory — it proves nothing */ }
|
|
795
|
+
}
|
|
796
|
+
return ended;
|
|
797
|
+
}
|
|
767
798
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
768
799
|
// v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
|
|
769
800
|
// route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
|
|
@@ -781,6 +812,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
781
812
|
// subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
|
|
782
813
|
// swallowed (never affects the run); the operator closes a surviving watch pane.
|
|
783
814
|
const lock = acquireRunLock(repoRoot, runId);
|
|
815
|
+
const endedRunIds = endedRunIdsForReconcile(repoRoot); // one snapshot for every sweep this caller makes
|
|
784
816
|
// D10: the lock is this daemon's liveness record and already carries its pid; status prints that
|
|
785
817
|
// identity beside the supervision row. The `orchestrator` tier belongs exclusively to the seated
|
|
786
818
|
// supervisor, so a run never beats or stands down that seat's record on the daemon's behalf.
|
|
@@ -878,7 +910,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
878
910
|
// Termination (SIGINT/SIGTERM): record the daemon-controlled exit before closing every live slot,
|
|
879
911
|
// reconcile owned panes against an EMPTY
|
|
880
912
|
// desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
|
|
881
|
-
// other runs' panes by construction), release the run lock, then exit. There is still no run-end,
|
|
913
|
+
// live/unknown other runs' panes by construction), release the run lock, then exit. There is still no run-end,
|
|
882
914
|
// so stop-amend-resume keeps resuming; exit-cause distinguishes this deliberate stop from an
|
|
883
915
|
// observer-classified abrupt death. keepPanes:"forever" (the
|
|
884
916
|
// keep-everything debug override) preserves panes but still releases the lock and exits.
|
|
@@ -907,7 +939,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
907
939
|
catch { /* cosmetic — reconcile is the backstop */ }
|
|
908
940
|
}
|
|
909
941
|
try {
|
|
910
|
-
await driver.reconcile?.(new Set(), runId);
|
|
942
|
+
await driver.reconcile?.(new Set(), runId, { endedRunIds });
|
|
911
943
|
}
|
|
912
944
|
catch { /* cosmetic — visibility is never a gate */ }
|
|
913
945
|
}
|
|
@@ -1093,7 +1125,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1093
1125
|
// herdr's watches bookkeeping lives in close(), and a raw pane-close in the sweep would
|
|
1094
1126
|
// leave narrator() a stale cache) — the driver always sees it as desired; its lifecycle is
|
|
1095
1127
|
// decided here from the fold alone.
|
|
1096
|
-
await driver.reconcile?.(new Set([...desired, watchName]), runId, opts);
|
|
1128
|
+
await driver.reconcile?.(new Set([...desired, watchName]), runId, { ...opts, endedRunIds });
|
|
1097
1129
|
// OBS-103: when the fold retires the watch (run-end boundary), close the narrator. The
|
|
1098
1130
|
// decision keys on the run identity in the pane name — narrator() adopts a prior daemon
|
|
1099
1131
|
// instance's pane under the same owned name, so a stop→resume cycle's leftover narrator
|
|
@@ -1109,7 +1141,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1109
1141
|
/* cosmetic — visibility is never a gate */
|
|
1110
1142
|
}
|
|
1111
1143
|
};
|
|
1112
|
-
|
|
1144
|
+
// run start/resume boundary: nothing in flight, so the sweep takes this run's judge/review/consult
|
|
1145
|
+
// panes too. OBS-777: the one startup snapshot lets it reclaim only older runs proven ended, while
|
|
1146
|
+
// every live or unknown run remains spared.
|
|
1147
|
+
await reconcile();
|
|
1113
1148
|
// v1.4 self-reference guard: a random nonce on the worker trailer AND exit marker. Displayed
|
|
1114
1149
|
// source/diffs (e.g. a worker editing tickmarkr's own prompt.ts/daemon.ts) can't know it, so an echoed
|
|
1115
1150
|
// TICKMARKR_RESULT/TICKMARKR_EXIT literal can never premature-harvest the worker. Quote-split keeps the
|
package/dist/run/journal.js
CHANGED
|
@@ -7,6 +7,7 @@ import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js
|
|
|
7
7
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
8
|
import { buildProfile, classify } from "../route/profile.js";
|
|
9
9
|
import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
|
|
10
|
+
import { normalizeGateOutcome } from "./outcome.js";
|
|
10
11
|
import { redactSecrets } from "./redact.js";
|
|
11
12
|
export function phaseForGate(gate) {
|
|
12
13
|
if (gate === "acceptance")
|
|
@@ -15,6 +16,23 @@ export function phaseForGate(gate) {
|
|
|
15
16
|
return "review";
|
|
16
17
|
return `gate:${gate}`;
|
|
17
18
|
}
|
|
19
|
+
/** The pipe projects the same normalized gate outcome as the TTY rail. The selected-test field only
|
|
20
|
+
* chooses the held outcome's operator noun after the accessor has classified it; it is not a second
|
|
21
|
+
* verdict read. */
|
|
22
|
+
const journalGateDetail = (data) => {
|
|
23
|
+
if (typeof data.gate !== "string")
|
|
24
|
+
return undefined;
|
|
25
|
+
switch (normalizeGateOutcome(data).kind) {
|
|
26
|
+
case "passed":
|
|
27
|
+
return `${data.gate} passed`;
|
|
28
|
+
case "failed":
|
|
29
|
+
return `${data.gate} failed`;
|
|
30
|
+
case "held":
|
|
31
|
+
return Array.isArray(data.selectedTests) ? `${data.gate} selected-test screen` : `${data.gate} held`;
|
|
32
|
+
default:
|
|
33
|
+
return data.gate;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
18
36
|
export function formatJournalNarration({ event, taskId, data }) {
|
|
19
37
|
const assignment = data.assignment;
|
|
20
38
|
const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
|
|
@@ -26,7 +44,9 @@ export function formatJournalNarration({ event, taskId, data }) {
|
|
|
26
44
|
? `${data.gate} failed${typeof data.lastMergedTask === "string" ? ` after ${data.lastMergedTask}` : ""}`
|
|
27
45
|
: event === "tip-verify"
|
|
28
46
|
? `${data.gate} passed`
|
|
29
|
-
:
|
|
47
|
+
: event === "gate-result"
|
|
48
|
+
? journalGateDetail(data)
|
|
49
|
+
: `${data.gate}`
|
|
30
50
|
: typeof data.code === "number" ? `exit ${data.code}`
|
|
31
51
|
: typeof data.pid === "number" ? `pid ${data.pid}`
|
|
32
52
|
: typeof data.baseRef === "string" ? `base ${data.baseRef.slice(0, 12)}`
|
package/dist/run/outcome.js
CHANGED
|
@@ -88,7 +88,14 @@ export function normalizeGateOutcome(source) {
|
|
|
88
88
|
return { kind: "unavailable", reason: `malformed gate result: expected an object, read ${shape}` };
|
|
89
89
|
}
|
|
90
90
|
const row = source;
|
|
91
|
-
//
|
|
91
|
+
// A gate-result data wrapper states its canonical verdict in `outcome`. Consumers may pass either
|
|
92
|
+
// the wrapper or the outcome itself; both must read the same kind even if stale bare fields remain.
|
|
93
|
+
if (hasOwn(row, "outcome")) {
|
|
94
|
+
if (isGateOutcome(row.outcome))
|
|
95
|
+
return row.outcome;
|
|
96
|
+
return { kind: "unavailable", reason: reasonOf(row, "malformed gate result: invalid explicit outcome") };
|
|
97
|
+
}
|
|
98
|
+
// `kind` selects the canonical outcome object. It must validate as exactly one canonical arm without any
|
|
92
99
|
// legacy truth discriminator; otherwise continuing through the legacy branches could reinterpret a
|
|
93
100
|
// contradictory dual-write as passed, failed or infra. Mixed and malformed canonical rows stay
|
|
94
101
|
// explicitly unavailable, retaining any reason/details the producer did manage to state.
|
|
@@ -3,6 +3,8 @@ export declare const SUPERVISION_BEAT_MS = 10000;
|
|
|
3
3
|
/** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
|
|
4
4
|
export declare const SUPERVISION_STALE_MS: number;
|
|
5
5
|
export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
|
|
6
|
+
/** Context watchers act at 75% unless their invocation declares another threshold. */
|
|
7
|
+
export declare const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
|
|
6
8
|
export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
|
|
7
9
|
export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
|
|
8
10
|
export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
|
|
@@ -17,16 +19,28 @@ export interface TierLiveness {
|
|
|
17
19
|
beatAgeMs?: number;
|
|
18
20
|
/** The seat the record names, when it names one. Always present on a seat tier that is not ABSENT. */
|
|
19
21
|
seat?: string;
|
|
22
|
+
/** The alarm's durable duty, orthogonal to whether its watcher is armed, stale or stood down. */
|
|
23
|
+
clearOwedSince?: string;
|
|
24
|
+
/** The latch exists but cannot be decoded; never mistaken for no duty owed. */
|
|
25
|
+
clearOwedUnreadable?: true;
|
|
26
|
+
}
|
|
27
|
+
/** A watcher observation that can raise or discharge the seat's clear obligation. */
|
|
28
|
+
export interface SupervisionBeatObservation {
|
|
29
|
+
armId: string;
|
|
30
|
+
pct: number;
|
|
31
|
+
thresholdPct: number;
|
|
20
32
|
}
|
|
21
33
|
export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
22
34
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
23
35
|
export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
24
|
-
export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string): void;
|
|
36
|
+
export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation): void;
|
|
25
37
|
/** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
|
|
26
38
|
export interface ArmedSupervision {
|
|
27
39
|
disarm: () => void;
|
|
28
40
|
}
|
|
29
41
|
export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
|
|
42
|
+
/** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
|
|
43
|
+
export declare function armWatchSupervision(repoRoot: string, beatMs?: number): ArmedSupervision;
|
|
30
44
|
export declare function readTierLiveness(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
|
|
31
45
|
export declare function supervisionStatus(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
|
|
32
46
|
/** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
|
package/dist/run/supervision.js
CHANGED
|
@@ -14,8 +14,8 @@ import { stateDirName, tickmarkrDir } from "../graph/graph.js";
|
|
|
14
14
|
//
|
|
15
15
|
// SUP-02: the beat mtime is the ONLY input. Nothing here reads a process table and nothing matches a
|
|
16
16
|
// process name — a poll-grep watcher carries `grep` in its own argv, so the filter that removes the
|
|
17
|
-
// probing grep removes the watched one. The payload
|
|
18
|
-
// record;
|
|
17
|
+
// probing grep removes the watched one. The payload's exitedWriterPid identifies the one-shot writer
|
|
18
|
+
// for an OPERATOR reading a STALE record; no derivation consults it, so its value changes no state.
|
|
19
19
|
// Zero new deps — node:fs stdlib, exactly as lock.ts.
|
|
20
20
|
/** How often a watcher rewrites its own tier's beat. */
|
|
21
21
|
export const SUPERVISION_BEAT_MS = 10_000;
|
|
@@ -27,6 +27,8 @@ export const SUPERVISION_STALE_MS = 6 * SUPERVISION_BEAT_MS;
|
|
|
27
27
|
// millisecond into the future. A second of slack covers even a coarse filesystem. Anything past it
|
|
28
28
|
// is SKEW, and skew is the one direction this instrument may not fail in: see readTierLiveness.
|
|
29
29
|
export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
|
|
30
|
+
/** Context watchers act at 75% unless their invocation declares another threshold. */
|
|
31
|
+
export const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
|
|
30
32
|
// The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
|
|
31
33
|
// mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
|
|
32
34
|
// seat means adding it here, and `status` then renders it whether or not it has ever beaten.
|
|
@@ -55,6 +57,8 @@ const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "sup
|
|
|
55
57
|
export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
|
|
56
58
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
57
59
|
export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
|
|
60
|
+
/** The independent latch a stand-down cannot overwrite or remove. */
|
|
61
|
+
const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.clear-owed`);
|
|
58
62
|
// SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
|
|
59
63
|
// Two boards watch one repo the moment an operator opens a second pane, and the tier is armed while
|
|
60
64
|
// EITHER of them lives. The stand-down marker speaks for the whole tier, so the first board out
|
|
@@ -93,25 +97,102 @@ function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
|
|
|
93
97
|
}
|
|
94
98
|
return stale;
|
|
95
99
|
}
|
|
100
|
+
/** Read the latch without creating, touching or repairing it. */
|
|
101
|
+
function readClearObligation(repoRoot, tier) {
|
|
102
|
+
const p = supervisionObligationPath(repoRoot, tier);
|
|
103
|
+
let st;
|
|
104
|
+
try {
|
|
105
|
+
st = statSync(p);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const code = e.code;
|
|
109
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "NONE" : "UNREADABLE";
|
|
110
|
+
}
|
|
111
|
+
if (!st.isFile())
|
|
112
|
+
return "UNREADABLE";
|
|
113
|
+
try {
|
|
114
|
+
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
115
|
+
if (rec?.tier !== tier)
|
|
116
|
+
return "UNREADABLE";
|
|
117
|
+
if (typeof rec.clearOwedSince !== "string" || Number.isNaN(Date.parse(rec.clearOwedSince)))
|
|
118
|
+
return "UNREADABLE";
|
|
119
|
+
if (typeof rec.armId !== "string" || !rec.armId.trim())
|
|
120
|
+
return "UNREADABLE";
|
|
121
|
+
if (typeof rec.thresholdPct !== "number" || !Number.isFinite(rec.thresholdPct)
|
|
122
|
+
|| rec.thresholdPct < 0 || rec.thresholdPct > 100)
|
|
123
|
+
return "UNREADABLE";
|
|
124
|
+
return { clearOwedSince: rec.clearOwedSince, armId: rec.armId, thresholdPct: rec.thresholdPct };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return "UNREADABLE";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Publish a crossing atomically; a firing replacement inherits the original duty's instant. */
|
|
131
|
+
function raiseClearObligation(repoRoot, tier, armId, thresholdPct) {
|
|
132
|
+
const existing = readClearObligation(repoRoot, tier);
|
|
133
|
+
if (typeof existing === "object" && existing.armId === armId)
|
|
134
|
+
return;
|
|
135
|
+
if (existing === "UNREADABLE") {
|
|
136
|
+
throw new Error(`${tier} clear obligation is unreadable — refusing to overwrite a duty that may still be owed`);
|
|
137
|
+
}
|
|
138
|
+
const p = supervisionObligationPath(repoRoot, tier);
|
|
139
|
+
const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`;
|
|
140
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
141
|
+
writeFileSync(tmp, JSON.stringify({
|
|
142
|
+
tier, armId, thresholdPct,
|
|
143
|
+
clearOwedSince: typeof existing === "object" ? existing.clearOwedSince : new Date().toISOString(),
|
|
144
|
+
}) + "\n");
|
|
145
|
+
renameSync(tmp, p);
|
|
146
|
+
}
|
|
147
|
+
/** Only a different arm observed below the firing threshold proves the old seat was cleared. */
|
|
148
|
+
function dischargeClearObligation(repoRoot, tier, observation) {
|
|
149
|
+
const existing = readClearObligation(repoRoot, tier);
|
|
150
|
+
if (existing === "UNREADABLE") {
|
|
151
|
+
throw new Error(`${tier} clear obligation is unreadable — refusing to erase a duty that may still be owed`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof existing === "object" && existing.armId !== observation.armId
|
|
154
|
+
&& observation.pct < existing.thresholdPct) {
|
|
155
|
+
rmSync(supervisionObligationPath(repoRoot, tier), { force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateObservation(observation) {
|
|
159
|
+
if (!observation.armId.trim())
|
|
160
|
+
throw new Error("a supervision observation needs a non-empty arm identity");
|
|
161
|
+
for (const [name, value] of [["pct", observation.pct], ["threshold-pct", observation.thresholdPct]]) {
|
|
162
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
|
163
|
+
throw new Error(`--${name} must be a number from 0 through 100`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
96
167
|
// WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
|
|
97
168
|
// purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
|
|
98
169
|
// that beats on the watcher's behalf, which would report every dead tier as healthy.
|
|
99
|
-
function writeSupervisionBeat(repoRoot, tier, seat, armId) {
|
|
170
|
+
function writeSupervisionBeat(repoRoot, tier, seat, armId, observation) {
|
|
100
171
|
// A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
|
|
101
172
|
// verb: any caller that could write a seatless record could arm a tier nobody occupies.
|
|
102
173
|
if (isSeatTier(tier) && !seat?.trim()) {
|
|
103
174
|
throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
|
|
104
175
|
}
|
|
176
|
+
if (observation !== undefined)
|
|
177
|
+
validateObservation(observation);
|
|
105
178
|
tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
|
|
106
179
|
const p = supervisionBeatPath(repoRoot, tier);
|
|
107
180
|
mkdirSync(dirname(p), { recursive: true });
|
|
181
|
+
// Raise before the beat so a later beat failure cannot hide a duty; discharge only after the
|
|
182
|
+
// below-threshold observation exists on disk.
|
|
183
|
+
if (observation !== undefined && observation.pct >= observation.thresholdPct) {
|
|
184
|
+
raiseClearObligation(repoRoot, tier, observation.armId, observation.thresholdPct);
|
|
185
|
+
}
|
|
108
186
|
writeFileSync(p, JSON.stringify({
|
|
109
187
|
tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
|
|
110
|
-
|
|
188
|
+
...(observation !== undefined ? { pct: observation.pct, thresholdPct: observation.thresholdPct } : {}),
|
|
189
|
+
exitedWriterPid: process.pid, beatAt: new Date().toISOString(),
|
|
111
190
|
}) + "\n");
|
|
191
|
+
if (observation !== undefined)
|
|
192
|
+
dischargeClearObligation(repoRoot, tier, observation);
|
|
112
193
|
}
|
|
113
|
-
export function beatSupervision(repoRoot, tier, seat) {
|
|
114
|
-
writeSupervisionBeat(repoRoot, tier, seat);
|
|
194
|
+
export function beatSupervision(repoRoot, tier, seat, observation) {
|
|
195
|
+
writeSupervisionBeat(repoRoot, tier, seat, undefined, observation);
|
|
115
196
|
}
|
|
116
197
|
// THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
|
|
117
198
|
// calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
|
|
@@ -138,7 +219,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
138
219
|
}
|
|
139
220
|
catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
|
|
140
221
|
// This watcher's own identity fences BOTH its presence and its stand-down against every later arm.
|
|
141
|
-
//
|
|
222
|
+
// A process id alone collides between two boards in one host (and after reuse); a UUID never aliases the
|
|
142
223
|
// stale presence of a killed process that a later last-one-out cleanup may already have observed.
|
|
143
224
|
const id = `${process.pid}.${randomUUID()}`;
|
|
144
225
|
const presence = supervisionPresencePath(repoRoot, tier, id);
|
|
@@ -146,7 +227,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
146
227
|
const mark = () => {
|
|
147
228
|
try {
|
|
148
229
|
writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
|
|
149
|
-
writeFileSync(presence, JSON.stringify({ tier,
|
|
230
|
+
writeFileSync(presence, JSON.stringify({ tier, exitedWriterPid: process.pid, id }) + "\n");
|
|
150
231
|
}
|
|
151
232
|
catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
|
|
152
233
|
};
|
|
@@ -182,7 +263,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
182
263
|
mkdirSync(dirname(p), { recursive: true });
|
|
183
264
|
writeFileSync(tmp, JSON.stringify({
|
|
184
265
|
tier, ...(seat ? { seat } : {}), armId: id,
|
|
185
|
-
|
|
266
|
+
exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
|
|
186
267
|
}) + "\n");
|
|
187
268
|
renameSync(tmp, p);
|
|
188
269
|
}
|
|
@@ -198,6 +279,10 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
198
279
|
},
|
|
199
280
|
};
|
|
200
281
|
}
|
|
282
|
+
/** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
|
|
283
|
+
export function armWatchSupervision(repoRoot, beatMs = SUPERVISION_BEAT_MS) {
|
|
284
|
+
return armSupervision(repoRoot, "watch", beatMs);
|
|
285
|
+
}
|
|
201
286
|
// BEAT DERIVATION — pure, and the only thing that reads the beat. One statSync: never creates,
|
|
202
287
|
// touches or reaps the record it reports on, and never creates the directory that holds it. ONLY a
|
|
203
288
|
// missing path is ABSENT: ENOENT (no beat file) and ENOTDIR (nothing that could hold one) mean no
|
|
@@ -206,7 +291,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
206
291
|
// never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
|
|
207
292
|
// the narrower question "does the beat say alive", which is all a beat can ever say.
|
|
208
293
|
export function readTierLiveness(repoRoot, tier, now = Date.now()) {
|
|
209
|
-
return beatLiveness(tier, readBeat(repoRoot, tier), now);
|
|
294
|
+
return withClearObligation(repoRoot, tier, beatLiveness(tier, readBeat(repoRoot, tier), now));
|
|
210
295
|
}
|
|
211
296
|
// The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
|
|
212
297
|
// reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
|
|
@@ -266,6 +351,15 @@ function beatLiveness(tier, beat, now) {
|
|
|
266
351
|
const beatAgeMs = Math.max(0, age); // inside the grace: the two clocks' resolutions, not skew
|
|
267
352
|
return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs, ...named };
|
|
268
353
|
}
|
|
354
|
+
/** Add the independent duty without replacing or reinterpreting the watcher's state. */
|
|
355
|
+
function withClearObligation(repoRoot, tier, liveness) {
|
|
356
|
+
const obligation = readClearObligation(repoRoot, tier);
|
|
357
|
+
if (obligation === "NONE")
|
|
358
|
+
return liveness;
|
|
359
|
+
return obligation === "UNREADABLE"
|
|
360
|
+
? { ...liveness, clearOwedUnreadable: true }
|
|
361
|
+
: { ...liveness, clearOwedSince: obligation.clearOwedSince };
|
|
362
|
+
}
|
|
269
363
|
// A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
|
|
270
364
|
// payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
|
|
271
365
|
// torn write or a stray file at that path says nothing about any watcher, and calling one of those a
|
|
@@ -317,15 +411,17 @@ export function supervisionStatus(repoRoot, tier, now = Date.now()) {
|
|
|
317
411
|
const beat = readBeat(repoRoot, tier);
|
|
318
412
|
const standDown = readStandDown(repoRoot, tier);
|
|
319
413
|
if (standDown === "UNREADABLE")
|
|
320
|
-
return { tier, state: "UNREADABLE" };
|
|
414
|
+
return withClearObligation(repoRoot, tier, { tier, state: "UNREADABLE" });
|
|
321
415
|
const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
|
|
322
416
|
beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
|
|
323
417
|
if (standDown !== "NONE" && !beatOutranksStandDown) {
|
|
324
418
|
// the seat that stood down is named by the marker, falling back to whatever its last beat named
|
|
325
419
|
const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
|
|
326
|
-
return
|
|
420
|
+
return withClearObligation(repoRoot, tier, {
|
|
421
|
+
tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}),
|
|
422
|
+
});
|
|
327
423
|
}
|
|
328
|
-
return beatLiveness(tier, beat, now);
|
|
424
|
+
return withClearObligation(repoRoot, tier, beatLiveness(tier, beat, now));
|
|
329
425
|
}
|
|
330
426
|
/** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
|
|
331
427
|
export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
|
|
@@ -333,4 +429,6 @@ export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS
|
|
|
333
429
|
// The seat is rendered BESIDE the state, never instead of it: `overseer-context ARMED (w3:p2)` says
|
|
334
430
|
// both that something is beating and who is behind it, which is the pair an operator needs to act. A
|
|
335
431
|
// row with no seat to name renders exactly as it always did.
|
|
336
|
-
export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}`
|
|
432
|
+
export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}` +
|
|
433
|
+
`${t.clearOwedSince ? ` CLEAR-OWED since ${t.clearOwedSince}` : ""}` +
|
|
434
|
+
`${t.clearOwedUnreadable ? " CLEAR-OWED unreadable" : ""}`).join(divider)}`;
|
package/package.json
CHANGED
|
@@ -45,7 +45,8 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
|
|
|
45
45
|
|
|
46
46
|
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
47
47
|
2. Read the `version` field from the repository's `package.json`.
|
|
48
|
-
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or
|
|
48
|
+
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`), or to install this repository's build as a REAL COPY — `npm pack`, then `npm i -g ./<tarball>`. Do not compile, plan, or run on hope.
|
|
49
|
+
> ⚠ **Never `npm i -g .` on the repository directory, and never link it.** npm SYMLINKS a directory install, which makes the working tree itself the machine-wide binary: every later build — including a gate's own `npm run build` — silently hot-swaps the CLI for every repository on the machine, with no version change to notice it by. Measured 2026-08-29: a verify build gate rewrote the shared binary while another repository's daemon was mid-run against it, and a positive control that rebuilds at a pre-fix ref would have installed the very defect it was proving fixed, machine-wide (OBS-771). Verify an install by comparing the global and repo **inodes** — they must DIFFER — never by `tickmarkr version`, which cannot go red when nothing is bumped.
|
|
49
50
|
|
|
50
51
|
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
51
52
|
|
|
@@ -40,7 +40,8 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
|
|
|
40
40
|
|
|
41
41
|
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
42
42
|
2. Read the `version` field from the repository's `package.json`.
|
|
43
|
-
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or
|
|
43
|
+
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`), or to install this repository's build as a REAL COPY — `npm pack`, then `npm i -g ./<tarball>`. Do not compile, plan, or run on hope.
|
|
44
|
+
> ⚠ **Never `npm i -g .` on the repository directory, and never link it.** npm SYMLINKS a directory install, which makes the working tree itself the machine-wide binary: every later build — including a gate's own `npm run build` — silently hot-swaps the CLI for every repository on the machine, with no version change to notice it by. Measured 2026-08-29: a verify build gate rewrote the shared binary while another repository's daemon was mid-run against it, and a positive control that rebuilds at a pre-fix ref would have installed the very defect it was proving fixed, machine-wide (OBS-771). Verify an install by comparing the global and repo **inodes** — they must DIFFER — never by `tickmarkr version`, which cannot go red when nothing is bumped.
|
|
44
45
|
|
|
45
46
|
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
46
47
|
|
|
@@ -60,6 +60,11 @@ through brief lineage. **An executor choice nobody made is still an executor cho
|
|
|
60
60
|
OPERATOR LAYOUT/CONVENTION rather than a shipped milestone — names like `*-discipline`, `*-drill`,
|
|
61
61
|
`*-parity`, `*-least-permission`, `context-reset-*`, `consults-*`, `agent-*`, `*-tab-layout`,
|
|
62
62
|
`*-visible-*`, `*-panes*`, `user-tabs-*`.
|
|
63
|
+
**A standing instruction carries its revocation premise.** Every standing rule you lift from memory,
|
|
64
|
+
handoff or a live correction states the premise that makes it true and the concrete observation that
|
|
65
|
+
would falsify that premise and revoke the rule. If you cannot name the falsifier, you have written a
|
|
66
|
+
preference, not standing supervision law. When the falsifier arrives, retire or amend the rule in the
|
|
67
|
+
shipped skill in the same act; do not leave successors to obey a rule whose reason is already false.
|
|
63
68
|
**Earned 2026-08-04, expensively.** That directory held `…-falsification-drill-discipline.md`, written
|
|
64
69
|
three weeks earlier: *"a gate or grep-pin is assumed WRONG until a falsification drill proves it bites…
|
|
65
70
|
run the drill that should redden it and SEE the red before trusting green."* That is Evidence discipline
|
|
@@ -565,6 +570,10 @@ cleared, re-briefed, or replaced keeps beating that tier's file forever. Measure
|
|
|
565
570
|
**three-hour window in which no orchestrator was alive**, and it would have silently re-armed a
|
|
566
571
|
recorded stand-down within 10 seconds. On the same sweep the overseer tier had **three** beat loops,
|
|
567
572
|
one owned by an unrelated session. So:
|
|
573
|
+
- **Split the liveness reads.** A tier's liveness is read from beat freshness in the repository status
|
|
574
|
+
path; a loop's liveness is read from the live process payload that is emitting that beat (`tickmarkr
|
|
575
|
+
beat <tier> --seat <seat>` in this repo). Neither liveness claim is read from a recorded pid: a pid
|
|
576
|
+
recorded earlier can be stale, reused, or detached from the beat now holding the tier green.
|
|
568
577
|
- **At every adopt, clear, or re-brief, sweep for pre-existing loops on YOUR tier before arming one**
|
|
569
578
|
(`pgrep -f "tickmarkr beat <tier>"`), trace each to its parent session, and kill the **loop only**
|
|
570
579
|
— never the parent — then verify the parent survived.
|