@sema-agent/core 7.0.2 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/cross-session-envelope.d.ts +138 -0
  3. package/dist/agents/cross-session-envelope.js +191 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/repair-loop.d.ts +8 -7
  9. package/dist/agents/roster-store.d.ts +7 -2
  10. package/dist/agents/send-message-tool.d.ts +13 -0
  11. package/dist/agents/send-message-tool.js +36 -12
  12. package/dist/brain/errors.d.ts +18 -0
  13. package/dist/brain/errors.js +3 -0
  14. package/dist/brain/stream-engine.js +6 -4
  15. package/dist/core/checkpoint-store.d.ts +189 -3
  16. package/dist/core/checkpoint-store.js +56 -16
  17. package/dist/core/context-edit.d.ts +3 -0
  18. package/dist/core/governance-codes.d.ts +1 -1
  19. package/dist/core/governance-codes.js +4 -0
  20. package/dist/core/hooks.d.ts +34 -7
  21. package/dist/core/hooks.js +14 -8
  22. package/dist/core/image-downsample.d.ts +4 -3
  23. package/dist/core/permission-rule-consent.d.ts +72 -23
  24. package/dist/core/permission-rule-consent.js +115 -26
  25. package/dist/core/permission-rule-model.d.ts +245 -51
  26. package/dist/core/permission-rule-model.js +312 -54
  27. package/dist/core/permission-rule-org.js +13 -6
  28. package/dist/core/remote-env.d.ts +8 -1
  29. package/dist/core/roles.d.ts +30 -8
  30. package/dist/core/roles.js +12 -8
  31. package/dist/core/runner/assemble-result.js +2 -1
  32. package/dist/core/runner/prepare-task.d.ts +41 -2
  33. package/dist/core/runner/prepare-task.js +353 -152
  34. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  35. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  36. package/dist/core/runner/runtask.d.ts +12 -3
  37. package/dist/core/runner/runtask.js +45 -7
  38. package/dist/core/safety-axis-vocab.d.ts +1 -1
  39. package/dist/core/strategy-store.d.ts +4 -1
  40. package/dist/core/task-notification.d.ts +64 -5
  41. package/dist/core/task-notification.js +25 -4
  42. package/dist/core/task-registry-shared.d.ts +7 -3
  43. package/dist/core/tool-errors.d.ts +1 -1
  44. package/dist/core/tool-policy.d.ts +51 -7
  45. package/dist/core/tool-policy.js +63 -9
  46. package/dist/core/types.d.ts +110 -9
  47. package/dist/core/untrusted-text.js +17 -1
  48. package/dist/engine/compaction/compaction.js +6 -2
  49. package/dist/engine/harness/agent-harness.d.ts +28 -6
  50. package/dist/engine/harness/agent-harness.js +34 -2
  51. package/dist/engine/harness/messages.js +4 -0
  52. package/dist/engine/harness/types.d.ts +37 -0
  53. package/dist/engine/harness/types.js +5 -0
  54. package/dist/engine/session/session.js +3 -2
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.js +5 -2
  57. package/dist/internal/harness.d.ts +1 -0
  58. package/dist/internal/harness.js +1 -0
  59. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  60. package/dist/orchestration/run-workflow-tool.js +7 -2
  61. package/dist/orchestration/workflow-governance.js +1 -1
  62. package/dist/orchestration/workflow-types.d.ts +1 -0
  63. package/dist/orchestration/workflow.js +1 -1
  64. package/dist/stores/file/mailbox-store.d.ts +2 -1
  65. package/package.json +1 -1
  66. package/test/export-surface.snapshot.json +125 -1
@@ -27,7 +27,7 @@
27
27
  * this by editing a file backend's file. That is the settings-file trust model, stated rather than
28
28
  * defended against: for a file backend, host = user, no more and no less.
29
29
  */
