sentinelayer-cli 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -2
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +483 -47
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
package/README.md CHANGED
@@ -138,6 +138,24 @@ daemon per session is enforced via `senti-daemon.json` in the session directory
138
138
  (logs in `senti-daemon.log` next to it), and the daemon exits on its own when
139
139
  the session expires.
140
140
 
141
+ Startup uses optional cached `.sentinelayer/CODEBASE_INGEST.json` context only;
142
+ it does not recursively scan your working directory. A missing, invalid, oversized
143
+ or slow cache yields empty context. Run `sl ingest map --path .` separately
144
+ when you need a full scan. The cache reader has a 750ms budget and is terminated
145
+ before returning; resume probes have a 2s deadline per attempt (one retry), while
146
+ the existing workspace-list lookup keeps its 5s deadline. Optional title,
147
+ metadata and welcome synchronization are bounded; startup does not rotate auth
148
+ or wait through a title `Retry-After` retry. No hard wall-clock guarantee applies
149
+ to required local filesystem writes or a stalled OS filesystem.
150
+
151
+ With `--json`, stdout remains one JSON document. `remoteSync.status` reports
152
+ `synced` only after metadata acknowledgement, otherwise `degraded` (or the
153
+ existing `disabled`/`auth_required` states). `firstMessage.posted` retains its
154
+ **local stream durability** meaning; `firstMessage.remoteSynced` and
155
+ `firstMessage.remoteSync` separately report API acknowledgement/failure. An
156
+ acknowledgement is not a browser or teammate-access test. A transient resume
157
+ timeout keeps the existing local ID rather than minting a duplicate.
158
+
141
159
  Then point your agents at it:
142
160
 
143
161
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,7 +12,8 @@
12
12
  "test:e2e": "node --import ./tests/setup-env.mjs --test tests/e2e*.test.mjs",
13
13
  "test:coverage": "c8 node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
14
14
  "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run",
15
- "release:publish": "node scripts/release-publish.mjs"
15
+ "release:publish": "node scripts/release-publish.mjs",
16
+ "test:conformance": "node --import ./tests/setup-env.mjs --test tests/conformance.engram-21-3.test.mjs"
16
17
  },
