@intentic/sandbox-contract 1.243.0 → 1.245.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 +4 -1
- package/dist/agent-catalog.d.ts +2 -1
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +3 -2
- package/dist/agent-catalog.js.map +1 -1
- package/dist/batch-runs.d.ts +32 -0
- package/dist/batch-runs.d.ts.map +1 -0
- package/dist/batch-runs.js +43 -0
- package/dist/batch-runs.js.map +1 -0
- package/dist/card-status.d.ts.map +1 -1
- package/dist/card-status.js +8 -1
- package/dist/card-status.js.map +1 -1
- package/dist/chores/stack.d.ts.map +1 -1
- package/dist/chores/stack.js +2 -2
- package/dist/chores/stack.js.map +1 -1
- package/dist/command-classes.d.ts +1 -0
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +2 -2
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +106 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +50 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +138 -92
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.d.ts +50 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.js +36 -1
- package/dist/contracts/secrets.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +37 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +67 -28
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/definition.d.ts +8 -0
- package/dist/definition.d.ts.map +1 -1
- package/dist/events.d.ts +440 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +40 -4
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +294 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/safety-policy.d.ts +6 -0
- package/dist/safety-policy.d.ts.map +1 -1
- package/dist/safety-policy.js +1 -0
- package/dist/safety-policy.js.map +1 -1
- package/dist/schemas/agents.d.ts +4 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +3 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +1 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/computers.d.ts +1 -1
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +2 -1
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/engines.d.ts +2 -0
- package/dist/schemas/engines.d.ts.map +1 -1
- package/dist/schemas/engines.js +1 -0
- package/dist/schemas/engines.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +4 -0
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +7 -0
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/secrets.d.ts +75 -0
- package/dist/schemas/secrets.d.ts.map +1 -1
- package/dist/schemas/secrets.js +44 -0
- package/dist/schemas/secrets.js.map +1 -1
- package/dist/schemas/settings.d.ts +6 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +7 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/transcript-fold.d.ts.map +1 -1
- package/dist/transcript-fold.js +11 -0
- package/dist/transcript-fold.js.map +1 -1
- package/dist/workspace-state.d.ts +7 -0
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +8 -0
- package/dist/workspace-state.js.map +1 -1
- package/package.json +15 -4
- package/src/agent-catalog.test.ts +21 -4
- package/src/agent-catalog.ts +37 -13
- package/src/batch-runs.test.ts +167 -0
- package/src/batch-runs.ts +181 -0
- package/src/card-status.ts +13 -1
- package/src/chores/stack.test.ts +36 -0
- package/src/chores/stack.ts +13 -2
- package/src/command-classes.ts +8 -3
- package/src/contracts/secrets.contract.ts +67 -1
- package/src/events.ts +100 -11
- package/src/safety-policy.ts +23 -0
- package/src/schemas/agents.ts +6 -0
- package/src/schemas/computers.ts +15 -6
- package/src/schemas/engines.ts +1 -0
- package/src/schemas/plan-limits.ts +19 -0
- package/src/schemas/secrets.ts +110 -0
- package/src/schemas/settings.ts +45 -11
- package/src/transcript-fold.test.ts +47 -0
- package/src/transcript-fold.ts +11 -0
- package/src/workspace-state.test.ts +7 -0
- package/src/workspace-state.ts +28 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { STATE_DIR } from "@intentic/constants";
|
|
2
|
+
|
|
3
|
+
/* ONE BATCH RUN ENGINE, for every surface that fans an ISOLATED AGENT TURN out over a set of items and reads
|
|
4
|
+
* the answers back off disk.
|
|
5
|
+
*
|
|
6
|
+
* Three packs had written this separately — acceptance (a run per story), maintenance (a run per chore, per
|
|
7
|
+
* repository) and documentation (a run per package) — and the three copies agreed on every decision that
|
|
8
|
+
* matters and drifted on every detail that does not: `SCAN_RUNS` was 10 in one and 30 in another, one minted
|
|
9
|
+
* run ids with a per-process counter and one without (the one without could collide, and did, the moment a
|
|
10
|
+
* surface started several runs inside a millisecond), and each spelled the reporting clause its own way, so an
|
|
11
|
+
* agent's instructions for where to leave its answer depended on which screen started it.
|
|
12
|
+
*
|
|
13
|
+
* WHY THE CORE OWNS IT rather than one of them exporting it. A substrate is what other packs fire into, and a
|
|
14
|
+
* pack can be switched off: a run engine that stops existing because somebody hid a screen is not an engine.
|
|
15
|
+
* The reverse — a substrate living in one pack — is what produced the three copies, because reaching into
|
|
16
|
+
* `acceptance` for a run id is a dependency no other pack wants and reinventing it is one afternoon.
|
|
17
|
+
* `_extensions/README.md` states the rule; this is the second of the four substrates named there.
|
|
18
|
+
*
|
|
19
|
+
* WHAT A RUN IS, and the part that is load-bearing rather than incidental:
|
|
20
|
+
*
|
|
21
|
+
* • IT IS BACKED BY FILES, never by a store a pack owns. `run.json` is written before the first turn starts
|
|
22
|
+
* and each agent writes its own result beside it. So a run survives archiving the fleet agents, discarding
|
|
23
|
+
* them, closing the browser and rebuilding the image, and a browser that was shut when a turn finished picks
|
|
24
|
+
* the answer up the next time it opens.
|
|
25
|
+
* • ITS CONVERSATION IDS ARE DERIVED, so joining a run to the fleet is a filter over `GET /agents` rather than
|
|
26
|
+
* bookkeeping that can drift. This is why none of these surfaces owns session machinery: the worktree, the
|
|
27
|
+
* live status, the cost, the transcript and the `/agents/<id>` page all already exist.
|
|
28
|
+
* • IT LIVES UNDER `.intentic`, which is outside every repo (the root repo excludes it) and is bound back in
|
|
29
|
+
* SHARED for isolated turns, so an agent writing its result from inside its own worktree writes into the
|
|
30
|
+
* same tree the browser reads: nothing to land, no git noise.
|
|
31
|
+
*
|
|
32
|
+
* The manifest and result SHAPES stay with the packs. A chore's outcome vocabulary and a story's criteria have
|
|
33
|
+
* nothing to say to each other, and a substrate that tried to own both would be a union that grows a field per
|
|
34
|
+
* screen. What is here is what all three do identically: where the files go, how the ids are made, how a
|
|
35
|
+
* half-written file is survived, and what the agent is told about where to leave its answer. */
|
|
36
|
+
|
|
37
|
+
/* WHERE ONE KIND OF RUN KEEPS ITS DIRECTORIES. Taken as the tail rather than composed from a pack id, because
|
|
38
|
+
* the three existing layouts are not uniform and rewriting them would orphan every run already on disk:
|
|
39
|
+
* acceptance keeps runs under `records/artifacts/acceptance`, maintenance under `records/chores/runs`. A path
|
|
40
|
+
* is a fact about a tree that exists, not a naming opportunity. */
|
|
41
|
+
export interface BatchRunKind {
|
|
42
|
+
/* The directory holding this kind's run directories, workspace-relative, under the state dir. */
|
|
43
|
+
readonly runsDir: string;
|
|
44
|
+
/* The conversation-id prefix, two or three characters. Every conversation this kind starts carries it, so a
|
|
45
|
+
* prefix filter over `GET /agents` is the join key and not merely a naming convention. */
|
|
46
|
+
readonly prefix: string;
|
|
47
|
+
/* How many runs deep anything that READS RESULTS goes. A bound on the walk, not on what can be run: only
|
|
48
|
+
* recent runs carry news, and a workspace with hundreds of run directories must not spend a request per
|
|
49
|
+
* item to render a list or light a badge. One number per kind, shared by every reader of that kind, so a
|
|
50
|
+
* badge's idea of "recent" and a list's can never disagree. */
|
|
51
|
+
readonly scanRuns: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* The conversation id's own regex is `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$` — it lands in branch names and in
|
|
55
|
+
* paths — so this is a hard ceiling rather than a style choice. */
|
|
56
|
+
const CONVERSATION_ID_MAX = 64;
|
|
57
|
+
|
|
58
|
+
/* `r` + a base-36 millisecond, zero-padded, + a per-process counter: sortable, ~10 characters, and readable
|
|
59
|
+
* enough to match a directory to a moment. The clock is taken from the caller so this stays pure and testable.
|
|
60
|
+
*
|
|
61
|
+
* THE PADDING IS WHAT MAKES "SORTABLE" TRUE. Both copies this replaces claimed it and neither had it: a base-36
|
|
62
|
+
* number is shorter when it is smaller, so a plain `toString(36)` sorts `r1a` after `rzz` the moment two ids
|
|
63
|
+
* straddle a digit boundary. Today's milliseconds are all eight digits so nothing has gone wrong yet, and
|
|
64
|
+
* nothing will until 2059 — which is exactly the kind of latent boundary that is cheap to remove now and
|
|
65
|
+
* expensive to find later. Padded to the same width, the ids on disk are the length they already were.
|
|
66
|
+
*
|
|
67
|
+
* THE COUNTER IS NOT OPTIONAL, the other drift worth naming. A surface that fans out over items can get away
|
|
68
|
+
* without one, because its ids differ by item; a surface where one run IS one item cannot, and "run this chore
|
|
69
|
+
* in every repository" starts several inside the same millisecond. Both kinds share this function, so the
|
|
70
|
+
* safe answer is the only answer, and it costs the id one character. */
|
|
71
|
+
const TIME_DIGITS = 8;
|
|
72
|
+
let sequence = 0;
|
|
73
|
+
export const batchRunIdAt = (epochMs: number): string => `r${epochMs.toString(36).padStart(TIME_DIGITS, `0`)}${(sequence++).toString(36)}`;
|
|
74
|
+
|
|
75
|
+
/* The fleet conversation id for one item of one run, or for a run that is a single item (omit `item`).
|
|
76
|
+
*
|
|
77
|
+
* THE RUN ID SURVIVES TRUNCATION and the item is what gets cut, because the run id is how a card is attributed
|
|
78
|
+
* back to its run: lose that and a finished turn belongs to nothing. Callers that fan out must already have
|
|
79
|
+
* made their item slugs unique by suffixing, and the suffix sits at the end — exactly where the cut lands — so
|
|
80
|
+
* uniqueness holds only while the cut leaves it. In practice nothing is close: slugs are capped at 40 and run
|
|
81
|
+
* ids are ~10, well inside 64. A trailing separator left by the cut is trimmed, because `xt-r5k2-` is a
|
|
82
|
+
* conversation id the regex above would refuse. */
|
|
83
|
+
export const batchConversationId = (kind: BatchRunKind, runId: string, item?: string): string =>
|
|
84
|
+
`${kind.prefix}-${runId}${item === undefined ? `` : `-${item}`}`.slice(0, CONVERSATION_ID_MAX).replace(/[-_]+$/u, ``);
|
|
85
|
+
|
|
86
|
+
// Every conversation one kind starts, for the prefix filter over `GET /agents` that joins a run to the fleet.
|
|
87
|
+
export const batchRunPrefix = (kind: BatchRunKind): string => `${kind.prefix}-`;
|
|
88
|
+
|
|
89
|
+
// The directory holding one kind's run directories, workspace-relative. What a listing is asked for.
|
|
90
|
+
export const batchRunsDir = (kind: BatchRunKind): string => `${STATE_DIR}/${kind.runsDir}`;
|
|
91
|
+
export const batchRunDir = (kind: BatchRunKind, runId: string): string => `${batchRunsDir(kind)}/${runId}`;
|
|
92
|
+
export const batchRunManifestPath = (kind: BatchRunKind, runId: string): string => `${batchRunDir(kind, runId)}/run.json`;
|
|
93
|
+
|
|
94
|
+
/* Where one item of a run leaves its files. A run whose items are the run itself (`item` omitted) writes
|
|
95
|
+
* straight into the run directory, which is what maintenance already does and what keeps its `result.json`
|
|
96
|
+
* beside its `run.json` rather than one pointless level down. */
|
|
97
|
+
export const batchItemDir = (kind: BatchRunKind, runId: string, item?: string): string =>
|
|
98
|
+
item === undefined ? batchRunDir(kind, runId) : `${batchRunDir(kind, runId)}/${item}`;
|
|
99
|
+
export const batchResultPath = (kind: BatchRunKind, runId: string, item?: string): string => `${batchItemDir(kind, runId, item)}/result.json`;
|
|
100
|
+
|
|
101
|
+
/* A FILE THAT IS HALF-WRITTEN, or written by a build whose shape has since changed, IS SKIPPED rather than
|
|
102
|
+
* thrown on. One bad directory must not blank a whole history, and a run directory is written by an agent
|
|
103
|
+
* mid-turn, so reading one that is not finished being written is ordinary rather than exceptional.
|
|
104
|
+
*
|
|
105
|
+
* The caller supplies the shape check, because the shape is the pack's. This owns only the two failure modes
|
|
106
|
+
* every reader shares: text that is not JSON, and JSON that is not an object. */
|
|
107
|
+
export const parseBatchFile = <T>(text: string, shape: (value: Record<string, unknown>) => T | undefined): T | undefined => {
|
|
108
|
+
try {
|
|
109
|
+
const parsed: unknown = JSON.parse(text);
|
|
110
|
+
return typeof parsed !== `object` || parsed === null ? undefined : shape(parsed as Record<string, unknown>);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/* WHAT THE AGENT IS TOLD ABOUT WHERE TO LEAVE ITS ANSWER, appended to whatever prompt the pack composed.
|
|
117
|
+
*
|
|
118
|
+
* WHY THE AGENT WRITES A FILE AND NOT A ROUTE. A ledger is a daemon route, and reaching it from a turn would
|
|
119
|
+
* mean handing the agent a token and a client it needs for nothing else. Writing one small JSON file is
|
|
120
|
+
* something every agent can already do, and the surface promotes finished runs when it next sees them. The
|
|
121
|
+
* promotion is idempotent and re-runs on every poll, so nothing is lost by not being watched.
|
|
122
|
+
*
|
|
123
|
+
* `outcomes` is the pack's vocabulary and is spelled out in full, because a closed set is what lets a surface
|
|
124
|
+
* debounce without hiding anything: an agent that verified some findings and concluded they were false
|
|
125
|
+
* positives has to be able to SAY so, or the next poll starts the same turn again forever. Pass the
|
|
126
|
+
* explanations with the words — a model that reads an outcome as an admission of having done nothing useful
|
|
127
|
+
* will avoid it and report something else, and the surface never goes quiet.
|
|
128
|
+
*
|
|
129
|
+
* The closing line is not decoration. A turn that concludes there was nothing to do and writes no file is
|
|
130
|
+
* indistinguishable from a turn that died, and the surface has to show the second as an unknown. */
|
|
131
|
+
export const batchReportingClause = (params: {
|
|
132
|
+
readonly path: string;
|
|
133
|
+
readonly fields: string;
|
|
134
|
+
readonly outcomes?: string | undefined;
|
|
135
|
+
}): string =>
|
|
136
|
+
[
|
|
137
|
+
`When you are finished, write your conclusion to ${params.path} as JSON:`,
|
|
138
|
+
params.fields,
|
|
139
|
+
...(params.outcomes === undefined ? [] : [params.outcomes]),
|
|
140
|
+
`Write that file even if you conclude there was nothing to do.`,
|
|
141
|
+
].join(`\n\n`);
|
|
142
|
+
|
|
143
|
+
/* THE BODY OF THE `POST /agent` THAT STARTS ONE ITEM, so the flag combination that makes a run a run is decided
|
|
144
|
+
* once. `isolated: true` with a conversationId is the shape (and the only shape) that registers a fleet entry,
|
|
145
|
+
* which is why none of these packs owns session machinery; `unattended: true` is what the turn IS — started by
|
|
146
|
+
* a row rather than by a person at a composer — and it is what makes the daemon answer with the owner's
|
|
147
|
+
* `agentRunModels` unless the caller pinned a model on the row's caret, in which case the pick rides along and
|
|
148
|
+
* the daemon's fill step leaves it alone.
|
|
149
|
+
*
|
|
150
|
+
* PERMISSIONS AND ISOLATION ARE THE CALLER'S, deliberately. They are the two decisions that differ by kind and
|
|
151
|
+
* both are about safety rather than plumbing: an acceptance test that parks on a permission card is a test that
|
|
152
|
+
* never finishes, so that surface trades the prompt away; a maintenance chore is different in kind — nobody is
|
|
153
|
+
* waiting on it, it may take until tomorrow, and a sweep that can answer its own permission prompts is exactly
|
|
154
|
+
* the thing an owner would want to have been asked about. A default here would decide that for both. */
|
|
155
|
+
export interface BatchTurnPick {
|
|
156
|
+
readonly provider: string;
|
|
157
|
+
readonly model?: string | undefined;
|
|
158
|
+
readonly effort?: string | undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const batchTurnBody = (params: {
|
|
162
|
+
readonly prompt: string;
|
|
163
|
+
readonly title: string;
|
|
164
|
+
readonly conversationId: string;
|
|
165
|
+
readonly pick?: BatchTurnPick | undefined;
|
|
166
|
+
readonly extra?: Readonly<Record<string, unknown>> | undefined;
|
|
167
|
+
}): Record<string, unknown> => ({
|
|
168
|
+
prompt: params.prompt,
|
|
169
|
+
title: params.title.slice(0, 80),
|
|
170
|
+
conversationId: params.conversationId,
|
|
171
|
+
isolated: true,
|
|
172
|
+
unattended: true,
|
|
173
|
+
...(params.pick === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: {
|
|
176
|
+
agent: params.pick.provider,
|
|
177
|
+
...(params.pick.model === undefined ? {} : { model: params.pick.model }),
|
|
178
|
+
...(params.pick.effort === undefined ? {} : { effort: params.pick.effort }),
|
|
179
|
+
}),
|
|
180
|
+
...params.extra,
|
|
181
|
+
});
|
package/src/card-status.ts
CHANGED
|
@@ -13,7 +13,7 @@ type Cards = { -readonly [K in CardField]?: TranscriptRow[K] };
|
|
|
13
13
|
|
|
14
14
|
export const settledCards = (cards: TranscriptCards, reply: AgentReply | undefined): TranscriptCards => {
|
|
15
15
|
const out: Cards = {};
|
|
16
|
-
const { plan, question, permission, browserHelp, terminalHelp, serviceOffer, capabilityOffer, paymentOffer } = cards;
|
|
16
|
+
const { plan, question, permission, browserHelp, terminalHelp, serviceOffer, capabilityOffer, paymentOffer, credentialOffer } = cards;
|
|
17
17
|
if (plan !== undefined) {
|
|
18
18
|
out.plan = { ...plan, status: reply?.kind !== "plan" ? "cancelled" : reply.approve ? "approved" : "rejected" };
|
|
19
19
|
}
|
|
@@ -59,6 +59,17 @@ export const settledCards = (cards: TranscriptCards, reply: AgentReply | undefin
|
|
|
59
59
|
if (paymentOffer !== undefined) {
|
|
60
60
|
out.paymentOffer = { ...paymentOffer, status: reply?.kind !== "payment_offer" ? "cancelled" : reply.approve ? "approved" : "skipped" };
|
|
61
61
|
}
|
|
62
|
+
/* A yes settles the decision; WHO decided is the credential_receipt frame's to say, because only the
|
|
63
|
+
* daemon knows it — the reply carries no name, the identity was verified off the request that delivered
|
|
64
|
+
* it. A reply that reaches this derivation at all is one the daemon already accepted from an approver: a
|
|
65
|
+
* click from anybody else is refused before the card is settled, so it never gets here and the card stays
|
|
66
|
+
* pending. */
|
|
67
|
+
if (credentialOffer !== undefined) {
|
|
68
|
+
out.credentialOffer = {
|
|
69
|
+
...credentialOffer,
|
|
70
|
+
status: reply?.kind !== "credential_offer" ? "cancelled" : reply.approve ? "approved" : "skipped",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
62
73
|
return out;
|
|
63
74
|
};
|
|
64
75
|
|
|
@@ -77,6 +88,7 @@ export const cancelledCards = (cards: TranscriptCards): TranscriptCards => {
|
|
|
77
88
|
"serviceOffer",
|
|
78
89
|
"capabilityOffer",
|
|
79
90
|
"paymentOffer",
|
|
91
|
+
"credentialOffer",
|
|
80
92
|
] as const) {
|
|
81
93
|
const card = cards[field];
|
|
82
94
|
if (card === undefined) {
|
package/src/chores/stack.test.ts
CHANGED
|
@@ -72,6 +72,42 @@ describe(`the patterns are safe to interpolate`, () => {
|
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
/* THE PATTERNS AGAINST LINES, which is the only place a false finding can be caught before a reader is told to go
|
|
76
|
+
* and fix one. The invariants above ask whether a rule is well formed; these ask whether it is right, and the cases
|
|
77
|
+
* are lines from real files rather than invented ones.
|
|
78
|
+
*
|
|
79
|
+
* Only the rule whose names are ordinary words is covered here. `@NgModule(` and `ReactDOM.render(` cannot be
|
|
80
|
+
* mistaken for anything, and a test that restated them would be asserting that a literal is itself. */
|
|
81
|
+
describe(`the Vue 2 teardown hooks`, () => {
|
|
82
|
+
const hits = (line: string): boolean => new RegExp(idiomRule(`vue-2-lifecycle`)?.pattern ?? `(?:)`).test(line);
|
|
83
|
+
|
|
84
|
+
test(`a hook is found however the component writes it`, () => {
|
|
85
|
+
expect(hits(` destroyed() {`)).toBe(true);
|
|
86
|
+
expect(hits(` beforeDestroy() {`)).toBe(true);
|
|
87
|
+
expect(hits(` destroyed: function () {`)).toBe(true);
|
|
88
|
+
expect(hits(` destroyed: async () => {`)).toBe(true);
|
|
89
|
+
// The name is Vue's alone, so it is the finding whatever it is assigned.
|
|
90
|
+
expect(hits(` beforeDestroy: this.teardown,`)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/* The finding this rule really produced, against a module that deletes idle machines. Every line below is a
|
|
94
|
+
* fact about a tally, and a chore that reads them as a lifecycle hook sends its reader at a file with no
|
|
95
|
+
* component in it. */
|
|
96
|
+
test(`a field named after the word is not a hook`, () => {
|
|
97
|
+
expect(hits(`): Promise<{ warned: number; destroyed: number; dropped: number }> => {`)).toBe(false);
|
|
98
|
+
expect(hits(` const tally = { warned: 0, destroyed: 0, dropped: 0 };`)).toBe(false);
|
|
99
|
+
expect(hits(` return \`destroyed\`;`)).toBe(false);
|
|
100
|
+
expect(hits(` if (machine.destroyed) {`)).toBe(false);
|
|
101
|
+
expect(hits(`type IdleVerdict = "kept" | "warned" | "destroyed" | "dropped";`)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The other half of the same guard: the pattern above is only ever asked about a component file, so a backend
|
|
105
|
+
// module is not eligible for this finding whatever it happens to name a variable.
|
|
106
|
+
test(`the sweep asks the question of components only`, () => {
|
|
107
|
+
expect(idiomRule(`vue-2-lifecycle`)?.globs).toEqual([`*.vue`]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
75
111
|
describe(`recognising the stack`, () => {
|
|
76
112
|
test(`a framework is recognised from any manifest's dependency names`, () => {
|
|
77
113
|
expect(frameworksOf([`vue`, `vite`]).map((framework) => framework.id)).toEqual([`vue`]);
|
package/src/chores/stack.ts
CHANGED
|
@@ -168,8 +168,19 @@ export const IDIOM_RULES: readonly IdiomRule[] = [
|
|
|
168
168
|
framework: `vue`,
|
|
169
169
|
label: `the Vue 2 teardown hooks`,
|
|
170
170
|
replacement: `beforeUnmount and unmounted`,
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
/* TWO NAMES, AND ONLY ONE OF THEM IS VUE'S. `beforeDestroy` is a word nobody writes by accident, so its
|
|
172
|
+
* presence is the finding. `destroyed` is ordinary English, and asking only for a `(` or a `:` after it
|
|
173
|
+
* matched `{ warned: number; destroyed: number }` in a backend module that reaps idle machines: the chore
|
|
174
|
+
* told its reader to migrate a Node file to beforeUnmount, which is the kind of row that teaches people to
|
|
175
|
+
* stop reading the rest. So the ambiguous half has to be a FUNCTION in an options object, the only shape a
|
|
176
|
+
* hook is ever written in, and a field or a type named after the word is no longer evidence of anything.
|
|
177
|
+
*
|
|
178
|
+
* The globs are the second half of the same guard, and `*.ts` was the reason a backend file was eligible at
|
|
179
|
+
* all. A teardown hook is a component's, so this reads components. What that gives up is a Vue 2 mixin
|
|
180
|
+
* living in a plain `.js` file, and it is the right trade: `vue-global-api` still catches such a file when
|
|
181
|
+
* it constructs anything, and an idiom found nowhere costs less than one found in the wrong repository. */
|
|
182
|
+
pattern: `\\bbeforeDestroy\\s*[(:]|\\bdestroyed\\s*(\\(\\s*\\)\\s*\\{|:\\s*(async\\s+)?(function|\\(\\s*\\)\\s*=>))`,
|
|
183
|
+
globs: [`*.vue`],
|
|
173
184
|
},
|
|
174
185
|
{
|
|
175
186
|
id: `vue-global-api`,
|
package/src/command-classes.ts
CHANGED
|
@@ -94,8 +94,13 @@ const spansOf = (patterns: readonly RegExp[], command: string): CommandSpan[] =>
|
|
|
94
94
|
/* Sorted, with overlaps folded together. Two patterns firing on one fragment is ordinary here (a script's
|
|
95
95
|
* recursive delete matches both the with-a-literal-path pattern and the any-path one), and handing a renderer
|
|
96
96
|
* overlapping ranges makes it either double-paint or reinvent this. Adjacency is NOT merged: touching spans
|
|
97
|
-
* from genuinely different fragments read correctly as two marks.
|
|
98
|
-
|
|
97
|
+
* from genuinely different fragments read correctly as two marks.
|
|
98
|
+
*
|
|
99
|
+
* EXPORTED because a caller that marks SEVERAL classes at once needs it too, and the overlap it has to fold is
|
|
100
|
+
* across classes rather than within one: `rm -rf /work` is both files.destructive and system.destructive on the
|
|
101
|
+
* same characters, and a card that painted both would hand its renderer two ranges over one fragment. The
|
|
102
|
+
* command gate marks every matched class now (guard/command-gate.ts says why), so this is the second caller. */
|
|
103
|
+
export const mergeSpans = (spans: readonly CommandSpan[]): CommandSpan[] => {
|
|
99
104
|
const merged: CommandSpan[] = [];
|
|
100
105
|
for (const span of [...spans].sort((left, right) => left.start - right.start || left.end - right.end)) {
|
|
101
106
|
const last = merged.at(-1);
|
|
@@ -484,7 +489,7 @@ const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandC
|
|
|
484
489
|
* command text alone, which is every caller that has no filesystem to consult. */
|
|
485
490
|
export const matchCommand = (command: string, context?: CommandContext): CommandMatch[] =>
|
|
486
491
|
CommandClassSchema.options.flatMap((commandClass) => {
|
|
487
|
-
const spans =
|
|
492
|
+
const spans = mergeSpans(MATCHES[commandClass](command, context));
|
|
488
493
|
return spans.length === 0 ? [] : [{ commandClass, spans }];
|
|
489
494
|
});
|
|
490
495
|
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CredentialGateSchema,
|
|
4
|
+
CredentialGateSubjectParamSchema,
|
|
5
|
+
CredentialGatesSchema,
|
|
6
|
+
CredentialGrantSchema,
|
|
7
|
+
CredentialRequestSchema,
|
|
8
|
+
SecretInventorySchema,
|
|
9
|
+
SecretKeyParamSchema,
|
|
10
|
+
SecretKeysSchema,
|
|
11
|
+
SecretRevealSchema,
|
|
12
|
+
SecretSetSchema,
|
|
13
|
+
} from "../schemas/secrets.js";
|
|
3
14
|
import { OkSchema } from "../schemas/shared.js";
|
|
4
15
|
|
|
5
16
|
// User-supplied env-var secrets, written to the sandbox's gitignored desired-state/.env (which
|
|
@@ -54,4 +65,59 @@ export const secretsContract = {
|
|
|
54
65
|
})
|
|
55
66
|
.input(SecretKeyParamSchema)
|
|
56
67
|
.output(SecretRevealSchema),
|
|
68
|
+
/* THE APPROVAL GATES, four routes with three different audiences, which is why they are worth reading as a
|
|
69
|
+
* group.
|
|
70
|
+
*
|
|
71
|
+
* `gates` is READ BY EVERYONE who needs to explain the sandbox to itself: the Secrets view draws a badge
|
|
72
|
+
* from it, and the AGENT is allowed it too (auth/grants.ts), because a model that cannot see which
|
|
73
|
+
* credentials are gated cannot tell "not connected" from "needs Bob", and the difference decides whether
|
|
74
|
+
* its next move is to ask a person or to go looking for another road. It answers names, subjects and
|
|
75
|
+
* approver addresses; there is nothing else in it to leak.
|
|
76
|
+
*
|
|
77
|
+
* `setGate` and `removeGate` are the OWNER's alone, enforced in-route with authorizeOwner rather than by
|
|
78
|
+
* the /secrets role floor, because the floor is maintainer and a maintainer is exactly who a gate is
|
|
79
|
+
* sometimes written about. The policy lives off the workspace beside the credential vault for the same
|
|
80
|
+
* reason the vault does: `.intentic/config/` is tracked and agent-editable, so a gate stored there would
|
|
81
|
+
* be a lock whose key is in the room with the agent.
|
|
82
|
+
*
|
|
83
|
+
* `request` is the AGENT's door, and the only one of the four that can park a turn: it raises the release
|
|
84
|
+
* card and waits, so a gated account can be asked for rather than merely discovered to be absent. */
|
|
85
|
+
gates: oc
|
|
86
|
+
.route({
|
|
87
|
+
method: "GET",
|
|
88
|
+
path: "/secrets/gates",
|
|
89
|
+
summary: "Which credentials need somebody's approval",
|
|
90
|
+
description:
|
|
91
|
+
"What is gated and who may release it. Names and addresses only, never values, and the agent may read it too: knowing a credential needs Bob is what stops it concluding the account is simply not connected.",
|
|
92
|
+
})
|
|
93
|
+
.output(CredentialGatesSchema),
|
|
94
|
+
setGate: oc
|
|
95
|
+
.route({
|
|
96
|
+
method: "PUT",
|
|
97
|
+
path: "/secrets/gates/{subject}",
|
|
98
|
+
summary: "Put a credential behind named approvers",
|
|
99
|
+
description:
|
|
100
|
+
"Names exactly who may release one secret or one connected account, and how far a single release goes. The owner's call alone. A signed-in browser or a mounted server cannot be released for one use, so those are always for the rest of the conversation.",
|
|
101
|
+
})
|
|
102
|
+
.input(CredentialGateSchema)
|
|
103
|
+
.output(OkSchema),
|
|
104
|
+
removeGate: oc
|
|
105
|
+
.route({
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
path: "/secrets/gates/{subject}",
|
|
108
|
+
summary: "Stop requiring approval for a credential",
|
|
109
|
+
description: "Removes one gate, so the agent can use that credential the way it uses any other. The owner's call alone.",
|
|
110
|
+
})
|
|
111
|
+
.input(CredentialGateSubjectParamSchema)
|
|
112
|
+
.output(OkSchema),
|
|
113
|
+
request: oc
|
|
114
|
+
.route({
|
|
115
|
+
method: "POST",
|
|
116
|
+
path: "/secrets/request",
|
|
117
|
+
summary: "Ask a named person to release a credential",
|
|
118
|
+
description:
|
|
119
|
+
"Raises the release card in the live conversation and waits for one of the people named on it. Refused, rather than held, when there is nobody to ask: an unattended turn, no live conversation, or a click with no verified identity behind it.",
|
|
120
|
+
})
|
|
121
|
+
.input(CredentialRequestSchema)
|
|
122
|
+
.output(CredentialGrantSchema),
|
|
57
123
|
};
|
package/src/events.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { AgentSummarySchema, LandConflictSchema } from "./schemas/agents.js";
|
|
|
4
4
|
import { RateLimitInfoSchema } from "./schemas/claude-gate.js";
|
|
5
5
|
import { FastModeStateSchema } from "./schemas/fast-mode.js";
|
|
6
6
|
import { AccountUsageSchema, AgentReplySchema, ProviderRefusalSchema, UsageWindowSchema } from "./schemas/plan-limits.js";
|
|
7
|
+
import { CredentialGateKindSchema, CredentialGateScopeSchema, CredentialLaneSchema } from "./schemas/secrets.js";
|
|
7
8
|
import { ShareDetailSchema } from "./schemas/share.js";
|
|
8
9
|
import { MemberRoleSchema } from "./schemas/shared.js";
|
|
9
10
|
import { SubagentKindSchema, SubagentStatusSchema, SubagentVerificationSchema } from "./schemas/terminal.js";
|
|
@@ -35,10 +36,16 @@ export type AskQuestion = z.infer<typeof AskQuestionSchema>;
|
|
|
35
36
|
* card with no way to know it was holding four hundred characters of shell: it rendered them as a paragraph,
|
|
36
37
|
* wrapped mid-flag, and the fragment that caused the hold was somewhere in the middle of it.
|
|
37
38
|
*
|
|
38
|
-
* `spans` is the
|
|
39
|
+
* `spans` is where the pattern match fired, computed by the classifier at the moment it did (contract's
|
|
39
40
|
* command-classes.ts, matchCommand) and carried rather than re-derived: a browser that re-ran the patterns
|
|
40
|
-
* would be a second classifier, and the day the two disagreed the card would be marking a fragment
|
|
41
|
-
*
|
|
41
|
+
* would be a second classifier, and the day the two disagreed the card would be marking a fragment the daemon
|
|
42
|
+
* never saw. Offsets are into `text` AFTER truncation, so they are always paintable.
|
|
43
|
+
*
|
|
44
|
+
* IT IS NOT A CLAIM ABOUT WHY THE CARD EXISTS, and the card no longer presents it as one. The reason is the
|
|
45
|
+
* judge's sentence in the title; these are the fragments TRIAGE noticed, all of the matched classes' rather
|
|
46
|
+
* than whichever sorts first — the card used to show one class's and label them "Stopped for", so a command
|
|
47
|
+
* that cleaned a build directory on its way to publishing offered `rm -rf …` as its reason under a sentence
|
|
48
|
+
* about npm. Under the hard rule the title DOES name a class, so there the marks are that class's alone.
|
|
42
49
|
*
|
|
43
50
|
* `language` is a Shiki grammar id, and the two are the two execution backends the gate reads (command-gate's
|
|
44
51
|
* EXECUTION_SOURCES): a shell line and a script. */
|
|
@@ -52,7 +59,9 @@ export const ProgramAskSchema = z.object({
|
|
|
52
59
|
),
|
|
53
60
|
spans: z
|
|
54
61
|
.array(z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() }))
|
|
55
|
-
.describe(
|
|
62
|
+
.describe(
|
|
63
|
+
"Which fragments of the text the pattern match fired on: every matched class's, or, under the hard rule, only the class the title names. Offsets into text, in order, never overlapping.",
|
|
64
|
+
),
|
|
56
65
|
});
|
|
57
66
|
export type ProgramAsk = z.infer<typeof ProgramAskSchema>;
|
|
58
67
|
|
|
@@ -80,16 +89,20 @@ export const PermissionAskSchema = z.object({
|
|
|
80
89
|
program: ProgramAskSchema.optional().describe(
|
|
81
90
|
"The program this card is holding, when the card is about one. Present on a command gate's card and absent on every other permission ask.",
|
|
82
91
|
),
|
|
83
|
-
/* THE JUDGE'S OWN SENTENCE,
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
92
|
+
/* THE JUDGE'S OWN SENTENCE, WHERE THE TITLE IS SOMEBODY ELSE'S. On an ordinary command card the sentence IS
|
|
93
|
+
* the title (the judge read the owner's policy and the program, and its account of why this needs asking is
|
|
94
|
+
* the only account there is), so this is left off rather than printing the same words twice. It carries the
|
|
95
|
+
* sentence on the two cards whose title says something the sentence cannot: the hard rule's, which names the
|
|
96
|
+
* consequence that stopped it, and a machine command's, which names the computer.
|
|
97
|
+
*
|
|
98
|
+
* Written by the quick model from the program text and the policy, never by the agent being gated — a card
|
|
99
|
+
* whose persuasive half was authored by the thing it is stopping argues for its own approval, and the turns
|
|
100
|
+
* that raise cards are exactly the ones whose account of themselves may be a stranger's. */
|
|
88
101
|
explain: z
|
|
89
102
|
.string()
|
|
90
103
|
.optional()
|
|
91
104
|
.describe(
|
|
92
|
-
"One plain sentence saying what the program does and why it is being asked about. Written by the judge that read your safety policy, never by the agent being gated.",
|
|
105
|
+
"One plain sentence saying what the program does and why it is being asked about, where the title says something else. Written by the judge that read your safety policy, never by the agent being gated.",
|
|
93
106
|
),
|
|
94
107
|
});
|
|
95
108
|
export type PermissionAsk = z.infer<typeof PermissionAskSchema>;
|
|
@@ -209,6 +222,39 @@ export const PaymentOfferSchema = z.object({
|
|
|
209
222
|
});
|
|
210
223
|
export type PaymentOffer = z.infer<typeof PaymentOfferSchema>;
|
|
211
224
|
|
|
225
|
+
/* ONE GATED CREDENTIAL, ASKED FOR, the card the daemon raises when the agent reaches for a secret or a
|
|
226
|
+
* connected account the owner put behind a named person (secrets/credential-gate.ts).
|
|
227
|
+
*
|
|
228
|
+
* Every field but `why` is the daemon's own: the subject and its approvers come off the gate policy the owner
|
|
229
|
+
* wrote (which lives off the workspace, where the agent cannot edit it), the lane and detail come from the
|
|
230
|
+
* exit that was about to spend the credential, and the scope is the policy's, not the asker's. The model
|
|
231
|
+
* contributes one line of rationale and nothing else, which is what makes the card impossible to
|
|
232
|
+
* misrepresent: a prompt-injected turn can ask for the production password and cannot make the card say it is
|
|
233
|
+
* asking for the staging one.
|
|
234
|
+
*
|
|
235
|
+
* THE APPROVERS ARE ON THE CARD because the card is not addressed to "the owner" the way every other offer
|
|
236
|
+
* here is — it is addressed to a LIST, the server checks the clicker's verified identity against it, and a
|
|
237
|
+
* click from anybody else is refused with the card left standing. So the names have to be visible: a card
|
|
238
|
+
* whose buttons do nothing for the person looking at it must say who it is waiting for. */
|
|
239
|
+
export const CredentialOfferSchema = z.object({
|
|
240
|
+
// The gate's subject: a secret's reference name (`DATABASE_URL`) or a capability id (`reddit`).
|
|
241
|
+
subject: z.string().describe("Which credential is being asked for."),
|
|
242
|
+
kind: CredentialGateKindSchema,
|
|
243
|
+
lane: CredentialLaneSchema,
|
|
244
|
+
// Where it would go, in the reader's terms: the head of the agent's command line, the page's host, or the
|
|
245
|
+
// capability's own name. Reference-form by construction on the secret lanes (resolution is what fires the
|
|
246
|
+
// ask), so this can be shown without leaking anything.
|
|
247
|
+
detail: z
|
|
248
|
+
.string()
|
|
249
|
+
.optional()
|
|
250
|
+
.describe("Where it would go: the start of the command, the site, or what is being mounted. Never a value: the command still reads as a reference at this point."),
|
|
251
|
+
// The agent's one-line case, the only prose on the card that is the model's.
|
|
252
|
+
why: z.string().optional().describe("The agent's case for using it, and the only words on this card that are the agent's."),
|
|
253
|
+
approvers: z.array(z.string()).describe("Who may release it. A click from anyone else is refused and leaves the card standing."),
|
|
254
|
+
scope: CredentialGateScopeSchema,
|
|
255
|
+
});
|
|
256
|
+
export type CredentialOffer = z.infer<typeof CredentialOfferSchema>;
|
|
257
|
+
|
|
212
258
|
// One provider-advertised slash command, an ACP agent's available_commands entry, or a Claude Code session's
|
|
213
259
|
// supportedCommands() (its built-ins plus the workspace's own .claude/commands and any plugin/skill commands).
|
|
214
260
|
// `hint` is the argument placeholder the popover shows after the name.
|
|
@@ -360,6 +406,7 @@ const terminalHelpCard = {
|
|
|
360
406
|
const serviceOfferCard = { requestId: z.string(), offer: ServiceOfferSchema };
|
|
361
407
|
const capabilityOfferCard = { requestId: z.string(), offer: CapabilityOfferSchema };
|
|
362
408
|
const paymentOfferCard = { requestId: z.string(), offer: PaymentOfferSchema };
|
|
409
|
+
const credentialOfferCard = { requestId: z.string(), offer: CredentialOfferSchema };
|
|
363
410
|
|
|
364
411
|
/* HOW AN OFFER'S ACCEPTED HALF ENDED, the follow-up that lands on the card after the click. Each is the body of
|
|
365
412
|
* the frame that reports it (`service_receipt`, `capability_outcome`, `payment_receipt`) and the field the
|
|
@@ -382,6 +429,15 @@ export const PaymentReceiptSchema = z.object({
|
|
|
382
429
|
network: z.string().optional(),
|
|
383
430
|
});
|
|
384
431
|
export type PaymentReceipt = z.infer<typeof PaymentReceiptSchema>;
|
|
432
|
+
/* WHO RELEASED A GATED CREDENTIAL, or that a person refused it. `released` carries the approver's own address,
|
|
433
|
+
* read off the VERIFIED identity on the reply rather than off anything the click claimed, which is what makes
|
|
434
|
+
* the row an audit line rather than a rendering. There is no receipt for a card nobody answered: `resolved`
|
|
435
|
+
* already says so, and inventing "refused" for a deadline would put words in a person's mouth. */
|
|
436
|
+
export const CredentialReceiptSchema = z.object({
|
|
437
|
+
outcome: z.enum(["released", "refused"]),
|
|
438
|
+
approvedBy: z.string().optional(),
|
|
439
|
+
});
|
|
440
|
+
export type CredentialReceipt = z.infer<typeof CredentialReceiptSchema>;
|
|
385
441
|
|
|
386
442
|
/* THE THREE RESTORABLE CARDS, named so the turn journal can hold them verbatim: a parked turn's raised cards
|
|
387
443
|
* are written down beside its prompt (sandbox turn-journal.ts), and a daemon death under the park restores the
|
|
@@ -468,6 +524,12 @@ export const TranscriptPaymentOfferSchema = z.object({
|
|
|
468
524
|
receipt: PaymentReceiptSchema.optional().describe("How the approved payment ended (the payment_receipt frame)."),
|
|
469
525
|
});
|
|
470
526
|
export type TranscriptPaymentOffer = z.infer<typeof TranscriptPaymentOfferSchema>;
|
|
527
|
+
export const TranscriptCredentialOfferSchema = z.object({
|
|
528
|
+
...credentialOfferCard,
|
|
529
|
+
status: OfferStatusSchema.describe("Where the decision stands."),
|
|
530
|
+
receipt: CredentialReceiptSchema.optional().describe("Who released it, or that somebody refused (the credential_receipt frame)."),
|
|
531
|
+
});
|
|
532
|
+
export type TranscriptCredentialOffer = z.infer<typeof TranscriptCredentialOfferSchema>;
|
|
471
533
|
|
|
472
534
|
// ---- transcript rows ----
|
|
473
535
|
// What a conversation is made of, on every surface: the rows the daemon folds a turn's frames into as they
|
|
@@ -664,13 +726,26 @@ export const TranscriptRowSchema = z.object({
|
|
|
664
726
|
serviceOffer: TranscriptServiceOfferSchema.optional().describe("The priced service run this row offered, the decision, and the receipt."),
|
|
665
727
|
capabilityOffer: TranscriptCapabilityOfferSchema.optional().describe("The capability setup this row asked for, the decision, and the outcome."),
|
|
666
728
|
paymentOffer: TranscriptPaymentOfferSchema.optional().describe("The payment this row asked for, the decision, and the receipt."),
|
|
729
|
+
credentialOffer: TranscriptCredentialOfferSchema.optional().describe(
|
|
730
|
+
"The gated credential this row asked to use, who may release it, and who did.",
|
|
731
|
+
),
|
|
667
732
|
});
|
|
668
733
|
export type TranscriptRow = z.infer<typeof TranscriptRowSchema>;
|
|
669
734
|
|
|
670
735
|
/* THE CARD FIELDS A ROW CAN CARRY, as one list, for every reader that has to ask "does this row hold a card":
|
|
671
736
|
* the fold that counts a card-only bubble as a row, the chat's row count (a branch is cut by it), and the
|
|
672
737
|
* surfaces that draw whichever card a bubble is waiting on. */
|
|
673
|
-
export const CARD_FIELDS = [
|
|
738
|
+
export const CARD_FIELDS = [
|
|
739
|
+
"plan",
|
|
740
|
+
"question",
|
|
741
|
+
"permission",
|
|
742
|
+
"browserHelp",
|
|
743
|
+
"terminalHelp",
|
|
744
|
+
"serviceOffer",
|
|
745
|
+
"capabilityOffer",
|
|
746
|
+
"paymentOffer",
|
|
747
|
+
"credentialOffer",
|
|
748
|
+
] as const;
|
|
674
749
|
export type CardField = (typeof CARD_FIELDS)[number];
|
|
675
750
|
export type TranscriptCards = Pick<TranscriptRow, CardField>;
|
|
676
751
|
// Whether a row holds a card at all, answered or not.
|
|
@@ -1164,6 +1239,20 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
1164
1239
|
* case the signed authorization expires unused and NOTHING left the wallet. A skip needs no receipt,
|
|
1165
1240
|
* nothing moved, and `resolved` already says so. */
|
|
1166
1241
|
PaymentReceiptSchema.extend({ kind: z.literal("payment_receipt"), requestId: z.string() }),
|
|
1242
|
+
/* A GATED CREDENTIAL awaiting a NAMED person's click, the one card on this stream that is not addressed to
|
|
1243
|
+
* the owner: the daemon holds an exit (a `{{secret:…}}` about to resolve, a browser field about to be
|
|
1244
|
+
* typed into, a connected account about to be mounted) parked until one of the gate's approvers releases
|
|
1245
|
+
* it (secrets/credential-gate.ts). Raised OUTSIDE the turn generator like the offers above — the exits run
|
|
1246
|
+
* inside a PreToolUse hook and inside the daemon's own `secrets request` route — so it is not journalled
|
|
1247
|
+
* for restore: its waiter is a held hook or a held connection, both of which die with the daemon, and the
|
|
1248
|
+
* next use after a restart simply asks again. Settles through the same `POST /agent/reply`, which is where
|
|
1249
|
+
* the clicker's identity is checked against `offer.approvers`. */
|
|
1250
|
+
z.object({ kind: z.literal("credential_offer"), ...credentialOfferCard }),
|
|
1251
|
+
/* WHO RELEASED IT, pushed the moment a person decided, so the settled card names them rather than saying
|
|
1252
|
+
* only that something was approved: `released` with the approver's verified address, or `refused` when a
|
|
1253
|
+
* person said no. Nothing is pushed for a card nobody answered — `resolved` already says that, and a
|
|
1254
|
+
* deadline is not a refusal by anybody. */
|
|
1255
|
+
CredentialReceiptSchema.extend({ kind: z.literal("credential_receipt"), requestId: z.string() }),
|
|
1167
1256
|
// The card above named by `requestId` is released, the user answered (or dismissed it, or the turn was
|
|
1168
1257
|
// stopped out from under it), so the turn is executing again. Emitted by whoever parked, the moment its
|
|
1169
1258
|
// waiter settles, because the park's END is otherwise invisible on this stream: nothing else here says
|
package/src/safety-policy.ts
CHANGED
|
@@ -46,6 +46,29 @@ import type { CommandClass } from "./schemas/agent.js";
|
|
|
46
46
|
* set shrinks to block devices when that lands. */
|
|
47
47
|
export const HARD_RULE_CLASSES: ReadonlySet<CommandClass> = new Set<CommandClass>(["system.destructive"]);
|
|
48
48
|
|
|
49
|
+
/* WHETHER THE JUDGE RUNS AT ALL, and whether its answer is allowed to stop anything. The owner's switch over
|
|
50
|
+
* everything below, and the reason it exists is that a tier which spends a model call and can interrupt you is a
|
|
51
|
+
* tier somebody is entitled to decline — the old rulebook could be set to allow everything, and losing that when
|
|
52
|
+
* the judge arrived made the redesign a thing you could only opt further INTO.
|
|
53
|
+
*
|
|
54
|
+
* THREE STATES, and the middle one is the one worth arguing for. Nobody trusts a judge they have not watched,
|
|
55
|
+
* and the only evidence that it asks about the right things is a log of what it decided while it could not
|
|
56
|
+
* interrupt them. Same shape, and the same reasoning, as the tier judge's own Measure state (settings.autoTier).
|
|
57
|
+
*
|
|
58
|
+
* off nothing is judged. No model call, no cards, nothing written to the log. Triage still runs, because
|
|
59
|
+
* the hard rule below is built on it and costs nothing.
|
|
60
|
+
* watch the judge runs on every triage hit and every verdict is recorded, and NOTHING is ever held: an `ask`
|
|
61
|
+
* is logged as an ask and the command runs anyway. What it costs is one model call per triage hit;
|
|
62
|
+
* what it buys is the Recent decisions list, read against a policy nobody has tested yet.
|
|
63
|
+
* on the verdict decides, which is the behaviour this design describes everywhere else.
|
|
64
|
+
*
|
|
65
|
+
* THE HARD RULE IS NOT UNDER THIS SWITCH, at any setting. HARD_RULE_CLASSES is a typed verdict rather than a
|
|
66
|
+
* judgment, it never needed a model, and the Safety page promises in as many words that it cannot be edited
|
|
67
|
+
* away. So `off` and `watch` still raise a card for wiping a block device or deleting under /history — with a
|
|
68
|
+
* sentence saying the judge did not weigh in, rather than one pretending it did. */
|
|
69
|
+
export const CommandJudgeModeSchema = z.enum(["off", "watch", "on"]);
|
|
70
|
+
export type CommandJudgeMode = z.infer<typeof CommandJudgeModeSchema>;
|
|
71
|
+
|
|
49
72
|
/* WHAT THE JUDGE ANSWERS. Three verdicts, and each is a different instruction to the gate:
|
|
50
73
|
*
|
|
51
74
|
* allow run it, say nothing, nobody is interrupted. The ordinary answer for a triage false positive,
|
package/src/schemas/agents.ts
CHANGED
|
@@ -86,6 +86,12 @@ export const AgentAttentionSchema = z.object({
|
|
|
86
86
|
// A missing capability parked on the owner's setup (capabilities/capability-offer.ts), the agent is
|
|
87
87
|
// waiting for something to be connected, so the lane can say "setup needed" rather than a generic pause.
|
|
88
88
|
capability: z.boolean().describe("It needs something connected that is not connected yet."),
|
|
89
|
+
// A gated credential parked on a NAMED person's click (secrets/credential-gate.ts). Its own lane because
|
|
90
|
+
// it is the one pause the person reading the board may not be able to clear: every other card here waits
|
|
91
|
+
// for whoever is looking at it, this one waits for the specific people the gate names.
|
|
92
|
+
credential: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.describe("It is waiting for a named person to release a credential. The one pause that may not be yours to clear, whatever your role."),
|
|
89
95
|
conflict: z.boolean().describe("Its work cannot be merged without somebody resolving a clash."),
|
|
90
96
|
});
|
|
91
97
|
export type AgentAttention = z.infer<typeof AgentAttentionSchema>;
|