30
- import { type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
30
+ import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
31
31
  import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
32
32
  import type { SessionRuleOverlay } from "./permission-rule-session.js";
33
33
  /** One candidate rule inside an approval record: the exact text and where it would apply. */
@@ -48,10 +48,16 @@ export type RuleApprovalKind = "card" | "import" | "starter";
48
48
  * · `kind: "batch"` — a CONJUNCTION batch: confirming it is one yes to ALL of `candidates` at
49
49
  * once, with no per-member sub-selection. On a CARD record the batch carries two MINT-TIME
50
50
  * facts that cannot be re-derived from candidate texts and therefore must be stored:
51
- * `segments` (each member's source segment, index-aligned with `candidates`) and
51
+ * `segments` (each member's source segment, index-aligned with `candidates`),
52
52
  * `uncoveredSegments` (the honest surplus count the wire offer disclosed, judged on the
53
- * prepare-time coverage snapshot). On an import/starter record there is no compound command
54
- * and no coverage snapshot, so both fields must be ABSENT honest absence, never an
53
+ * prepare-time coverage snapshot) and since `schema: 3` (design/382 §3.5) `uncoveredDetail`,
54
+ * one reason row per counted segment in segment order: WHY each stayed uncovered is a mint-time
55
+ * fact that cannot be re-derived from candidate texts, so it must be stored or a crash/reload
56
+ * rebuild would render a card that says less than the first render did (record-is-what-you-see).
57
+ * REQUIRED on a card batch, all three: "optional, validated when present" is the
58
+ * silent-absence-passes shape this record form already refused once, and it is not re-imported
59
+ * for the new seat. On an import/starter record there is no compound command
60
+ * and no coverage snapshot, so all three fields must be ABSENT — honest absence, never an
55
61
  * empty-string stand-in. The read-side integrity checks refuse either direction's violation.
56
62
  *
57
63
  * Cardinality: a batch references 1..N candidates where N is the record's own candidate count.
@@ -67,6 +73,7 @@ export type RuleOffer2 = {
67
73
  readonly candidates: readonly number[];
68
74
  readonly segments?: readonly string[];
69
75
  readonly uncoveredSegments?: number;
76
+ readonly uncoveredDetail?: readonly UncoveredSegmentDetail[];
70
77
  };
71
78
  /** A durable record of one pending/settled human decision about minting rules.
72
79
  *
@@ -81,12 +88,16 @@ export interface RuleApprovalRecord {
81
88
  /** Structural owner (additive). Absent on every record a v1 tree minted. */
82
89
  owner?: RuleOwner;
83
90
  /**
84
- * design/375 §4.5 — the record-form version stamp. A stored row WITHOUT it predates this form;
85
- * such a row confirms and redeems nothing (refused loudly, `record_schema_stale`) and is never
86
- * read through a compatibility shim: pending approvals are short-lived, and re-triggering the
87
- * command draws a fresh card. Stores hand pre-form rows back as {@link StaleRuleApprovalRecord}.
91
+ * design/375 §4.5 — the record-form version stamp. A stored row WITHOUT it or carrying an older
92
+ * number — predates this form; such a row confirms and redeems nothing (refused loudly,
93
+ * `record_schema_stale`) and is never read through a compatibility shim: pending approvals are
94
+ * short-lived, and re-triggering the command draws a fresh card. Stores hand pre-form rows back
95
+ * as {@link StaleRuleApprovalRecord}. `3` (design/382 §3.5, B8/B7): the card batch's
96
+ * `uncoveredDetail` seat became REQUIRED and `edited.warnings` became storable — a `schema: 2`
97
+ * row is read back stale exactly like the pre-stamp rows before it (the short-lived-pending
98
+ * bargain, unchanged).
88
99
  */
89
- schema: 2;
100
+ schema: 3;
90
101
  kind: RuleApprovalKind;
91
102
  state: "pending" | "approved" | "redeemed";
92
103
  candidates: RuleCandidate[];
@@ -137,18 +148,26 @@ export interface RuleApprovalRecord {
137
148
  * same bytes); `at` is when the edit landed. Present ⇒ the record's chosen offer is the single
138
149
  * offer minted over this candidate (the append, the offer and the choice are one CAS write).
139
150
  * Absent on every card settled through an engine candidate.
151
+ *
152
+ * `warnings` (design/382 §3.3-5) — the BREADTH warning codes the text gates raised on this edit,
153
+ * written in the same CAS that settles it: "the width was named before the yes" becomes a fact the
154
+ * record itself carries, auditable after any crash or upgrade. Optional; present only when a
155
+ * warning was raised, and when present it is an array from the closed code set (the read-side
156
+ * integrity gate refuses anything else). Codes only — the sentences are derivable from the
157
+ * canonical rule text and are deliberately not stored (no second copy to drift).
140
158
  */
141
159
  edited?: {
142
160
  index: number;
143
161
  text: string;
144
162
  at: string;
163
+ warnings?: readonly EditedRuleBreadthWarning["code"][];
145
164
  };
146
165
  /** Dots already minted for this record, keyed by candidate index — the replay anchor. */
147
166
  redeemedDots?: Record<number, RuleDot>;
148
167
  }
149
168
  /**
150
169
  * design/375 §4.5 — the minimal ENVELOPE a store hands back for a durable row that predates the
151
- * `schema: 2` record form. A typed read cannot honestly return such a row as a
170
+ * CURRENT (`schema: 3`) record form. A typed read cannot honestly return such a row as a
152
171
  * {@link RuleApprovalRecord}, and dropping it at the store layer would misreport "stale" as
153
172
  * corruption — so the store contract is this variant instead: `staleSchema: true` plus the
154
173
  * IDENTITY fields, which are spelled the same way in every version and therefore safe to read.
@@ -209,6 +228,14 @@ export interface RuleConsentDeps {
209
228
  * coverage reads see no session rows — both fail toward asking.
210
229
  */
211
230
  sessionRules?: SessionRuleOverlay;
231
+ /**
232
+ * design/382 §2.4-6 — the deployment's sensitive-read negative control for the directoryRead MINT
233
+ * floor, same-source with its compiled read-deny judge (`(d) => readDeny.matchPath(d) !== null` is
234
+ * the canonical wiring). Consulted only when a card would PROPOSE a directory authorization from a
235
+ * cd segment — never by the explicit-consent doors (hand-written, import), which carry their own
236
+ * preview and validation. Absent ⇒ the engine's built-in default deny table stands in (refusal-only).
237
+ */
238
+ deniesDirectoryRead?: (directory: string) => boolean;
212
239
  }
