toolroll 0.9.57 → 0.9.59
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/CHANGELOG.md +15 -0
- package/dist/action-ledger.js +10 -5
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/export.js +2 -2
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/mcp-http.js +5 -5
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +23 -11
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +16 -1
- package/dist/serve.js +15 -0
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/render-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +3 -1
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +72 -12
- package/dist/store.js +367 -105
- package/dist/task-status.js +3 -1
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +10 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- package/dist/session-server.js +0 -39
package/dist/builder.js
CHANGED
|
@@ -857,9 +857,11 @@ export async function build(store, request) {
|
|
|
857
857
|
// still-blocked history) is left alone rather than having a
|
|
858
858
|
// revision 1 invented underneath it.
|
|
859
859
|
if (planDocument !== null && store.latestPlanRevision(taskRef) === null) {
|
|
860
|
+
// Revision 1 for a task planned once; past every earlier generation's revisions for one planned again (v119).
|
|
861
|
+
planRevisionNumber = store.nextPlanRevisionNumber(taskRef);
|
|
860
862
|
planRevisionId = store.insertPlanRevision({
|
|
861
863
|
taskRef,
|
|
862
|
-
revision:
|
|
864
|
+
revision: planRevisionNumber,
|
|
863
865
|
artifact: planArtifact.id,
|
|
864
866
|
parentHash: null,
|
|
865
867
|
reason: "the plan the operator approved",
|
|
@@ -1803,7 +1805,7 @@ function settleRevisionProposal(captured, binding) {
|
|
|
1803
1805
|
if (latest !== null && latest.status === "blocked") {
|
|
1804
1806
|
return broke("the agent proposed a plan revision, but one is already awaiting your approval on this task");
|
|
1805
1807
|
}
|
|
1806
|
-
const revisionNumber = (
|
|
1808
|
+
const revisionNumber = store.nextPlanRevisionNumber(taskRef);
|
|
1807
1809
|
// Stored re-serialized from the validated shape, never the agent's raw
|
|
1808
1810
|
// bytes: what a later brief quotes and hash-verifies is exactly what this
|
|
1809
1811
|
// parser admitted.
|
package/dist/chat-channel.d.ts
CHANGED
|
@@ -81,6 +81,7 @@ export declare function confirmedLink(store: Store, outcome: DoorOutcome, propos
|
|
|
81
81
|
export declare function proposalPreview(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): {
|
|
82
82
|
text: string;
|
|
83
83
|
buttons: boolean;
|
|
84
|
+
closed?: true;
|
|
84
85
|
};
|
|
85
86
|
/** The card after it resolves, from the recorded outcome — the same words every surface shows. */
|
|
86
87
|
export declare function proposalOutcomeText(proposal: LeadProposal): string;
|
package/dist/chat-channel.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
|
|
2
2
|
import { FLOW_HREF } from "./flow-engine.js";
|
|
3
|
+
import { closedQuestionWords } from "./decision.js";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, sharedActionAllowsChallenge, CHAT_ACTIONS, } from "./chat-actions.js";
|
|
5
6
|
import { isDirectChatProvider, subscriptionCredentialKey } from "./converse.js";
|
|
@@ -366,6 +367,8 @@ export function proposalPreview(store, proposal, repos, channel = null) {
|
|
|
366
367
|
case "answer": {
|
|
367
368
|
const id = typeof payload["decision"] === "number" ? payload["decision"] : null;
|
|
368
369
|
const decision = id === null ? null : store.getDecision(id);
|
|
370
|
+
if (decision?.supersededReason != null)
|
|
371
|
+
return { text: closedQuestionWords(decision.supersededReason), buttons: false, closed: true };
|
|
369
372
|
const pick = t("option", 64);
|
|
370
373
|
const irreversible = payload["reversible"] === false;
|
|
371
374
|
const body = decision === null
|
package/dist/chat-delivery.js
CHANGED
|
@@ -600,6 +600,8 @@ export function applyChatAction(options, event, binding, repos) {
|
|
|
600
600
|
const phase = action.phase;
|
|
601
601
|
if (proposal.state !== "pending")
|
|
602
602
|
content = { text: proposalOutcomeText(proposal), edit: event.ts };
|
|
603
|
+
else if (preview.closed)
|
|
604
|
+
content = { text: preview.text, edit: event.ts };
|
|
603
605
|
else if (!preview.buttons ||
|
|
604
606
|
preview.text.length > (options.maxProposal ?? 10_000))
|
|
605
607
|
content = { ...content, text: "Review this action in Toolroll." };
|
package/dist/cli-http.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Store } from './store.js';
|
|
|
3
3
|
import type { Principal } from './operate.js';
|
|
4
4
|
import { type RemoteCommandLookup } from './remote-command.js';
|
|
5
5
|
import { type Admission, type BudgetLimit } from './request-budget.js';
|
|
6
|
+
import type { SessionRunner } from './session-remote.js';
|
|
6
7
|
/**
|
|
7
8
|
* `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
|
|
8
9
|
* server runs that same command as that person. There is no per-resource API here and no second policy: the transport
|
|
@@ -11,6 +12,10 @@ import { type Admission, type BudgetLimit } from './request-budget.js';
|
|
|
11
12
|
*
|
|
12
13
|
* Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
|
|
13
14
|
* carrying a browser Origin is refused, so a page can never drive it.
|
|
15
|
+
*
|
|
16
|
+
* `session …` (D7) is the one command the server answers through its own owner rather than runOperateAs: native
|
|
17
|
+
* session operations need the server's live coding workspace (session-remote.ts). They are admitted, budgeted and
|
|
18
|
+
* refused here exactly like every other command.
|
|
14
19
|
*/
|
|
15
20
|
/** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
|
|
16
21
|
* drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
|
|
@@ -45,6 +50,8 @@ export type CliHttpOptions = {
|
|
|
45
50
|
modeOf?: RemoteCommandLookup | undefined;
|
|
46
51
|
/** The token's request budget (request-budget.ts), charged once per request before its body is read. */
|
|
47
52
|
admit?: ((principal: Principal) => Admission) | undefined;
|
|
53
|
+
/** Native session operations (`session …`), run by the server's session owner; absent where none is ready. */
|
|
54
|
+
session?: SessionRunner | undefined;
|
|
48
55
|
store: Store;
|
|
49
56
|
};
|
|
50
57
|
/** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
|
package/dist/cli-http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { adapterPolicy } from "./server/route-policy.js";
|
|
2
2
|
import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
|
|
3
3
|
import { limitWords } from './request-budget.js';
|
|
4
|
+
import { TOKEN_REPLACEMENT } from './password-bearer.js';
|
|
4
5
|
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
5
6
|
export const CLI_FILES_BYTES = 256 * 1024;
|
|
6
7
|
/** The whole JSON body: the files plus argv and JSON escaping headroom. */
|
|
@@ -8,11 +9,15 @@ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
|
|
|
8
9
|
const ARGV_MAX = 256;
|
|
9
10
|
const ARG_BYTES = 16 * 1024;
|
|
10
11
|
const FILES_MAX = 16;
|
|
12
|
+
/** What a missing or unusable token hears: how to get one, and how to save it. */
|
|
13
|
+
const SIGN_IN = `Sign in with an API token: ${TOKEN_REPLACEMENT.charAt(0).toLowerCase()}${TOKEN_REPLACEMENT.slice(1)}, then toolroll connect <origin> --token-stdin.`;
|
|
11
14
|
function send(response, status, value, headers = {}) {
|
|
12
15
|
if (response.destroyed || response.writableEnded)
|
|
13
16
|
return;
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
// A declared length: the client can tell a complete answer from a cut-off one.
|
|
18
|
+
const text = JSON.stringify(value);
|
|
19
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(Buffer.byteLength(text)), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
20
|
+
response.end(text);
|
|
16
21
|
}
|
|
17
22
|
async function body(request) {
|
|
18
23
|
const chunks = [];
|
|
@@ -83,7 +88,7 @@ export async function handleCliHttp(request, response, options) {
|
|
|
83
88
|
if (names.includes('cookie'))
|
|
84
89
|
return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
|
|
85
90
|
if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
|
|
86
|
-
return reject(401, 'unauthenticated',
|
|
91
|
+
return reject(401, 'unauthenticated', SIGN_IN);
|
|
87
92
|
if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
|
|
88
93
|
return reject(415, 'invalid-content-type', 'Send application/json.');
|
|
89
94
|
if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
|
|
@@ -96,7 +101,7 @@ export async function handleCliHttp(request, response, options) {
|
|
|
96
101
|
return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
|
|
97
102
|
}
|
|
98
103
|
if (!principal)
|
|
99
|
-
return reject(401, 'unauthenticated',
|
|
104
|
+
return reject(401, 'unauthenticated', `This API token is not valid (expired, revoked or its person removed). ${TOKEN_REPLACEMENT}.`);
|
|
100
105
|
// Counted once here, however long the command then holds the request (a task wait is one request).
|
|
101
106
|
const admitted = options.admit === undefined ? { ok: true } : options.admit(principal);
|
|
102
107
|
if (!admitted.ok && admitted.status === 503)
|
|
@@ -118,6 +123,8 @@ export async function handleCliHttp(request, response, options) {
|
|
|
118
123
|
const parsed = cliRequest(value);
|
|
119
124
|
if ('problem' in parsed)
|
|
120
125
|
return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
|
|
126
|
+
if (parsed.argv[0] === 'session')
|
|
127
|
+
return runSession(response, options, principal, parsed);
|
|
121
128
|
const row = (options.modeOf ?? contractRow)(parsed.argv);
|
|
122
129
|
if (row?.mode === 'step-up')
|
|
123
130
|
return reject(403, 'step-up', STEP_UP_MESSAGE);
|
|
@@ -154,3 +161,22 @@ export async function handleCliHttp(request, response, options) {
|
|
|
154
161
|
send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
|
|
155
162
|
return true;
|
|
156
163
|
}
|
|
164
|
+
/** `session …`: the prompt is the request's one file; the session owner answers one session envelope on stdout. */
|
|
165
|
+
async function runSession(response, options, principal, parsed) {
|
|
166
|
+
const refuse = (status, code, message) => { send(response, status, { ok: false, code, message }); return true; };
|
|
167
|
+
if (options.session === undefined)
|
|
168
|
+
return refuse(501, 'remote-unavailable', 'This server cannot run session commands. Update it, or use the console.');
|
|
169
|
+
const at = parsed.argv.indexOf('--file'), file = at < 0 ? undefined : parsed.argv[at + 1];
|
|
170
|
+
if (Object.keys(parsed.files).some(path => path !== file))
|
|
171
|
+
return refuse(400, 'invalid-request', 'Each file must name a declared input file argument.');
|
|
172
|
+
const out = [];
|
|
173
|
+
let exitCode;
|
|
174
|
+
try {
|
|
175
|
+
exitCode = await options.session(principal, parsed.argv, parsed.files, line => { out.push(`${line}\n`); });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return refuse(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
|
|
179
|
+
}
|
|
180
|
+
send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -424,7 +424,8 @@ async function dispatch(argv, write, mainOptions) {
|
|
|
424
424
|
if (first === "session") {
|
|
425
425
|
// D5: native coding sessions are deprecated. They still run this release; each use says so once, on stderr.
|
|
426
426
|
(mainOptions.session?.stderr ?? (line => process.stderr.write(`${line}\n`)))(deprecationWarning(`session ${rest.find(arg => !arg.startsWith("-")) ?? ""}`.trim(), "session"));
|
|
427
|
-
|
|
427
|
+
// D7: it runs on the saved connection over /api/cli, signed in with that profile's API token.
|
|
428
|
+
return runSessionCommand(rest, write, { ...(mainOptions.remote ?? mainOptions.team), ...mainOptions.session });
|
|
428
429
|
}
|
|
429
430
|
if (first === "demo")
|
|
430
431
|
return runDemoCommand(rest, write);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical difference between two sets of contract terms — a task's goal, exclusions, touches and acceptance
|
|
3
|
+
* rubric. Pure and import-light, so both the planner's source (planner-source.ts) and scope filing (scope.ts, which
|
|
4
|
+
* restarts planning when the terms truly change) read the same comparison.
|
|
5
|
+
*/
|
|
6
|
+
import type { AcceptanceCriterion, EvidenceKind } from "./scope.js";
|
|
7
|
+
/** The four contract fields a planner may propose to amend. */
|
|
8
|
+
export type ContractTerms = {
|
|
9
|
+
goal: string;
|
|
10
|
+
outOfScope: string | null;
|
|
11
|
+
touches: string[];
|
|
12
|
+
acceptance: AcceptanceCriterion[];
|
|
13
|
+
};
|
|
14
|
+
export type ContractChange = {
|
|
15
|
+
field: "goal";
|
|
16
|
+
kind: "changed";
|
|
17
|
+
before: string;
|
|
18
|
+
after: string;
|
|
19
|
+
} | {
|
|
20
|
+
field: "outOfScope";
|
|
21
|
+
kind: "added" | "removed" | "changed";
|
|
22
|
+
before: string | null;
|
|
23
|
+
after: string | null;
|
|
24
|
+
} | {
|
|
25
|
+
field: "touches";
|
|
26
|
+
kind: "added" | "removed";
|
|
27
|
+
path: string;
|
|
28
|
+
} | {
|
|
29
|
+
field: "acceptance";
|
|
30
|
+
kind: "added" | "removed" | "changed";
|
|
31
|
+
id: string;
|
|
32
|
+
before: {
|
|
33
|
+
statement: string;
|
|
34
|
+
evidence: EvidenceKind[];
|
|
35
|
+
how: string | null;
|
|
36
|
+
} | null;
|
|
37
|
+
after: {
|
|
38
|
+
statement: string;
|
|
39
|
+
evidence: EvidenceKind[];
|
|
40
|
+
how: string | null;
|
|
41
|
+
} | null;
|
|
42
|
+
/** For `changed`: which parts moved. `how` is advisory and unsigned,
|
|
43
|
+
* but a planner rewriting it is still a change the operator sees. */
|
|
44
|
+
moved: ("statement" | "evidence" | "how")[];
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Every addition, change, and removal between the filed contract and a
|
|
48
|
+
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
49
|
+
* count (the scope digest ignores them too); everything else does,
|
|
50
|
+
* including the advisory `how`, because the operator wrote it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const trimmed = (text) => {
|
|
2
|
+
if (text === null)
|
|
3
|
+
return null;
|
|
4
|
+
const t = text.trim();
|
|
5
|
+
return t === "" ? null : t;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Every addition, change, and removal between the filed contract and a
|
|
9
|
+
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
10
|
+
* count (the scope digest ignores them too); everything else does,
|
|
11
|
+
* including the advisory `how`, because the operator wrote it.
|
|
12
|
+
*/
|
|
13
|
+
export function contractChangesOf(filed, proposed) {
|
|
14
|
+
const changes = [];
|
|
15
|
+
if (filed.goal.trim() !== proposed.goal.trim()) {
|
|
16
|
+
changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
|
|
17
|
+
}
|
|
18
|
+
const filedOut = trimmed(filed.outOfScope);
|
|
19
|
+
const proposedOut = trimmed(proposed.outOfScope);
|
|
20
|
+
if (filedOut !== proposedOut) {
|
|
21
|
+
changes.push({
|
|
22
|
+
field: "outOfScope",
|
|
23
|
+
kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
|
|
24
|
+
before: filed.outOfScope,
|
|
25
|
+
after: proposed.outOfScope,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
29
|
+
const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
30
|
+
for (const path of filedTouches)
|
|
31
|
+
if (!proposedTouches.has(path))
|
|
32
|
+
changes.push({ field: "touches", kind: "removed", path });
|
|
33
|
+
for (const path of proposedTouches)
|
|
34
|
+
if (!filedTouches.has(path))
|
|
35
|
+
changes.push({ field: "touches", kind: "added", path });
|
|
36
|
+
const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
|
|
37
|
+
const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
|
|
38
|
+
const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
|
|
39
|
+
for (const [id, before] of filedById) {
|
|
40
|
+
const after = proposedById.get(id);
|
|
41
|
+
if (after === undefined) {
|
|
42
|
+
changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const moved = [];
|
|
46
|
+
if (before.statement.trim() !== after.statement.trim())
|
|
47
|
+
moved.push("statement");
|
|
48
|
+
if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
|
|
49
|
+
moved.push("evidence");
|
|
50
|
+
if (trimmed(before.how) !== trimmed(after.how))
|
|
51
|
+
moved.push("how");
|
|
52
|
+
if (moved.length > 0) {
|
|
53
|
+
changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [id, after] of proposedById) {
|
|
57
|
+
if (!filedById.has(id))
|
|
58
|
+
changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
|
|
59
|
+
}
|
|
60
|
+
return changes;
|
|
61
|
+
}
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
|
|
3
3
|
*
|
|
4
4
|
* A coordinator is the ONE credential the MCP surface accepts: minted by
|
|
5
|
-
* an operator password ceremony, hashed at rest
|
|
5
|
+
* an operator password ceremony, hashed at rest (a `credential` row of kind
|
|
6
|
+
* 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
|
|
6
7
|
* rate-limited, revocable — and able to do exactly one thing: file a
|
|
7
8
|
* proposal through the canonical door. What it files is an ordinary
|
|
8
9
|
* UNAPPROVED task; admission is the operator's existing scope ceremony,
|
package/dist/coordinator.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
|
|
3
3
|
*
|
|
4
4
|
* A coordinator is the ONE credential the MCP surface accepts: minted by
|
|
5
|
-
* an operator password ceremony, hashed at rest
|
|
5
|
+
* an operator password ceremony, hashed at rest (a `credential` row of kind
|
|
6
|
+
* 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
|
|
6
7
|
* rate-limited, revocable — and able to do exactly one thing: file a
|
|
7
8
|
* proposal through the canonical door. What it files is an ordinary
|
|
8
9
|
* UNAPPROVED task; admission is the operator's existing scope ceremony,
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
* the exported-cast mistake is named in the spec and not repeated here.
|
|
15
16
|
* Steering and every operator-speech surface cannot accept this type.
|
|
16
17
|
*/
|
|
17
|
-
import { createHash
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { hashSecret, mintCredential } from "./api-tokens.js";
|
|
18
20
|
import { canonicalRepos } from "./runner.js";
|
|
19
21
|
import { fileTaskProposal } from "./proposal.js";
|
|
20
22
|
import { describeScope } from "./scope.js";
|
|
@@ -27,18 +29,7 @@ function verified(row) {
|
|
|
27
29
|
return row;
|
|
28
30
|
}
|
|
29
31
|
const NAME = /^[a-z0-9-]{1,32}$/;
|
|
30
|
-
const CID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
31
32
|
const KEY = /^[\x21-\x7e]{8,64}$/;
|
|
32
|
-
function hashSecret(token) {
|
|
33
|
-
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
34
|
-
}
|
|
35
|
-
function mintCid() {
|
|
36
|
-
const bytes = randomBytes(12);
|
|
37
|
-
let cid = "";
|
|
38
|
-
for (const b of bytes)
|
|
39
|
-
cid += CID_ALPHABET[b % CID_ALPHABET.length];
|
|
40
|
-
return cid;
|
|
41
|
-
}
|
|
42
33
|
export function mintCoordinator(store, input) {
|
|
43
34
|
const days = input.days ?? 90;
|
|
44
35
|
if (!Number.isInteger(days) || days < 1 || days > 365)
|
|
@@ -53,15 +44,16 @@ export function mintCoordinator(store, input) {
|
|
|
53
44
|
if (input.repos.length === 0)
|
|
54
45
|
return { ok: false, reason: "no-repos" };
|
|
55
46
|
const repos = canonicalRepos(input.repos);
|
|
56
|
-
const token = (input.newToken ?? (() =>
|
|
47
|
+
const token = (input.newToken ?? (() => mintCredential("coordinator").token))();
|
|
57
48
|
return store.transact(() => {
|
|
58
49
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
59
|
-
const cid = (input.newCid ??
|
|
50
|
+
const cid = (input.newCid ?? (() => mintCredential("coordinator").id))();
|
|
60
51
|
try {
|
|
52
|
+
// Its generation: which coordinator of this name it is (revoke-and-remint keeps the earlier one's history apart).
|
|
61
53
|
store.handle
|
|
62
|
-
.prepare(`INSERT INTO
|
|
63
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
64
|
-
.run(cid, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt);
|
|
54
|
+
.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, created_by, created_at, expires_at, generation)
|
|
55
|
+
VALUES (?, 'coordinator', ?, ?, ?, ?, 'act', ?, ?, ?, ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'coordinator' AND name = ?))`)
|
|
56
|
+
.run(cid, cid, input.by, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt, name);
|
|
65
57
|
return { ok: true, cid, token, repos };
|
|
66
58
|
}
|
|
67
59
|
catch (error) {
|
|
@@ -81,9 +73,8 @@ export function mintCoordinator(store, input) {
|
|
|
81
73
|
* the stored hash; the caller re-runs this INSIDE any mutating
|
|
82
74
|
* transaction (the startup check is a courtesy; the txn check is law). */
|
|
83
75
|
export function authenticateCoordinator(store, token) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
.get(hashSecret(token));
|
|
76
|
+
const found = store.presentedCredential(token);
|
|
77
|
+
const row = found?.kind === "coordinator" ? store.handle.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'coordinator'").get(found.id) : undefined;
|
|
87
78
|
if (row === undefined)
|
|
88
79
|
return { ok: false, reason: "unknown" };
|
|
89
80
|
if (row["revoked_at"] !== null)
|
|
@@ -94,23 +85,23 @@ export function authenticateCoordinator(store, token) {
|
|
|
94
85
|
return {
|
|
95
86
|
ok: true,
|
|
96
87
|
who: verified({
|
|
97
|
-
cid: String(row["
|
|
88
|
+
cid: String(row["id"]),
|
|
98
89
|
name: String(row["name"]),
|
|
99
|
-
repos: JSON.parse(String(row["
|
|
90
|
+
repos: JSON.parse(String(row["projects_json"])),
|
|
100
91
|
perHour: Number(row["per_hour"]),
|
|
101
92
|
}),
|
|
102
93
|
};
|
|
103
94
|
}
|
|
104
95
|
export function revokeCoordinator(store, cid, by, now) {
|
|
105
96
|
return store.transact(() => {
|
|
106
|
-
const row = store.handle.prepare("SELECT revoked_at FROM
|
|
97
|
+
const row = store.handle.prepare("SELECT revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(cid);
|
|
107
98
|
if (row === undefined)
|
|
108
99
|
return { ok: false, reason: "unknown" };
|
|
109
100
|
if (row["revoked_at"] !== null)
|
|
110
101
|
return { ok: false, reason: "already-revoked" };
|
|
111
102
|
store.handle
|
|
112
|
-
.prepare("UPDATE
|
|
113
|
-
.run(now.toISOString(), cid);
|
|
103
|
+
.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'")
|
|
104
|
+
.run(now.toISOString(), by, cid);
|
|
114
105
|
// The revocation event rides the SAME transaction as the state change,
|
|
115
106
|
// and the credential's pending proposals expire with it (mate arc v3).
|
|
116
107
|
store.handle
|
|
@@ -122,13 +113,13 @@ export function revokeCoordinator(store, cid, by, now) {
|
|
|
122
113
|
}
|
|
123
114
|
export function listCoordinators(store) {
|
|
124
115
|
return store.handle
|
|
125
|
-
.prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.
|
|
126
|
-
FROM
|
|
116
|
+
.prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.kind = 'filed') AS last_filed
|
|
117
|
+
FROM credential c WHERE c.kind = 'coordinator' ORDER BY c.created_at, c.id`)
|
|
127
118
|
.all()
|
|
128
119
|
.map(row => ({
|
|
129
|
-
cid: String(row["
|
|
120
|
+
cid: String(row["id"]),
|
|
130
121
|
name: String(row["name"]),
|
|
131
|
-
repos: JSON.parse(String(row["
|
|
122
|
+
repos: JSON.parse(String(row["projects_json"])),
|
|
132
123
|
perHour: Number(row["per_hour"]),
|
|
133
124
|
createdBy: String(row["created_by"]),
|
|
134
125
|
createdAt: String(row["created_at"]),
|
|
@@ -253,7 +244,7 @@ export function fileCoordinatorProposal(store, token, input, now) {
|
|
|
253
244
|
],
|
|
254
245
|
}),
|
|
255
246
|
...(input.deliverable === undefined ? {} : { deliverable: input.deliverable }),
|
|
256
|
-
filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM
|
|
247
|
+
filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM credential WHERE id = ? AND kind = 'coordinator'").get(who.cid)?.["created_by"] ?? null, kind: "coordinator" },
|
|
257
248
|
admittedRepos: who.repos,
|
|
258
249
|
}, now);
|
|
259
250
|
if (!made.ok)
|
package/dist/decision.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export declare const LIMITS: {
|
|
|
43
43
|
};
|
|
44
44
|
/** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
|
|
45
45
|
export declare const NOTE_BYTE_CAP: 16000;
|
|
46
|
+
/** Why a question closed without an answer (v119), in plain words. */
|
|
47
|
+
export declare function closedQuestionReason(reason: "scope-changed" | "cancelled"): string;
|
|
48
|
+
/** The same, said once on one line wherever its answer would show. */
|
|
49
|
+
export declare function closedQuestionWords(reason: "scope-changed" | "cancelled"): string;
|
|
46
50
|
/**
|
|
47
51
|
* The one validator every note passes — before a draft persists AND inside
|
|
48
52
|
* the answer CAS, which stays the final gate. Trims, then refuses empty,
|
package/dist/decision.js
CHANGED
|
@@ -25,6 +25,14 @@ export const LIMITS = {
|
|
|
25
25
|
};
|
|
26
26
|
/** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
|
|
27
27
|
export const NOTE_BYTE_CAP = TEXT_LIMITS.noteBytes;
|
|
28
|
+
/** Why a question closed without an answer (v119), in plain words. */
|
|
29
|
+
export function closedQuestionReason(reason) {
|
|
30
|
+
return reason === "scope-changed" ? "The scope changed, so planning started again." : "The task was cancelled.";
|
|
31
|
+
}
|
|
32
|
+
/** The same, said once on one line wherever its answer would show. */
|
|
33
|
+
export function closedQuestionWords(reason) {
|
|
34
|
+
return `Closed without an answer. ${closedQuestionReason(reason)}`;
|
|
35
|
+
}
|
|
28
36
|
/**
|
|
29
37
|
* Unicode that reorders or breaks lines invisibly: bidi controls and the
|
|
30
38
|
* line/paragraph separators. A note carrying these can spoof what a
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Separate from orders.db: no live writer is reserved while deployment drains or backs up.
|
|
2
|
+
* SQLite owns the OS lock until close/process death, including SIGKILL. Never unlink this file:
|
|
3
|
+
* replacing its inode would let two contenders hold different locks for the same journal. */
|
|
4
|
+
export type DeploymentLock = {
|
|
5
|
+
assertHeld(journalFile: string): void;
|
|
6
|
+
release(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function acquireDeploymentLock(journalFile: string, waitMs?: number): DeploymentLock;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { closeSync, constants, lstatSync, openSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, basename } from "node:path";
|
|
4
|
+
const canonical = (file) => join(realpathSync(dirname(file)), basename(file));
|
|
5
|
+
export function acquireDeploymentLock(journalFile, waitMs = 5000) {
|
|
6
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 5000)
|
|
7
|
+
throw Error("Invalid deployment lock wait.");
|
|
8
|
+
const journal = canonical(journalFile), file = `${journal}.lock.sqlite`;
|
|
9
|
+
// Only create a missing file. On POSIX, closing ANY independently opened descriptor
|
|
10
|
+
// for an existing inode can drop this process's fcntl lock, even another connection's.
|
|
11
|
+
// Let SQLite manage all descriptors for an existing coordination file.
|
|
12
|
+
try {
|
|
13
|
+
const fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
14
|
+
closeSync(fd);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== "EEXIST")
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
if (!lstatSync(file).isFile())
|
|
21
|
+
throw Error(`Unexpected deployment lock: ${file}`);
|
|
22
|
+
// Lazy, as elsewhere in the CLI: merely importing the help must not load SQLite.
|
|
23
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
24
|
+
const db = new DatabaseSync(file);
|
|
25
|
+
try {
|
|
26
|
+
db.exec(`PRAGMA busy_timeout=${waitMs}; BEGIN EXCLUSIVE`);
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
db.close();
|
|
30
|
+
throw new Error(`Deployment is busy or its lock is unavailable: ${journal}. Let it finish, then retry.`, { cause });
|
|
31
|
+
}
|
|
32
|
+
let held = true;
|
|
33
|
+
return {
|
|
34
|
+
assertHeld(expected) {
|
|
35
|
+
if (!held || canonical(expected) !== journal)
|
|
36
|
+
throw Error("The deployment journal lock is not held.");
|
|
37
|
+
},
|
|
38
|
+
release() {
|
|
39
|
+
if (!held)
|
|
40
|
+
return;
|
|
41
|
+
db.close();
|
|
42
|
+
held = false;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
94
94
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
95
95
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
96
96
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
97
|
+
version?: never;
|
|
97
98
|
active: boolean;
|
|
98
99
|
phase: string;
|
|
99
100
|
detail: string;
|
|
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
107
108
|
wasRunning?: never;
|
|
108
109
|
backupPath?: never;
|
|
109
110
|
updateId?: never;
|
|
110
|
-
version?: never;
|
|
111
111
|
buildId?: never;
|
|
112
112
|
workDir?: never;
|
|
113
113
|
} | {
|
package/dist/dispatch.js
CHANGED
|
@@ -319,7 +319,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
|
|
|
319
319
|
// this is where the task page says so.
|
|
320
320
|
const sourceProblem = plannerSourceProblemOf(store, taskId);
|
|
321
321
|
if (sourceProblem !== null)
|
|
322
|
-
return answer("planner-source", "waiting",
|
|
322
|
+
return answer("planner-source", "waiting", sourceProblem.title, sourceProblem.detail, { action: "write-scope", role });
|
|
323
323
|
return answer("planning-ready", "retrying", "Planner ready", "An eligible worker can draft the scope on the next pass.", { role });
|
|
324
324
|
}
|
|
325
325
|
if (role === "scout")
|
package/dist/export.js
CHANGED
|
@@ -33,7 +33,7 @@ const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "chat_pair", "oa
|
|
|
33
33
|
const DERIVED_TABLE = /^(sqlite_|memory_search)/;
|
|
34
34
|
/** Columns that hold a secret (or what stands in for one): never exported. */
|
|
35
35
|
const SECRET_COLUMNS = new Set([
|
|
36
|
-
"
|
|
36
|
+
"credential.secret_hash", "approver.credential_hash", "runner.credential_hash", "invite.token_hash",
|
|
37
37
|
"flow_trigger.hook_hash", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
|
|
38
38
|
"chat_turn.credential_key", "lead_session.credential_key", "mate_turn.credential_key", "chat_runtime.push_url", "workflow_preview.token",
|
|
39
39
|
"quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
|
|
@@ -56,7 +56,7 @@ export function categoryOf(table) {
|
|
|
56
56
|
[/^subagent/, "subagents"],
|
|
57
57
|
[/^(flow|workflow_)/, "flows"],
|
|
58
58
|
[/^(chat_|lead_|team_|notification|push_)/, "chats"],
|
|
59
|
-
[/^(approver|
|
|
59
|
+
[/^(approver|credential$|invite|sso_identity|coordinator_|oauth_)/, "people"],
|
|
60
60
|
[/^(run|artifact|claim|worktree$|execution_slot|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
|
|
61
61
|
[/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
|
|
62
62
|
[/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
|
package/dist/lead-cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
|
|
2
|
+
import { closedQuestionWords } from "./decision.js";
|
|
2
3
|
/**
|
|
3
4
|
* `toolroll chat` (mate arc §6): the same thread the console shows,
|
|
4
5
|
* driven from a terminal. The password is typed once — it mints the lead
|
|
@@ -45,6 +46,8 @@ export function answerContextLines(store, payload) {
|
|
|
45
46
|
const decision = id === null ? null : store.getDecision(id);
|
|
46
47
|
if (decision === null)
|
|
47
48
|
return [" (the decision is gone)"];
|
|
49
|
+
if (decision.supersededReason !== null)
|
|
50
|
+
return [` ${decision.question}`, ` ${closedQuestionWords(decision.supersededReason)}`];
|
|
48
51
|
const pick = typeof payload["option"] === "string" ? payload["option"] : "";
|
|
49
52
|
return [
|
|
50
53
|
` ${decision.question}${decision.state !== "open" ? ` [${decision.state}]` : ""}`,
|
package/dist/lead-doors.js
CHANGED
|
@@ -14,6 +14,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
|
|
|
14
14
|
* scope was rewritten, the decision answered) is a refusal, never a
|
|
15
15
|
* silent re-read.
|
|
16
16
|
*/
|
|
17
|
+
import { closedQuestionWords } from "./decision.js";
|
|
17
18
|
import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
|
|
18
19
|
import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
|
|
19
20
|
import { isVerifiedApprover, reproveApprover } from "./principal.js";
|
|
@@ -317,6 +318,8 @@ function executeProposal(store, actor, kind, payload, now, options) {
|
|
|
317
318
|
return refuse("stale", "that option no longer exists on the decision");
|
|
318
319
|
// The proposal contract is "open": a decision past its deadline — swept
|
|
319
320
|
// or not — is not answered through a card (v3 review, finding 7).
|
|
321
|
+
if (decision.supersededReason !== null)
|
|
322
|
+
return refuse("stale", closedQuestionWords(decision.supersededReason));
|
|
320
323
|
if (decision.state === "answered")
|
|
321
324
|
return refuse("already-answered", "already answered — somebody got there first");
|
|
322
325
|
if (decision.state !== "open" || (decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime())) {
|
package/dist/lead-tools.js
CHANGED
|
@@ -284,12 +284,13 @@ export function decisionOver(store, repos, decisionId, now) {
|
|
|
284
284
|
return null;
|
|
285
285
|
// "Open" is derived, not stored (v3 review, finding 7): a decision past
|
|
286
286
|
// its deadline is expired here whether or not the sweep has run.
|
|
287
|
-
const state = decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
|
|
287
|
+
const state = decision.supersededReason !== null ? "closed" : decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
|
|
288
288
|
return {
|
|
289
289
|
repoIndex,
|
|
290
290
|
decision: decision.id,
|
|
291
291
|
task: ref.externalId,
|
|
292
292
|
state,
|
|
293
|
+
...(decision.supersededReason === null ? {} : { closedBecause: decision.supersededReason }),
|
|
293
294
|
question: decision.question,
|
|
294
295
|
options: decision.options.map(one => ({ id: one.id, label: one.label, reversible: one.reversible, consequence: one.consequence })),
|
|
295
296
|
ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(decision.createdAt)) / 3_600_000)),
|
package/dist/mcp-http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { adapterPolicy } from "./server/route-policy.js";
|
|
2
2
|
import { PACKAGE_VERSION } from "./version.js";
|
|
3
|
-
import {
|
|
3
|
+
import { tokenLive, tokenProjects } from "./api-tokens.js";
|
|
4
4
|
import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
|
|
5
5
|
import { authenticateCoordinator } from "./coordinator.js";
|
|
6
6
|
import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
|
|
@@ -26,10 +26,10 @@ export function createMcpHttp(options) {
|
|
|
26
26
|
if (presented.startsWith("so_")) {
|
|
27
27
|
if (!options.signedIn(request))
|
|
28
28
|
return null;
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (
|
|
29
|
+
// The secret is checked here too (the one credential check), so this door never rests on how signedIn reads the request.
|
|
30
|
+
const found = store.presentedCredential(presented);
|
|
31
|
+
const kept = found?.kind === "person" ? store.apiTokenSecret(found.id) : null;
|
|
32
|
+
if (kept === null)
|
|
33
33
|
return null;
|
|
34
34
|
if (!tokenLive(kept.row, options.clock().getTime()))
|
|
35
35
|
return null;
|