sentinelayer-cli 0.40.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.
- package/README.md +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- package/src/session/wake/resolve-target.js +10 -5
package/README.md
CHANGED
|
@@ -138,18 +138,36 @@ 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
|
|
144
162
|
sl session join <session-id> --agent <agent-name>
|
|
145
|
-
sl session
|
|
163
|
+
sl session action <session-id> working_on --target-sequence <n>
|
|
146
164
|
```
|
|
147
165
|
|
|
148
166
|
Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
|
|
149
167
|
|
|
150
168
|
- session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
|
|
151
|
-
- low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `
|
|
152
|
-
- agent lifecycle controls (join/
|
|
169
|
+
- low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
|
|
170
|
+
- agent lifecycle controls (join/ephemeral TTL presence/leave/kill); listener liveness never enters the durable transcript
|
|
153
171
|
- recap and context briefing for late-joining agents
|
|
154
172
|
- analytics + lineage artifacts at session closeout
|
|
155
173
|
|
|
@@ -694,7 +712,7 @@ Use `init-aidenid` to scaffold an Anthropic-compatible tool schema wrapper for A
|
|
|
694
712
|
Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API contract (tool binding -> HTTP path/method -> response field mapping) and cross-check it against the registry with `validate-aidenid-adapter`.
|
|
695
713
|
Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
|
|
696
714
|
Use `smoke` to verify the hosted MCP resource without printing the bearer. The canonical remote MCP paste URL is `https://api.sentinelayer.com/mcp`; the token audience/resource value is `https://mcp.sentinelayer.com` and is handled by the OAuth/MCP flow. `sl mcp smoke` works today through the CLI direct-mint path; third-party browser connectors such as Claude.ai still require the OAuth browser-flow and connector-registration work to complete before users can authenticate from that product.
|
|
697
|
-
Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, file
|
|
715
|
+
Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, API-authoritative file leases, and attention requests to local MCP clients. File-lease lifecycle operations never write session events; install edit preflights with `sl session guard-install` and remove them with `sl session guard-uninstall` before any CLI rollback. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md) and [docs/file-leases.md](docs/file-leases.md).
|
|
698
716
|
Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
|
|
699
717
|
|
|
700
718
|
MCP operator verification:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinelayer-cli",
|
|
3
|
-
"version": "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",
|
|
@@ -66,8 +67,7 @@
|
|
|
66
67
|
"zod": "4.1.12"
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
|
69
|
-
"c8": "
|
|
70
|
-
"license-checker-rseidelsohn": "4.4.2"
|
|
70
|
+
"c8": "11.0.0"
|
|
71
71
|
},
|
|
72
72
|
"overrides": {
|
|
73
73
|
"lighthouse": {
|
|
@@ -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.
|
package/src/auth/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|