213
240
  /** In-memory approval records — the test backend and the reference CAS semantics, the stale-row
214
241
  * envelope read included. */
@@ -297,6 +324,17 @@ export declare function prepareCardApproval(opts: {
297
324
  * leans wide — the over-offer direction, which redemption's per-rule validation keeps safe.
298
325
  */
299
326
  cwd?: string;
327
+ /**
328
+ * design/382 §2.4 (adversarial-review r2) — the EXECUTION working directory of the adjudicated
329
+ * call: the live tracked cwd the shell will resolve a relative `cd ./x` against, threaded by the
330
+ * caller exactly like `cwd` (the runner's ask-projection seat threads its `handsCwdRef.current`;
331
+ * a host preparing cards for its own calls threads whatever base those calls execute under).
332
+ * ONLY the relative-cd resolution reads it; scope eligibility and the derived project scope stay
333
+ * on `cwd`. Absent ⇒ `cwd` is the base (the single-value deployment, where the two coincide).
334
+ * Present and DIFFERENT, it is what keeps this durable consent path and the advisory ask-time
335
+ * projection minting the SAME directory member — two faces reading one world.
336
+ */
337
+ execCwd?: string;
300
338
  deps: RuleConsentDeps;
301
339
  }): Promise<{
302
340
  approvalId: string;
@@ -346,7 +384,7 @@ export declare function confirmRuleApproval(opts: {
346
384
  /** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
347
385
  * The two `record_*` members past `record_not_found` are the durable-row gates (design/375 §4.5/§4.7),
348
386
  * disclosed to the record's own owner only — a non-owner gets `record_not_found` for every row:
349
- * - `"record_schema_stale"` — the record predates the `schema: 2` form; the decision it carried
387
+ * - `"record_schema_stale"` — the record predates the current (`schema: 3`) form; the decision it carried
350
388
  * is void, and the recovery path is re-triggering the command for a fresh card (no
351
389
  * compatibility read, no migration — `detail.message` says so);
352
390
  * - `"record_malformed"` — the record's offer structure failed the read-side integrity checks
@@ -394,6 +432,16 @@ export type ConfirmResult = {
394
432
  export type EditedRuleTextPrecheck = {
395
433
  ok: true;
396
434
  canonicalRule: string;
435
+ /**
436
+ * design/382 §3.3-5 — BREADTH notes about the accepted rule (additive; absent = nothing to
437
+ * say). A warning is never a refusal: the muscle-memory wide forms are legal (§3.4), and this
438
+ * seat is what makes accepting one an informed act. Rendering them beside the input line is
439
+ * the surface's duty (the engine cannot force a display); what the ENGINE guarantees is the
440
+ * durable half — a confirmation through the edit arm writes the hit codes into the record
441
+ * (`edited.warnings`), so "the width was named before the yes" is an auditable fact. The
442
+ * confirmation result deliberately does NOT repeat them (one fact, one loud place).
443
+ */
444
+ warnings?: readonly EditedRuleBreadthWarning[];
397
445
  } | {
398
446
  ok: false;
399
447
  code?: RuleRejectCode;
@@ -483,19 +531,20 @@ export interface ImportPreview {
483
531
  reason: string;
484
532
  }>;
485
533
  /**
486
- * #490 件4 the entries whose SPELLING was rewritten on the way in, `from` as the settings file
487
- * writes it and `to` as this lane will hold it (`Bash(npm run *)` → `Bash(npm run:*)`).
488
- *
489
- * Exactly ONE row per candidate the translation produced, in candidate order, carrying that
490
- * candidate's `scope`. The scope is load-bearing rather than decoration: the same file line can
491
- * appear in two layers and become two candidates in two scopes, and a report keyed on the text alone
492
- * would show one row for two consents (independent-review finding — the row count and the candidate
493
- * count must be readable against each other).
534
+ * ALWAYS EMPTY in this version a compatibility seat, kept on the wire shape so an existing reader
535
+ * keeps finding the key (removing a wire field would break readers for no gain).
494
536
  *
495
- * Always present, empty when nothing was translated: a person confirming this batch is being shown
496
- * rule texts that are not the ones in their file, and a preview that let that pass silently would be
497
- * asking for consent to something it had not shown them. Entries refused AFTER translation are not
498
- * here they are in `skipped`, named as the file writes them.
537
+ * The seat once carried the entries whose MATCH FORM was rewritten on the way in (the space-star
538
+ * suggestion form of the other product translated to this lane's colon-star form, one row per
539
+ * translated candidate with its `scope`). That translation is retired: the space-star wildcard is
540
+ * now a first-class match form of this lane's own grammar, so an entry's match form imports as the
541
+ * file states it — `Bash(npm run *)` lands as `Bash(npm run *)`, never as a colon-star — and nothing
542
+ * is ever pushed here. What `candidates` carries is the grammar's CANONICAL re-projection of each
543
+ * entry (the parser folds spacing: `Bash(npm<TAB>run *)` lands as `Bash(npm run *)`); that is the
544
+ * ordinary normalization every rule text in this lane undergoes, the same one the store applies,
545
+ * and not a translation this seat reports. An entry whose star placement has no rule form is
546
+ * refused into `skipped` under `unsupported.wildcard`, named as the file writes it. A reader
547
+ * rendering a "rewritten spellings" column from this seat renders an empty column, correctly.
499
548
  */
500
549
  translated: Array<{
501
550
  from: string;
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, segmentCoverageOf, suggestRulesForCommand, translateImportedWildcardRule, } from "./permission-rule-model.js";
2
+ import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, } from "./permission-rule-model.js";
3
3
  import { errText, isValidConsentScope, normalizePersistedRule, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
4
  export class InMemoryRuleApprovalRecordStore {
5
5
  rows = new Map();
@@ -7,7 +7,7 @@ export class InMemoryRuleApprovalRecordStore {
7
7
  const r = this.rows.get(id);
8
8
  if (r === undefined)
9
9
  return undefined;
10
- if (r.schema !== 2) {
10
+ if (r.schema !== 3) {
11
11
  return {
12
12
  staleSchema: true,
13
13
  id: r.id,
@@ -139,8 +139,42 @@ function approvalRecordDamageOf(rec) {
139
139
  if (!Number.isInteger(offer.uncoveredSegments) || offer.uncoveredSegments < 0) {
140
140
  return `card batch offer ${at} must carry a non-negative uncovered-segment count`;
141
141
  }
142
+ const detail = offer.uncoveredDetail;
143
+ if (!Array.isArray(detail))
144
+ return `card batch offer ${at} must carry its uncovered-segment reason rows`;
145
+ if (detail.length !== offer.uncoveredSegments) {
146
+ return `card batch offer ${at} carries ${detail.length} reason rows for an uncovered-segment count of ${offer.uncoveredSegments} — one fact, two seats, and they must agree`;
147
+ }
148
+ for (const [ri, row] of detail.entries()) {
149
+ if (typeof row !== "object" ||
150
+ row === null ||
151
+ typeof row.segment !== "string" ||
152
+ (row.reason !== "redirection" && row.reason !== "no_rule_form" && row.reason !== "cap_overflow")) {
153
+ return `card batch offer ${at} reason row ${ri} is not a segment/reason row from the closed reason set`;
154
+ }
155
+ }
156
+ if (detail.length > 0) {
157
+ const rows = detail;
158
+ const table = typeof rec.command === "string" ? segmentCoverageOf(rec.command, { persisted: [] }, { tool: CARD_RULE_TOOL, cwd: undefined }) : undefined;
159
+ if (table === undefined) {
160
+ return `card batch offer ${at} carries reason rows but the record's command cannot back them — a row without a readable source command is a row nobody can audit`;
161
+ }
162
+ let cursor = 0;
163
+ for (const [ri, row] of rows.entries()) {
164
+ let found = -1;
165
+ for (let j = cursor; j < table.length; j++) {
166
+ if (table[j]?.segment === row.segment) {
167
+ found = j;
168
+ break;
169
+ }
170
+ }
171
+ if (found === -1)
172
+ return `card batch offer ${at} reason row ${ri} names a segment the record's command does not carry at or after that position`;
173
+ cursor = found + 1;
174
+ }
175
+ }
142
176
  }
143
- else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined) {
177
+ else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined || offer.uncoveredDetail !== undefined) {
144
178
  return `${escapeForDisclosure(rec.kind)} batch offer ${at} must not carry segment metadata — there is no compound command behind it`;
145
179
  }
146
180
  }
@@ -149,6 +183,16 @@ function approvalRecordDamageOf(rec) {
149
183
  if (!claimed.has(at))
150
184
  return `candidate ${at} belongs to no offer — every candidate row is claimed by exactly one offer`;
151
185
  }
186
+ if (rec.edited !== undefined) {
187
+ if (typeof rec.edited !== "object" || rec.edited === null)
188
+ return "the edited seat is not an object";
189
+ const w = rec.edited.warnings;
190
+ if (w !== undefined) {
191
+ if (!Array.isArray(w) || w.length !== 1 || (w[0] !== "broad_prefix" && w[0] !== "compound_prefix")) {
192
+ return "the edited seat's warnings are not a single-code array from the closed breadth-code set";
193
+ }
194
+ }
195
+ }
152
196
  return undefined;
153
197
  }
154
198
  export function ruleOffersOfRecord(rec) {
@@ -163,21 +207,51 @@ export function ruleOffersOfRecord(rec) {
163
207
  return { rule: parsed.rule.rule, match: parsed.rule.match, command: parsed.rule.command };
164
208
  };
165
209
  return rec.offers.map((offer) => {
166
- if (offer.kind === "single")
167
- return { kind: "single", ...tripleOf(offer.candidate) };
210
+ if (offer.kind === "single") {
211
+ const t = tripleOf(offer.candidate);
212
+ if (t.match === "subpath") {
213
+ throw new Error("approval-record single offer references a directory rule — a shape this engine does not mint; refusing to render it");
214
+ }
215
+ return { kind: "single", ...t };
216
+ }
168
217
  const segments = offer.segments;
169
- if (segments === undefined || offer.uncoveredSegments === undefined) {
218
+ if (segments === undefined || offer.uncoveredSegments === undefined || offer.uncoveredDetail === undefined) {
170
219
  throw new Error("a batch offer without mint-time segment metadata has no card face to project — import/starter batches render as previews, not offer cards");
171
220
  }
221
+ const rawDetail = offer.uncoveredDetail;
222
+ const count = offer.uncoveredSegments;
223
+ const detail = [];
224
+ const detailDamaged = (() => {
225
+ if (!Array.isArray(rawDetail) || !Number.isInteger(count) || count < 0 || rawDetail.length !== count)
226
+ return true;
227
+ for (let i = 0; i < rawDetail.length; i++) {
228
+ const row = rawDetail[i];
229
+ if (typeof row !== "object" || row === null)
230
+ return true;
231
+ const segment = row.segment;
232
+ const reason = row.reason;
233
+ if (typeof segment !== "string" || (reason !== "redirection" && reason !== "no_rule_form" && reason !== "cap_overflow"))
234
+ return true;
235
+ detail.push({ segment, reason });
236
+ }
237
+ return false;
238
+ })();
239
+ if (detailDamaged) {
240
+ throw new Error("a batch offer's uncovered-segment reason rows do not match their count or the closed reason set — refusing to render damaged mint-time facts as a card face");
241
+ }
172
242
  return {
173
243
  kind: "batch",
174
244
  rules: offer.candidates.map((candidate, i) => {
175
245
  const segment = segments[i];
176
246
  if (segment === undefined)
177
247
  throw new Error(`batch offer member ${i} has no source segment recorded`);
178
- return { ...tripleOf(candidate), segment };
248
+ const t = tripleOf(candidate);
249
+ return t.match === "subpath"
250
+ ? { kind: "directoryRead", rule: t.rule, directory: t.command, segment }
251
+ : { kind: "command", rule: t.rule, match: t.match, command: t.command, segment };
179
252
  }),
180
- uncoveredSegments: offer.uncoveredSegments,
253
+ uncoveredSegments: count,
254
+ uncoveredDetail: detail,
181
255
  };
182
256
  });
183
257
  }
@@ -234,12 +308,19 @@ export async function prepareCardApproval(opts) {
234
308
  if (sessionRows.length > 0)
235
309
  table = [...sessionRows, ...listed.rules];
236
310
  }
237
- coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId });
311
+ coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId, ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}) });
238
312
  }