17
18
  "bin": {
18
19
  "sentinelayer-cli": "bin/sentinelayer-cli.js",
@@ -0,0 +1,34 @@
1
+ // admission-scope.js — the admitted-agent scope, as a leaf module.
2
+ //
3
+ // While a command runs as an admitted agent (see src/session/admission-auth.js),
4
+ // this AsyncLocalStorage holds its admission credential. resolveActiveAuthSession
5
+ // consults it FIRST, so every credential lookup made anywhere inside the command --
6
+ // session transport, remote verification, confirmation reads, helpers of helpers --
7
+ // gets the admission credential or nothing. The human's own token is unreachable
8
+ // from inside the scope, so no human-authenticated subrequest can hide behind a
9
+ // scoped main request.
10
+ //
11
+ // It imports nothing from this codebase, so the auth service can depend on it
12
+ // without an import cycle.
13
+
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+
16
+ export const admittedAgentScope = new AsyncLocalStorage();
17
+
18
+ /**
19
+ * undefined -> not inside an admitted-agent scope (resolve auth normally)
20
+ * null -> inside one, but the credential has expired: nothing to send
21
+ * object -> the admission credential, bound to the API that issued it
22
+ */
23
+ export function scopedAdmissionAuth() {
24
+ const credential = admittedAgentScope.getStore();
25
+ if (!credential) return undefined;
26
+ if (!(credential.expiresAt * 1000 > Date.now())) return null;
27
+ return {
28
+ apiUrl: credential.apiUrl,
29
+ token: credential.token,
30
+ source: "session_admission",
31
+ user: null,
32
+ admissionId: credential.admissionId,
33
+ };
34
+ }
package/src/auth/gate.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import pc from "picocolors";
7
7
  import { resolveActiveAuthSession } from "./service.js";
8
+ import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
8
9
  import { authLoginHint } from "../ui/command-hints.js";
9
10
 
10
11
  /**
@@ -383,6 +384,11 @@ export async function checkAuthGate(args) {
383
384
  resolveError = error instanceof Error ? `session_read_error: ${error.message}` : "session_read_error";
384
385
  }
385
386
 
387
+ // An agent command whose admission is stored here: its credential IS the admission
388
+ // (runCli then dispatches it inside that scope, or it refuses a tombstone).
389
+ if (await resolveAgentAdmissionTarget(args)) {
390
+ return { authenticated: true, session: null, bypassReason: "session_admission", failureReason: null };
391
+ }
386
392
  return { authenticated: false, session: null, bypassReason: null, failureReason: resolveError };
387
393
  }
388
394
 
package/src/auth/http.js CHANGED
@@ -3,6 +3,10 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { installTestEgressGuard } from "../net/test-egress-guard.js";
7
+
8
+ // No-op outside tests; see src/net/test-egress-guard.js.
9
+ installTestEgressGuard();
6
10
 
7
11
  /**
8
12
  * Default timeout applied to Sentinelayer API requests when no override is provided.
@@ -5,6 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
5
5
  import open from "open";
6
6
 
7
7
  import { loadConfig } from "../config/service.js";
8
+ import { scopedAdmissionAuth } from "./admission-scope.js";
8
9
  import { SentinelayerApiError, requestJson, requestJsonMutation } from "./http.js";
9
10
  import {
10
11
  clearStoredSession,
@@ -667,6 +668,11 @@ export async function resolveActiveAuthSession({
667
668
  tokenTtlDays = DEFAULT_API_TOKEN_TTL_DAYS,
668
669
  homeDir,
669
670
  } = {}) {
671
+ // Inside an admitted agent's command the ONLY credential is its admission (or,
672
+ // once expired, none). The human token is never reachable from there.
673
+ const scoped = scopedAdmissionAuth();
674
+ if (scoped !== undefined) return scoped;
675
+
670
676
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
671
677
 
672
678
  const envToken = String(env.SENTINELAYER_TOKEN || "").trim();
@@ -0,0 +1,155 @@
1
+ /**
2
+ * BOARD — the deterministic done-gate.
3
+ *
4
+ * Carter's ask (room 431032) was "the free in-house model checks to see if items are
5
+ * done and marks them done, so I know it's done." A model deciding done-ness defeats
6
+ * the thing the board is for: the instant "done" means "Gemma believed it", the mark
7
+ * is worth nothing and he is back to checking everything himself. **A ticket wrongly
8
+ * marked done is worse than one left open** — an open ticket still has his attention,
9
+ * a closed one has left his field of view.
10
+ *
11
+ * So: the model may PROPOSE a link between a ticket and some evidence. Only a
12
+ * deterministic check of a verifiable fact may STAMP it. Model proposes, evidence
13
+ * disposes. Nothing in this file imports a model, and that is structural, not stylistic.
14
+ *
15
+ * THREE STATES, NOT TWO — the point of the module:
16
+ *
17
+ * done the evidence was checked and it holds
18
+ * not-done the evidence was checked and it does not hold
19
+ * cannot-verify the check could not be made
20
+ *
21
+ * `cannot-verify` must never collapse into either neighbour. Folding it into
22
+ * `not-done` nags about finished work until the board is ignored; folding it into
23
+ * `done` closes tickets nobody completed, which is the failure that makes the whole
24
+ * board worthless. An unknown answer gets its own name and refuses — the same shape as
25
+ * `absent | building | ready` in the engram lifecycle.
26
+ *
27
+ * PURE BY CONSTRUCTION: the actual lookups are INJECTED as a resolver. This module
28
+ * decides what the evidence MEANS; it never fetches anything. That makes every branch
29
+ * — including every failure branch — reachable in a test without a network.
30
+ */
31
+
32
+ export const VERDICTS = Object.freeze({
33
+ DONE: "done",
34
+ NOT_DONE: "not-done",
35
+ CANNOT_VERIFY: "cannot-verify",
36
+ });
37
+
38
+ /**
39
+ * The evidence kinds a ticket may claim. CLOSED SET: an unrecognised kind resolves to
40
+ * `cannot-verify`, never to a guess. Adding a kind is a deliberate edit here plus a
41
+ * resolver that can answer it.
42
+ */
43
+ export const EVIDENCE_KINDS = Object.freeze({
44
+ /** A pull request is merged. { repo, number } */
45
+ PR_MERGED: "pr-merged",
46
+ /** A commit is an ancestor of the named branch. { repo, sha, branch } */
47
+ SHA_ON_BRANCH: "sha-on-branch",
48
+ /** A named check concluded successfully on a ref. { repo, ref, check } */
49
+ CHECK_CONCLUSIVE: "check-conclusive",
50
+ /** A deployment is serving the named build. { service, sha } */
51
+ DEPLOY_SHA: "deploy-sha",
52
+ });
53
+
54
+ const KIND_REQUIRED_FIELDS = Object.freeze({
55
+ [EVIDENCE_KINDS.PR_MERGED]: ["repo", "number"],
56
+ [EVIDENCE_KINDS.SHA_ON_BRANCH]: ["repo", "sha", "branch"],
57
+ [EVIDENCE_KINDS.CHECK_CONCLUSIVE]: ["repo", "ref", "check"],
58
+ [EVIDENCE_KINDS.DEPLOY_SHA]: ["service", "sha"],
59
+ });
60
+
61
+ function cannotVerify(reason) {
62
+ return { verdict: VERDICTS.CANNOT_VERIFY, reason };
63
+ }
64
+
65
+ /**
66
+ * Validate an evidence descriptor's SHAPE before anything is looked up.
67
+ *
68
+ * A malformed descriptor is `cannot-verify`, never `not-done`: we did not learn that
69
+ * the work is incomplete, only that the ticket does not say how to check it.
70
+ */
71
+ export function describeEvidence(evidence) {
72
+ if (!evidence || typeof evidence !== "object") {
73
+ return cannotVerify("evidence is missing");
74
+ }
75
+ const kind = typeof evidence.kind === "string" ? evidence.kind.trim() : "";
76
+ if (!kind) return cannotVerify("evidence has no kind");
77
+ const required = KIND_REQUIRED_FIELDS[kind];
78
+ if (!required) return cannotVerify(`unrecognised evidence kind: ${kind}`);
79
+ const missing = required.filter((field) => {
80
+ const value = evidence[field];
81
+ return value === undefined || value === null || (typeof value === "string" && !value.trim());
82
+ });
83
+ if (missing.length > 0) {
84
+ return cannotVerify(`evidence kind ${kind} is missing: ${missing.join(", ")}`);
85
+ }
86
+ return { verdict: null, kind, required };
87
+ }
88
+
89
+ /**
90
+ * Decide whether a ticket's evidence holds.
91
+ *
92
+ * @param {object} evidence the ticket's evidence claim
93
+ * @param {object} deps
94
+ * @param {(evidence:object)=>Promise<boolean|null>} deps.resolve
95
+ * Performs the lookup. `true` -> the fact holds, `false` -> it does not,
96
+ * `null`/`undefined` -> INDETERMINATE. A thrown error is also indeterminate:
97
+ * an unreachable API tells us nothing about the work, so it must not be
98
+ * reported as either answer.
99
+ * @returns {Promise<{verdict:string, reason?:string, kind?:string}>}
100
+ */
101
+ export async function checkDone(evidence, { resolve } = {}) {
102
+ const shape = describeEvidence(evidence);
103
+ if (shape.verdict === VERDICTS.CANNOT_VERIFY) return shape;
104
+
105
+ if (typeof resolve !== "function") {
106
+ return cannotVerify("no resolver was provided");
107
+ }
108
+
109
+ let answer;
110
+ try {
111
+ answer = await resolve(evidence);
112
+ } catch (error) {
113
+ // An unreachable resolver is not evidence of incompleteness.
114
+ const detail = error && error.message ? error.message : String(error);
115
+ return { ...cannotVerify(`resolver failed: ${detail}`), kind: shape.kind };
116
+ }
117
+
118
+ if (answer === true) return { verdict: VERDICTS.DONE, kind: shape.kind };
119
+ if (answer === false) return { verdict: VERDICTS.NOT_DONE, kind: shape.kind };
120
+ return { ...cannotVerify("resolver returned an indeterminate answer"), kind: shape.kind };
121
+ }
122
+
123
+ /**
124
+ * Decide a whole ticket, which may carry several pieces of evidence.
125
+ *
126
+ * ALL of them must hold. One `not-done` makes the ticket not done; otherwise a single
127
+ * `cannot-verify` makes the whole ticket unverifiable. A ticket is never `done` on a
128
+ * partial reading of its own evidence — that would let an unreachable check quietly
129
+ * become a passing one, which is `cannot-verify` collapsing into `done` by the back
130
+ * door rather than the front.
131
+ *
132
+ * A ticket claiming NO evidence is `cannot-verify`: nothing about it can be checked,
133
+ * and it must not be closable by a checker.
134
+ */
135
+ export async function checkTicketDone(ticket, deps = {}) {
136
+ const claims = Array.isArray(ticket?.evidence) ? ticket.evidence : [];
137
+ if (claims.length === 0) {
138
+ return { verdict: VERDICTS.CANNOT_VERIFY, reason: "ticket claims no evidence", results: [] };
139
+ }
140
+
141
+ const results = [];
142
+ for (const claim of claims) {
143
+ results.push(await checkDone(claim, deps));
144
+ }
145
+
146
+ const notDone = results.find((r) => r.verdict === VERDICTS.NOT_DONE);
147
+ if (notDone) {
148
+ return { verdict: VERDICTS.NOT_DONE, reason: notDone.reason ?? "an evidence claim does not hold", results };
149
+ }
150
+ const unverifiable = results.find((r) => r.verdict === VERDICTS.CANNOT_VERIFY);
151
+ if (unverifiable) {
152
+ return { verdict: VERDICTS.CANNOT_VERIFY, reason: unverifiable.reason, results };
153
+ }
154
+ return { verdict: VERDICTS.DONE, results };
155
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * BOARD — resolvers: the code that actually asks whether a ticket's evidence holds.
3
+ *
4
+ * `done-gate.js` decides what an answer MEANS. This decides what the answer IS. The
5
+ * split matters because the gate must stay pure and testable while the lookups are
6
+ * inherently messy — networks time out, repos get renamed, tokens expire.
7
+ *
8
+ * THE ONE RULE THAT GOVERNS EVERY RESOLVER HERE:
9
+ *
10
+ * true the fact was checked and HOLDS
11
+ * false the fact was checked and DOES NOT hold
12
+ * null we could not find out
13
+ *
14
+ * `null` is not a failure to be tidied away into `false`. **"I could not reach GitHub"
15
+ * and "the PR is not merged" are different facts, and only one of them is about the
16
+ * work.** Collapsing them means every network blip re-opens finished tickets until
17
+ * nobody trusts the board.
18
+ *
19
+ * THE SUBTLER HALF, and the one this file exists to get right: **absence is not
20
+ * failure.** A check that has not started yet contributes no row and no conclusion.
21
+ * Reading that silence as `false` is the same defect as reading a missing CI row as
22
+ * green, just pointed the other way — and it is the mistake most likely to be made by
23
+ * someone writing these resolvers in a hurry.
24
+ *
25
+ * Every lookup is INJECTED (`run`), so each branch above -- including every failure
26
+ * branch -- is reachable in a test without a network.
27
+ */
28
+
29
+ /** Shape a resolver must return. Anything else is treated as indeterminate. */
30
+ const YES = true;
31
+ const NO = false;
32
+ const UNKNOWN = null;
33
+
34
+ function parseJson(stdout) {
35
+ try {
36
+ return JSON.parse(String(stdout ?? ""));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {object} deps
44
+ * @param {(argv:string[])=>Promise<{ok:boolean,stdout?:string,stderr?:string}>} deps.run
45
+ * Executes a `gh` invocation. `ok:false` means the call failed for ANY reason
46
+ * -- that is indeterminate, never a negative verdict.
47
+ * @param {(sha:string)=>Promise<boolean|null>} [deps.probeDeployedSha]
48
+ * Optional. Without it, `deploy-sha` evidence resolves to UNKNOWN rather than
49
+ * being guessed at -- this module will not pretend to know what is live.
50
+ */
51
+ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
52
+ if (typeof run !== "function") throw new TypeError("a run() is required");
53
+
54
+ async function prMerged({ repo, number }) {
55
+ const res = await run(["pr", "view", String(number), "--repo", repo, "--json", "state,mergedAt"]);
56
+ if (!res || !res.ok) return UNKNOWN; // unreachable, missing, or unauthorised
57
+ const data = parseJson(res.stdout);
58
+ if (!data || typeof data.state !== "string") return UNKNOWN;
59
+ if (data.state === "MERGED") return YES;
60
+ // OPEN or CLOSED-without-merge is a real negative: the PR exists and did not merge.
61
+ if (data.state === "OPEN" || data.state === "CLOSED") return NO;
62
+ return UNKNOWN;
63
+ }
64
+
65
+ async function shaOnBranch({ repo, sha, branch }) {
66
+ const res = await run(["api", `repos/${repo}/compare/${branch}...${sha}`, "--jq", ".status"]);
67
+ if (!res || !res.ok) return UNKNOWN;
68
+ const status = String(res.stdout ?? "").trim();
69
+ // GitHub's compare status, base=branch head=sha:
70
+ // identical / behind -> sha is contained in branch
71
+ // ahead / diverged -> it is not
72
+ if (status === "identical" || status === "behind") return YES;
73
+ if (status === "ahead" || status === "diverged") return NO;
74
+ return UNKNOWN;
75
+ }
76
+
77
+ async function checkConclusive({ repo, ref, check }) {
78
+ const res = await run(["api", `repos/${repo}/commits/${ref}/check-runs`, "--jq", ".check_runs"]);
79
+ if (!res || !res.ok) return UNKNOWN;
80
+ const runs = parseJson(res.stdout);
81
+ if (!Array.isArray(runs)) return UNKNOWN;
82
+
83
+ const named = runs.filter((r) => r && r.name === check);
84
+ // ABSENCE IS NOT FAILURE. A check that never started contributes no row, so an
85
+ // empty match means we do not know -- exactly the "missing row reads as green"
86
+ // trap, inverted. Reporting NO here would mark finished work as incomplete
87
+ // whenever a workflow was renamed or skipped.
88
+ if (named.length === 0) return UNKNOWN;
89
+
90
+ // Any still running -> the answer is not in yet.
91
+ if (named.some((r) => r.status !== "completed")) return UNKNOWN;
92
+
93
+ const conclusions = named.map((r) => r.conclusion);
94
+ if (conclusions.every((c) => c === "success" || c === "skipped")) return YES;
95
+ if (conclusions.some((c) => c === "failure" || c === "timed_out" || c === "cancelled")) return NO;
96
+ return UNKNOWN;
97
+ }
98
+
99
+ async function deploySha({ sha }) {
100
+ if (typeof probeDeployedSha !== "function") return UNKNOWN;
101
+ try {
102
+ const live = await probeDeployedSha(sha);
103
+ return live === true ? YES : live === false ? NO : UNKNOWN;
104
+ } catch {
105
+ return UNKNOWN;
106
+ }
107
+ }
108
+
109
+ const BY_KIND = {
110
+ "pr-merged": prMerged,
111
+ "sha-on-branch": shaOnBranch,
112
+ "check-conclusive": checkConclusive,
113
+ "deploy-sha": deploySha,
114
+ };
115
+
116
+ /**
117
+ * The function `checkDone` expects: evidence -> true | false | null.
118
+ * An unrecognised kind returns UNKNOWN here, though the gate refuses it before we
119
+ * are ever called -- defence in depth, since this module is exported on its own.
120
+ */
121
+ return async function resolve(evidence) {
122
+ const handler = BY_KIND[evidence?.kind];
123
+ if (!handler) return UNKNOWN;
124
+ return handler(evidence);
125
+ };
126
+ }
127
+
128
+ /** A `run` backed by the real `gh` CLI. Never throws: a failure is indeterminate. */
129
+ export function createGhRunner({ spawn }) {
130
+ return async function run(argv) {
131
+ try {
132
+ const res = await spawn("gh", argv);
133
+ return { ok: res.code === 0, stdout: res.stdout, stderr: res.stderr };
134
+ } catch (error) {
135
+ return { ok: false, stderr: error?.message ?? String(error) };
136
+ }
137
+ };
138
+ }
package/src/cli.js CHANGED
@@ -14,6 +14,11 @@ const COMMAND_REGISTRARS = {
14
14
  exportName: "registerOmarGateCommand",
15
15
  needsLegacy: true,
16
16
  },
17
+ board: {
18
+ loader: () => import("./commands/board.js"),
19
+ exportName: "registerBoardCommand",
20
+ needsLegacy: false,
21
+ },
17
22
  audit: {
18
23
  loader: () => import("./commands/audit.js"),
19
24
  exportName: "registerAuditCommand",
@@ -257,6 +262,24 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
257
262
  invokeLegacy: runLegacyCliWithErrorHandling,
258
263
  onlyCommand: normalizedArgs[0],
259
264
  });
260
- await program.parseAsync(["node", "sentinelayer-cli", ...normalizedArgs]);
265
+ const dispatch = () => program.parseAsync(["node", "sentinelayer-cli", ...normalizedArgs]);
266
+
267
+ // THE choke point for agent authority: a `session` command naming an agent whose
268
+ // admission is stored here runs entirely inside that admission's scope, whatever
269
+ // the subcommand (say, post-agent, observe, locks, checkpoints, ...), so no
270
+ // subcommand, present or future, can reach the human token. A tombstoned
271
+ // admission refuses here, before any request.
272
+ const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } = await import(
273
+ "./session/admission-auth.js"
274
+ );
275
+ const target = await resolveAgentAdmissionTarget(normalizedArgs);
276
+ // Execution must match authorization: before ANY action runs, re-derive the identity
277
+ // from Commander's resolved options and compare it with the scope in force.
278
+ program.hook("preAction", (_hooked, actionCommand) => assertDispatchMatchesScope(actionCommand));
279
+ if (target) {
280
+ await withAgentAdmission(target.sessionId, target.agentId, dispatch);
281
+ return;
282
+ }
283
+ await dispatch();
261
284
  }