239
313
  catch {
240
314
  coverage = undefined;
241
315
  }
242
- const suggested = suggestRulesForCommand(opts.command, coverage !== undefined ? { coverage } : undefined);
316
+ const suggested = suggestRulesForCommand(opts.command, {
317
+ ...(coverage !== undefined ? { coverage } : {}),
318
+ scope,
319
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
320
+ ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}),
321
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
322
+ ...(opts.deps.deniesDirectoryRead !== undefined ? { deniesDirectoryRead: opts.deps.deniesDirectoryRead } : {}),
323
+ });
243
324
  const candidates = [];
244
325
  const claimedBy = new Map();
245
326
  const claim = (text) => {
@@ -257,13 +338,14 @@ export async function prepareCardApproval(opts) {
257
338
  candidates: offer.rules.map((r) => claim(r.rule)),
258
339
  segments: offer.rules.map((r) => r.segment),
259
340
  uncoveredSegments: offer.uncoveredSegments,
341
+ uncoveredDetail: offer.uncoveredDetail ?? [],
260
342
  });
261
343
  if (candidates.length === 0)
262
344
  return undefined;
263
345
  const record = {
264
346
  id: mintId(opts.deps, "rar"),
265
347
  ...recordIdentityOf(owner),
266
- schema: 2,
348
+ schema: 3,
267
349
  kind: "card",
268
350
  state: "pending",
269
351
  rev: 0,
@@ -366,7 +448,8 @@ function checkEditedRuleText(text, command) {
366
448
  message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${escapeForDisclosure(command)}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
367
449
  };
368
450
  }
369
- return { ok: true, canonicalRule: parsed.rule.rule };
451
+ const warnings = ruleBreadthWarningsOf(parsed.rule);
452
+ return { ok: true, canonicalRule: parsed.rule.rule, ...(warnings.length > 0 ? { warnings } : {}) };
370
453
  }
371
454
  export function precheckEditedRuleText(text, command) {
372
455
  if (typeof text !== "string") {
@@ -443,7 +526,7 @@ async function confirmEditedCandidate(rec, edit, deps) {
443
526
  candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
444
527
  offers: [...rec.offers, { kind: "single", candidate: index }],
445
528
  selectedOffer: rec.offers.length,
446
- edited: { index, text: edit.text, at: nowIso(deps) },
529
+ edited: { index, text: edit.text, at: nowIso(deps), ...(checked.warnings !== undefined ? { warnings: checked.warnings.map((w) => w.code) } : {}) },
447
530
  };
448
531
  const won = await deps.approvals.cas(rec.id, rec.rev, next);
449
532
  if (!won) {
@@ -675,30 +758,36 @@ export async function prepareCcImport(opts) {
675
758
  skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
676
759
  continue;
677
760
  }
761
+ if (entry.startsWith("Read(")) {
762
+ const parsed = parseAllowRuleText(entry);
763
+ if ("reject" in parsed) {
764
+ skipped.push({
765
+ rule: entry,
766
+ reason: "unsupported.form: only the Read(//abs-dir/**) directory form imports in v1 — this entry stays in the settings file, unimported",
767
+ });
768
+ continue;
769
+ }
770
+ if (!candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope))) {
771
+ candidates.push({ rule: parsed.rule.rule, scope });
772
+ }
773
+ continue;
774
+ }
678
775
  if (!entry.startsWith("Bash(")) {
679
776
  const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
680
- ? "unsupported.tool: only Bash(...) command rules import in v1 — this entry stays in the settings file, unimported"
777
+ ? "unsupported.tool: only Bash(...) command rules and Read(//abs-dir/**) directory rules import in v1 — this entry stays in the settings file, unimported"
681
778
  : /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
682
779
  ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
683
- : "unsupported.form: not a Bash(...) command rule — it stays in the settings file, unimported";
780
+ : "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported";
684
781
  skipped.push({ rule: entry, reason });
685
782
  continue;
686
783
  }
687
- const wildcard = translateImportedWildcardRule(entry);
688
- if (wildcard !== undefined && "skip" in wildcard) {
689
- skipped.push({ rule: entry, reason: wildcard.skip });
690
- continue;
691
- }
692
- const text = wildcard?.rule ?? entry;
693
- const parsed = parseAllowRuleText(text);
784
+ const parsed = parseAllowRuleText(entry);
694
785
  if ("reject" in parsed) {
695
786
  skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
696
787
  continue;
697
788
  }
698
789
  if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
699
790
  continue;
700
- if (wildcard !== undefined)
701
- translated.push({ from: entry, to: parsed.rule.rule, scope });
702
791
  candidates.push({ rule: parsed.rule.rule, scope });
703
792
  }
704
793
  }
@@ -708,7 +797,7 @@ export async function prepareCcImport(opts) {
708
797
  const record = {
709
798
  id: mintId(opts.deps, "rar"),
710
799
  ...recordIdentityOf(owner),
711
- schema: 2,
800
+ schema: 3,
712
801
  kind: "import",
713
802
  state: "pending",
714
803
  rev: 0,
@@ -742,7 +831,7 @@ export async function prepareStarterBatch(opts) {
742
831
  const record = {
743
832
  id: mintId(opts.deps, "rar"),
744
833
  ...recordIdentityOf(owner),
745
- schema: 2,
834
+ schema: 3,
746
835
  kind: "starter",
747
836
  state: "pending",
748
837
  rev: 0,