262
285
 
@@ -0,0 +1,116 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { spawn as nodeSpawn } from "node:child_process";
5
+
6
+ import { VERDICTS, checkTicketDone } from "../board/done-gate.js";
7
+ import { createEvidenceResolver, createGhRunner } from "../board/evidence-resolvers.js";
8
+
9
+ /**
10
+ * `sl board check` — ask whether a ticket's evidence actually holds.
11
+ *
12
+ * This is the command that makes the done-gate real. Without a caller, the gate and
13
+ * its resolvers are correct code that never runs, which this repo established the
14
+ * hard way tonight.
15
+ *
16
+ * WHAT IT DELIBERATELY DOES NOT DO: mark anything done. It reports a verdict and
17
+ * exits. Closing a ticket is a write, and a checker that closes tickets on its own
18
+ * judgement is the thing the whole design exists to prevent -- a model or a script
19
+ * deciding "done" instead of a person acting on verified evidence.
20
+ *
21
+ * EXIT CODES, chosen so a script cannot mistake one outcome for another:
22
+ * 0 done every claim was checked and holds
23
+ * 1 not-done a claim was checked and does not hold
24
+ * 2 cannot-verify something could not be checked -- NOT a failure of the work
25
+ *
26
+ * `cannot-verify` gets its own code rather than sharing with `not-done`, because a
27
+ * caller that treats "GitHub was down" as "the work is incomplete" reintroduces the
28
+ * collapse at the shell level, one layer below where we prevented it.
29
+ */
30
+
31
+ function spawnCapture(cmd, argv) {
32
+ return new Promise((resolve, reject) => {
33
+ let stdout = "";
34
+ let stderr = "";
35
+ const child = nodeSpawn(cmd, argv, { stdio: ["ignore", "pipe", "pipe"] });
36
+ child.on("error", reject);
37
+ child.stdout.on("data", (d) => { stdout += d; });
38
+ child.stderr.on("data", (d) => { stderr += d; });
39
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
40
+ });
41
+ }
42
+
43
+ const EXIT = {
44
+ [VERDICTS.DONE]: 0,
45
+ [VERDICTS.NOT_DONE]: 1,
46
+ [VERDICTS.CANNOT_VERIFY]: 2,
47
+ };
48
+
49
+ /**
50
+ * Read a ticket from a JSON file: `{ id?, title?, evidence: [ ... ] }`.
51
+ * A file that is missing, unreadable or unparseable is CANNOT-VERIFY, never NOT-DONE:
52
+ * we learned nothing about the work, only about the file.
53
+ */
54
+ export async function loadTicket(file) {
55
+ let raw;
56
+ try {
57
+ raw = await fsp.readFile(file, "utf-8");
58
+ } catch (error) {
59
+ return { error: `cannot read ticket file: ${error?.message ?? error}` };
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== "object") return { error: "ticket file is not an object" };
64
+ return { ticket: parsed };
65
+ } catch (error) {
66
+ return { error: `ticket file is not valid JSON: ${error?.message ?? error}` };
67
+ }
68
+ }
69
+
70
+ export async function runBoardCheck({ file, emitJson, deps = {} } = {}) {
71
+ const { ticket, error } = await loadTicket(file);
72
+ if (error) {
73
+ const out = { verdict: VERDICTS.CANNOT_VERIFY, reason: error };
74
+ print(out, emitJson);
75
+ return EXIT[VERDICTS.CANNOT_VERIFY];
76
+ }
77
+
78
+ const run = deps.run ?? createGhRunner({ spawn: deps.spawn ?? spawnCapture });
79
+ const resolve = deps.resolve ?? createEvidenceResolver({ run });
80
+ const result = await checkTicketDone(ticket, { resolve });
81
+
82
+ print({ ...result, ticket: ticket.id ?? ticket.title ?? path.basename(file) }, emitJson);
83
+ return EXIT[result.verdict] ?? EXIT[VERDICTS.CANNOT_VERIFY];
84
+ }
85
+
86
+ function print(result, emitJson) {
87
+ if (emitJson) {
88
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
89
+ return;
90
+ }
91
+ const lines = [`verdict: ${result.verdict}`];
92
+ if (result.reason) lines.push(`reason: ${result.reason}`);
93
+ for (const r of result.results ?? []) {
94
+ lines.push(` - ${r.kind ?? "?"}: ${r.verdict}${r.reason ? ` (${r.reason})` : ""}`);
95
+ }
96
+ process.stdout.write(`${lines.join("\n")}\n`);
97
+ }
98
+
99
+ export function registerBoardCommand(program) {
100
+ const board = program.command("board").description("Check ticket evidence deterministically");
101
+
102
+ board
103
+ .command("check")
104
+ .description("Check whether a ticket's evidence holds (done | not-done | cannot-verify)")
105
+ .requiredOption("--file <path>", "Ticket JSON file with an `evidence` array")
106
+ .option("--json", "Emit machine-readable output")
107
+ .action(async (options) => {
108
+ const code = await runBoardCheck({
109
+ file: path.resolve(process.cwd(), String(options.file)),
110
+ emitJson: Boolean(options.json),
111
+ });
112
+ process.exitCode = code;
113
+ });
114
+
115
+ return board;
116
+ }