@sema-agent/core 5.26.0 → 5.27.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 (34) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  3. package/dist/agents/agent-transcript-tool.js +2 -1
  4. package/dist/agents/send-message-tool.d.ts +4 -1
  5. package/dist/agents/subagent.d.ts +5 -2
  6. package/dist/core/checkpoint-store.d.ts +7 -2
  7. package/dist/core/hooks.d.ts +39 -4
  8. package/dist/core/hooks.js +15 -12
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +20 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  12. package/dist/core/memory-engine/file-backend.js +250 -24
  13. package/dist/core/memory-engine/types.d.ts +8 -1
  14. package/dist/core/memory-vector.d.ts +6 -1
  15. package/dist/core/memory-vector.js +14 -4
  16. package/dist/core/memory.js +1 -6
  17. package/dist/core/permission-rule-model.d.ts +70 -5
  18. package/dist/core/permission-rule-model.js +58 -0
  19. package/dist/core/runner/prepare-memory.js +14 -9
  20. package/dist/core/runner/prepare-task.d.ts +9 -3
  21. package/dist/core/runner/prepare-task.js +29 -8
  22. package/dist/core/runner/runtask.d.ts +8 -1
  23. package/dist/core/task-registry-agent.d.ts +4 -3
  24. package/dist/core/task-registry.d.ts +6 -3
  25. package/dist/core/tool-policy.d.ts +9 -2
  26. package/dist/core/types.d.ts +35 -7
  27. package/dist/engine/loop/types.d.ts +10 -3
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  31. package/dist/orchestration/workflow.d.ts +9 -6
  32. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  33. package/dist/stores/file/index.d.ts +1 -1
  34. package/package.json +1 -1
@@ -229,6 +229,29 @@ export class FileMemoryEngineBackend {
229
229
  scopeDir(scope) {
230
230
  return registerScope(this.directoryRoot, this.controlPlaneRoot, scope);
231
231
  }
232
+ containInboundReject(finding, absPath, text, entryId, committed) {
233
+ this.inboundFindings.push(finding);
234
+ const q = quarantineAndTombstone(absPath, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
235
+ const shadowText = committed !== undefined ? this.readCommittedShadow(entryId) : undefined;
236
+ let restoredShadow = false;
237
+ if (shadowText !== undefined) {
238
+ try {
239
+ atomicWriteFileSync(absPath, shadowText);
240
+ restoredShadow = true;
241
+ }
242
+ catch {
243
+ }
244
+ }
245
+ if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
246
+ const contained = q.removed || restoredShadow;
247
+ this.inboundFindings.push({
248
+ path: finding.path,
249
+ code: "quarantine_failed",
250
+ reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
251
+ });
252
+ }
253
+ return shadowText;
254
+ }
232
255
  inboundGate(relPath, text) {
233
256
  const nameFinding = scanMemoryFileName(relPath);
234
257
  if (nameFinding !== undefined)
@@ -288,27 +311,7 @@ export class FileMemoryEngineBackend {
288
311
  }
289
312
  }
290
313
  if (finding) {
291
- this.inboundFindings.push(finding);
292
- const q = quarantineAndTombstone(f.path, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
293
- const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
294
- let restoredShadow = false;
295
- if (shadowText !== undefined) {
296
- try {
297
- atomicWriteFileSync(f.path, shadowText);
298
- restoredShadow = true;
299
- }
300
- catch {
301
- }
302
- entry = entryFromFile(shadowText, entry.id, f.slug, scope);
303
- }
304
- if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
305
- const contained = q.removed || restoredShadow;
306
- this.inboundFindings.push({
307
- path: finding.path,
308
- code: "quarantine_failed",
309
- reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
310
- });
311
- }
314
+ const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
312
315
  if (shadowText === undefined) {
313
316
  if (committed !== undefined) {
314
317
  delete ledger[entry.id];
@@ -316,6 +319,7 @@ export class FileMemoryEngineBackend {
316
319
  }
317
320
  continue;
318
321
  }
322
+ entry = entryFromFile(shadowText, entry.id, f.slug, scope);
319
323
  }
320
324
  else {
321
325
  if (committed !== undefined)
@@ -377,10 +381,13 @@ export class FileMemoryEngineBackend {
377
381
  return this.listHeadersWith(scopes, true);
378
382
  }
379
383
  listHeadersWith(scopes, adopt) {
384
+ return this.listHeadersFrom(scopes, (s) => this.readScope(s, adopt));
385
+ }
386
+ listHeadersFrom(scopes, read) {
380
387
  const out = [];
381
388
  for (const scope of scopes) {
382
389
  const dir = this.scopeDir(scope);
383
- for (const e of this.readScope(scope, adopt)) {
390
+ for (const e of read(scope)) {
384
391
  out.push(headerOf(e, join(dir, `${e.slug}.md`)));
385
392
  }
386
393
  }
@@ -390,10 +397,13 @@ export class FileMemoryEngineBackend {
390
397
  return this.getByIdsWith(ids, true);
391
398
  }
392
399
  getByIdsWith(ids, adopt) {
400
+ return this.getByIdsFrom(ids, (s) => this.readScope(s, adopt));
401
+ }
402
+ getByIdsFrom(ids, read) {
393
403
  const want = new Set(ids);
394
404
  const out = [];
395
405
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
396
- for (const e of this.readScope(scope, adopt)) {
406
+ for (const e of read(scope)) {
397
407
  if (want.has(e.id))
398
408
  out.push(e);
399
409
  }
@@ -413,10 +423,226 @@ export class FileMemoryEngineBackend {
413
423
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
414
424
  };
415
425
  }
426
+ restrictedFindingKeys = new Set();
427
+ recordRestrictedFinding(key, finding) {
428
+ if (this.restrictedFindingKeys.has(key))
429
+ return;
430
+ this.restrictedFindingKeys.add(key);
431
+ this.inboundFindings.push(finding);
432
+ }
433
+ readScopeCommitted(scope, audit) {
434
+ const dir = this.scopeDir(scope);
435
+ const isRoot = dir === this.directoryRoot;
436
+ const files = scanEntryFiles(dir, {
437
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
438
+ onSkip: audit
439
+ ? (p, kind) => {
440
+ if (kind !== "unreadable")
441
+ return;
442
+ const rel = relative(this.directoryRoot, p);
443
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
444
+ path: rel,
445
+ code: "unreadable",
446
+ reason: "the path could not be read (lstat/readdir failed) — the restricted-session divergence audit is incomplete under it",
447
+ });
448
+ }
449
+ : undefined,
450
+ });
451
+ this.ledger = undefined;
452
+ const ledger = this.loadLedger();
453
+ const entries = [];
454
+ for (const f of files) {
455
+ const rel = relative(this.directoryRoot, f.path);
456
+ let text;
457
+ try {
458
+ text = readFileSync(f.path, "utf8");
459
+ }
460
+ catch {
461
+ if (audit) {
462
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
463
+ path: rel,
464
+ code: "unreadable",
465
+ reason: "memory entry file could not be read — it is missing from this restricted session's view",
466
+ });
467
+ }
468
+ continue;
469
+ }
470
+ const parsed = parseEntryFile(text);
471
+ if (parsed.id === undefined)
472
+ continue;
473
+ const entry = entryFromFile(text, parsed.id, f.slug, scope);
474
+ const committed = ledger[entry.id];
475
+ if (committed === entry.rev) {
476
+ if (audit && this.readCommittedShadow(entry.id) === undefined) {
477
+ try {
478
+ atomicWriteFileSync(this.shadowPath(entry.id), text);
479
+ }
480
+ catch {
481
+ }
482
+ }
483
+ entries.push(entry);
484
+ continue;
485
+ }
486
+ const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
487
+ if (shadowText !== undefined) {
488
+ const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
489
+ if (shadowEntry.rev === entry.rev) {
490
+ entries.push(entry);
491
+ continue;
492
+ }
493
+ const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
494
+ if (otherProjection !== undefined) {
495
+ if (audit) {
496
+ const finding = this.inboundGate(rel, text);
497
+ if (finding !== undefined) {
498
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
499
+ }
500
+ else {
501
+ this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
502
+ path: rel,
503
+ code: "restricted_divergence",
504
+ reason: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
505
+ });
506
+ }
507
+ }
508
+ continue;
509
+ }
510
+ if (audit) {
511
+ const finding = this.inboundGate(rel, text);
512
+ if (finding !== undefined) {
513
+ this.containInboundReject(finding, f.path, text, entry.id, committed);
514
+ }
515
+ else {
516
+ this.recordRestrictedFinding(`divergence|${rel}|${entry.rev}`, {
517
+ path: rel,
518
+ code: "restricted_divergence",
519
+ reason: "on-disk content diverges from the committed entry with no backend transaction backing it; this adoption-restricted session serves the committed content and leaves the disk state pending — an unrestricted session can adopt it normally",
520
+ });
521
+ }
522
+ }
523
+ entries.push(shadowEntry);
524
+ continue;
525
+ }
526
+ if (committed !== undefined) {
527
+ if (audit) {
528
+ this.recordRestrictedFinding(`legacy|${rel}|${entry.rev}`, {
529
+ path: rel,
530
+ code: "restricted_divergence",
531
+ reason: "on-disk content diverges from a committed entry that has no committed shadow copy; this adoption-restricted session withholds the entry (nothing trustworthy to serve) — an unrestricted session can adjudicate it",
532
+ });
533
+ }
534
+ continue;
535
+ }
536
+ if (audit) {
537
+ const finding = this.inboundGate(rel, text);
538
+ if (finding !== undefined) {
539
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
540
+ }
541
+ else {
542
+ this.recordRestrictedFinding(`uncommitted|${rel}|${entry.rev}`, {
543
+ path: rel,
544
+ code: "restricted_divergence",
545
+ reason: "an id-bearing memory file exists on disk that the committed account has never seen; this adoption-restricted session neither adopts nor serves it — it stays on disk for an unrestricted session to adopt normally",
546
+ });
547
+ }
548
+ }
549
+ }
550
+ return entries;
551
+ }
552
+ idProjectionElsewhere(id, excludeAbsPath) {
553
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
554
+ const dir = this.scopeDir(scope);
555
+ const isRoot = dir === this.directoryRoot;
556
+ for (const f of scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined })) {
557
+ if (f.path === excludeAbsPath)
558
+ continue;
559
+ const text = readSafe(f.path);
560
+ if (text === undefined)
561
+ continue;
562
+ if (parseEntryFile(text).id === id)
563
+ return relative(this.directoryRoot, f.path);
564
+ }
565
+ }
566
+ return undefined;
567
+ }
568
+ restrictedAdoptionView(opts) {
569
+ const audit = opts?.audit === true;
570
+ const refuse = (op) => {
571
+ throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
572
+ };
573
+ return {
574
+ listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScopeCommitted(s, audit)),
575
+ getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScopeCommitted(s, audit)),
576
+ search: async (query, scopes, o) => this.searchFrom(query, scopes, o, (s) => this.readScopeCommitted(s, audit)),
577
+ applyPatches: async () => refuse("applyPatches"),
578
+ getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
579
+ setConsolidationCursor: async () => refuse("setConsolidationCursor"),
580
+ };
581
+ }
582
+ auditRestrictedDivergence(scopes) {
583
+ for (const scope of scopes)
584
+ this.readScopeCommitted(scope, true);
585
+ const ledger = { ...this.loadLedger() };
586
+ const present = new Map();
587
+ let complete = true;
588
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
589
+ const dir = this.scopeDir(scope);
590
+ const isRoot = dir === this.directoryRoot;
591
+ const files = scanEntryFiles(dir, {
592
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
593
+ onSkip: (_p, kind) => {
594
+ if (kind === "unreadable")
595
+ complete = false;
596
+ },
597
+ });
598
+ for (const f of files) {
599
+ const text = readSafe(f.path);
600
+ if (text === undefined) {
601
+ complete = false;
602
+ continue;
603
+ }
604
+ const id = parseEntryFile(text).id;
605
+ if (id !== undefined)
606
+ present.set(id, [...(present.get(id) ?? []), relative(this.directoryRoot, f.path)]);
607
+ }
608
+ }
609
+ for (const [id, paths] of present) {
610
+ if (paths.length < 2 || ledger[id] === undefined)
611
+ continue;
612
+ this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
613
+ path: paths[0] ?? id,
614
+ code: "restricted_divergence",
615
+ reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates which projection is real`,
616
+ });
617
+ }
618
+ if (!complete) {
619
+ this.recordRestrictedFinding(`missing-audit-incomplete`, {
620
+ path: ".",
621
+ code: "restricted_divergence",
622
+ reason: "the divergence audit could not scan every path (unreadable subtree) — missing-entry detection was skipped this pass rather than judged from an incomplete scan",
623
+ });
624
+ return;
625
+ }
626
+ for (const [id, rev] of Object.entries(ledger)) {
627
+ if (present.has(id))
628
+ continue;
629
+ this.ledger = undefined;
630
+ if (this.loadLedger()[id] === undefined)
631
+ continue;
632
+ this.recordRestrictedFinding(`missing|${id}|${rev}`, {
633
+ path: `id:${id}`,
634
+ code: "restricted_divergence",
635
+ reason: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
636
+ });
637
+ }
638
+ }
416
639
  async search(query, scopes, opts) {
417
640
  return this.searchWith(query, scopes, opts, true);
418
641
  }
419
642
  searchWith(query, scopes, opts, adopt) {
643
+ return this.searchFrom(query, scopes, opts, (s) => this.readScope(s, adopt));
644
+ }
645
+ searchFrom(query, scopes, opts, read) {
420
646
  const limit = opts?.limit ?? 20;
421
647
  const q = termSet(query);
422
648
  if (q.size === 0)
@@ -424,7 +650,7 @@ export class FileMemoryEngineBackend {
424
650
  const scored = [];
425
651
  for (const scope of scopes) {
426
652
  const dir = this.scopeDir(scope);
427
- for (const e of this.readScope(scope, adopt)) {
653
+ for (const e of read(scope)) {
428
654
  const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
429
655
  const d = jaccardDistance(q, haystack);
430
656
  if (d === null)
@@ -223,9 +223,16 @@ export interface MemorySessionHandle {
223
223
  * the gate's whole purpose is keeping those bytes out of the prompt, so a refused clear must not
224
224
  * leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
225
225
  indexOnDiskUntrusted?: boolean;
226
+ /** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
227
+ * either the plane is a read-only layering (`writeScope === null`) or the caller declared the
228
+ * session unable to persist (an explicit verdict — never inferred down here). Restricted sessions
229
+ * read committed state only (ledger + control-plane shadow); disk divergence with no transaction
230
+ * backing is neither adopted into the committed account nor served, and `inject` reads the
231
+ * materialize-time index text instead of the live on-disk file. */
232
+ adoptionRestricted?: boolean;
226
233
  }
227
234
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
228
- export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "invalid";
235
+ export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "restricted_divergence" | "invalid";
229
236
  /** One rejected file: path (relative to the memory dir), stable code, and a model-readable reason. */
230
237
  export interface HarvestRejection {
231
238
  path: string;
@@ -13,7 +13,12 @@
13
13
  * MySQL is NOT "can't support vectors" — it is `portable`-capable (JSON column + in-process cosine), just not
14
14
  * `native`. A deployment injects an embedder (config-driven); the store then reports the achieved rung.
15
15
  */
16
- /** Lower-cased alphanumeric term set of a string (for the lexical stand-in distance). */
16
+ /** Lower-cased lexical term set: alphanumeric runs PLUS CJK character bigrams (a single-character
17
+ * run contributes its unigram). CJK scripts have no `a-z0-9` runs at all, so an alphanumeric-only
18
+ * tokenizer made every pure-CJK entry an EMPTY set — never a lexical-rung candidate for any query
19
+ * — and a pure-CJK query returned nothing; character bigrams are the standard analyzer unit there
20
+ * (word boundaries are not written). Bigrams stay within one run: adjacency across an intervening
21
+ * non-CJK character is not real adjacency. */
17
22
  export declare function termSet(s: string): Set<string>;
18
23
  /** Lexical stand-in distance: `1 - Jaccard(terms)` ∈ [0,1] ⊂ [0,2]; `null` = no overlap (not a candidate). */
19
24
  export declare function jaccardDistance(query: Set<string>, text: string): number | null;
@@ -1,8 +1,18 @@
1
+ const CJK_RUN = /[\u3040-\u30FF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF]+/g;
1
2
  export function termSet(s) {
2
- return new Set(s
3
- .toLowerCase()
4
- .split(/[^a-z0-9]+/)
5
- .filter(Boolean));
3
+ const lower = s.toLowerCase();
4
+ const out = new Set();
5
+ for (const run of lower.split(/[^a-z0-9]+/))
6
+ if (run)
7
+ out.add(run);
8
+ for (const m of lower.matchAll(CJK_RUN)) {
9
+ const chars = [...m[0]];
10
+ for (const c of chars)
11
+ out.add(c);
12
+ for (let i = 0; i + 1 < chars.length; i++)
13
+ out.add(chars[i] + chars[i + 1]);
14
+ }
15
+ return out;
6
16
  }
7
17
  export function jaccardDistance(query, text) {
8
18
  const t = termSet(text);
@@ -1,5 +1,6 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
2
  import { parseScopeKey } from "./memory-engine/scope-contract.js";
3
+ import { termSet } from "./memory-vector.js";
3
4
  import { sanitizeUntrustedText } from "./untrusted-text.js";
4
5
  export function supportsConsolidation(store) {
5
6
  return (typeof store.searchScored === "function" &&
@@ -148,12 +149,6 @@ export function guardedMemoryStore(inner, utilityGate) {
148
149
  function renderBullet(e) {
149
150
  return `- (${e.ts} UTC) ${e.text}`;
150
151
  }
151
- function termSet(s) {
152
- return new Set(s
153
- .toLowerCase()
154
- .split(/[^a-z0-9]+/)
155
- .filter(Boolean));
156
- }
157
152
  export class InMemoryMemoryStore {
158
153
  byScope = new Map();
159
154
  cursorByScope = new Map();
@@ -120,6 +120,53 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
120
120
  * an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
121
121
  */
122
122
  export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
123
+ /**
124
+ * design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
125
+ * (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
126
+ * thing that would let it produce a prefix).
127
+ *
128
+ * A flat set of BODIES — word sequences, each at least two words. A prefix candidate exists for a
129
+ * command iff some body here is a word-boundary prefix of its folded form, and the LONGEST hit wins:
130
+ * the deeper body is the narrower rule, so listing (or not listing) a deeper body is how this table
131
+ * sets suggestion granularity per branch. No groups, no denylist, and no fallback arm: a head outside
132
+ * the table, an unreviewed subcommand, a runtime-defined name (a git alias, a `git-<x>`/`cargo-<x>`
133
+ * external subcommand, a gh extension, a kubectl plugin), a flag or operand in a body position and a
134
+ * quoted token all fail the same way — by not being listed. Closure comes from positive enumeration
135
+ * itself, never from an exclusion list racing names that only exist at runtime.
136
+ *
137
+ * Review criteria — every row must pass BOTH axes (the same principle as the interpreter refusal
138
+ * above: the rule text must not read narrower than what it grants):
139
+ * · "runs what it is told to": a body whose use is fetching or naming a program to execute
140
+ * (`npm exec`, `docker run`, `kubectl exec`, `gh extension`, `git submodule foreach`, the install
141
+ * family) is refused — one click cannot be read as having granted arbitrary execution. Running the
142
+ * WORKSPACE'S OWN pinned content (`npm run`, `npm ci`, `cargo run`, `cargo test`) is inside the
143
+ * boundary: the scripts and lockfiles those execute are checked into the repository being worked on.
144
+ * · "rewrites what others execute": a body whose main use is writing configuration that changes what
145
+ * OTHER commands later run (`git config` — hooksPath/pager/alias; `kubectl config` —
146
+ * exec-credential; `npm config`/`npm set` — script-shell; `go env` — persisted GOFLAGS/GOBIN) is
147
+ * refused — its readable width and its real width differ by a whole composition surface.
148
+ * Past both axes there is deliberately NO "dangerousness" axis: `git push:*` and `git rebase:*` are
149
+ * wide but readable, and a person nodding at that text is granting exactly that.
150
+ *
151
+ * Residual width, stated rather than hidden (a reviewed trade, not an oversight): a prefix rule
152
+ * admits ANY arguments after its body, and some listed bodies carry flags that name a program to
153
+ * execute (`go build`/`go test`/`go vet -toolexec`, `git fetch --upload-pack`, `git rebase -x`,
154
+ * `git grep -O`, `git push --receive-pack`). The axes judge a body's MAIN use, not every flag —
155
+ * per-flag grammar is the road this module's history rejected twice, and applied consistently it
156
+ * would empty the table. Three standing fences hold that residue: the org deny/ask layer runs ahead
157
+ * of the rule lane and cannot be silenced by it; a mandated ask (egress/irreversibility marks,
158
+ * shellGate:"always") is not rule-clearable either; and the narrower exact candidate — plus minting
159
+ * no rule at all — is always on the same card.
160
+ *
161
+ * Maintenance: adding a row is a reviewed change — keep the per-group reasoning beside it current.
162
+ * The integrity pins (every body ≥ 2 words, lowercase word shape, no interpreter heads, no
163
+ * duplicates) are enforced by this module's test suite. This table and the read-only classifier's
164
+ * allowlists are DIFFERENT instruments and must never be merged or cross-referenced: that one is a
165
+ * machine auto-allow face whose criterion is "provably read-only"; this one is a human suggestion
166
+ * face whose criterion is "width a person can read off the rule text". One guards against a machine
167
+ * loosening; the other against a person being misled.
168
+ */
169
+ export declare const SUGGESTION_LEXICON: readonly string[];
123
170
  /**
124
171
  * Parse one rule text into its canonical shape, or refuse it with a reason.
125
172
  *
@@ -184,11 +231,29 @@ export interface RuleSuggestion {
184
231
  /**
185
232
  * The 1-2 candidates offered on an approval card for `command`.
186
233
  *
187
- * The exact form (this whole command line) is always offered when the command is matchable at all. A
188
- * prefix form is offered only when it is a shape a person can read as narrow: the upstream multi-word
189
- * convention (`git status`, `npm run`) is preferred over a bare program name, and no prefix at all is
190
- * offered for an interpreter head that candidate would be refused by the validator anyway, and
191
- * offering a candidate the store will not accept is worse than offering one fewer.
234
+ * **Array order is a documented CONTRACT, not an implementation accident**: display order = array
235
+ * order = narrowest first. The EXACT form (this whole command line) is always index 0 whenever
236
+ * anything is offered at all; a broader reviewed PREFIX form at most one follows at index 1.
237
+ * Selection indices and redemption tickets are index-keyed against this order (a card's
238
+ * `selectedCandidate` and its `rt.<index>.` tickets), so consumers may rely on it.
239
+ *
240
+ * The prefix candidate comes ONLY from {@link SUGGESTION_LEXICON} — the longest reviewed body that is
241
+ * a word-boundary prefix of the folded command. History, and why there is no heuristic arm: two
242
+ * rounds of guessing produced two different wrong answers — a bare program name (`rm -f x` →
243
+ * `Bash(rm:*)`), then an operand mistaken for a subcommand (`rm harmless.txt` →
244
+ * `Bash(rm harmless.txt:*)`, which admits a second, unnamed target) — and both failed the same way:
245
+ * nothing in the command TEXT distinguishes a subcommand from an operand without a per-command
246
+ * grammar. The lexicon IS that grammar, per reviewed row; anything it does not list (a bare verb, an
247
+ * interpreter head, an unreviewed subcommand, a runtime-defined name) yields no prefix, with no
248
+ * fallback. Naive spacing note: `folded` keeps quoted whitespace, so splitting on single spaces can
249
+ * shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
250
+ * characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
251
+ *
252
+ * Every produced candidate must survive the round trip — parse as a rule AND admit the very command
253
+ * it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
254
+ * is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
255
+ * round-trip is silently not offered, since offering an option redemption would refuse is worse than
256
+ * offering one fewer.
192
257
  *
193
258
  * Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
194
259
  * substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
@@ -6,6 +6,54 @@ export const BARE_INTERPRETER_NAMES = new Set([
6
6
  "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
7
7
  "osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
8
8
  ]);
9
+ export const SUGGESTION_LEXICON = [
10
+ "git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
11
+ "git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
12
+ "git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
13
+ "git rev-parse", "git ls-files", "git grep",
14
+ "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
15
+ "git remote show", "git worktree list", "git submodule update", "git submodule status",
16
+ "npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
17
+ "npm pack", "npm publish", "npm version", "npm why",
18
+ "pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
19
+ "yarn run", "yarn test", "yarn workspaces list",
20
+ "cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
21
+ "cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
22
+ "go build", "go test", "go vet", "go fmt", "go doc",
23
+ "go mod tidy", "go mod download", "go mod verify", "go mod graph",
24
+ "docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
25
+ "docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
26
+ "docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
27
+ "docker compose build", "docker compose pull",
28
+ "kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
29
+ "kubectl diff", "kubectl explain", "kubectl top",
30
+ "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
31
+ "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
32
+ "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
33
+ "gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
34
+ "gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
35
+ "gh search repos", "gh search issues", "gh search prs", "gh search code",
36
+ ];
37
+ const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
38
+ function longestReviewedBody(tokens) {
39
+ let best;
40
+ for (const words of LEXICON_BODIES) {
41
+ if (words.length > tokens.length)
42
+ continue;
43
+ if (best !== undefined && words.length <= best.length)
44
+ continue;
45
+ let hit = true;
46
+ for (let i = 0; i < words.length; i++) {
47
+ if (words[i] !== tokens[i]) {
48
+ hit = false;
49
+ break;
50
+ }
51
+ }
52
+ if (hit)
53
+ best = words;
54
+ }
55
+ return best?.join(" ");
56
+ }
9
57
  function foldSpacing(s) {
10
58
  let out = "";
11
59
  let quote;
@@ -131,5 +179,15 @@ export function suggestRulesForCommand(command) {
131
179
  const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
132
180
  if ("rule" in exact)
133
181
  out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
182
+ if (out.length === 1) {
183
+ const body = longestReviewedBody(folded.split(" "));
184
+ if (body !== undefined) {
185
+ const text = formatAllowRuleText(body, "prefix");
186
+ const parsed = parseAllowRuleText(text);
187
+ if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
188
+ out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
189
+ }
190
+ }
191
+ }
134
192
  return out;
135
193
  }
@@ -126,7 +126,12 @@ export async function prepareMemory(input) {
126
126
  };
127
127
  };
128
128
  const onEngineIncident = (err) => deps.onError?.(err, { phase: "memory", sessionId });
129
- const retrievalBackend = (b) => b.retrievalView?.() ?? b;
129
+ const adoptionRestricted = input.memoryPersistenceDeclared === false;
130
+ const retrievalBackend = (b, planeRestricted) => planeRestricted
131
+ ? (b.restrictedAdoptionView?.({ audit: false }) ??
132
+ b.retrievalView?.() ??
133
+ b)
134
+ : (b.retrievalView?.() ?? b);
130
135
  const planeScopes = (scopes, write) => [...new Set([...scopes, ...(write !== null ? [write] : [])])];
131
136
  const pollutedOpts = (engine) => {
132
137
  const rec = engine.sessionPollution(sessionId);
@@ -173,8 +178,8 @@ export async function prepareMemory(input) {
173
178
  const personal = createPersonalEngine(personalBackendChosen);
174
179
  const personalEngine = personal.engine;
175
180
  const p = planes;
176
- const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
177
- const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null);
181
+ const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null, { adoptionRestricted });
182
+ const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null, { adoptionRestricted });
178
183
  const writeIsPersonal = p.writePlane === "personal";
179
184
  writeEngine = writeIsPersonal ? personalEngine : projectEngine;
180
185
  writeHandle = writeIsPersonal ? personalHandle : projectHandle;
@@ -183,13 +188,13 @@ export async function prepareMemory(input) {
183
188
  injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
184
189
  toolPlanes = [
185
190
  {
186
- backend: retrievalBackend(backend),
191
+ backend: retrievalBackend(backend, adoptionRestricted || p.writePlane !== "project"),
187
192
  scopes: planeScopes(p.project, p.writePlane === "project" ? memorySpec.writeScope : null),
188
193
  recordRetrieved: (ids) => projectEngine.recordRetrieved(ids),
189
194
  challengeExclusions: () => projectEngine.readChallengeExclusions(),
190
195
  },
191
196
  {
192
- backend: retrievalBackend(personal.backend),
197
+ backend: retrievalBackend(personal.backend, adoptionRestricted || p.writePlane !== "personal"),
193
198
  scopes: planeScopes(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null),
194
199
  recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
195
200
  challengeExclusions: () => personalEngine.readChallengeExclusions(),
@@ -216,14 +221,14 @@ export async function prepareMemory(input) {
216
221
  else if (personalOnly) {
217
222
  const personal = createPersonalEngine(choosePersonalBackend());
218
223
  const personalEngine = personal.engine;
219
- const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
224
+ const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
220
225
  writeEngine = personalEngine;
221
226
  writeHandle = handle;
222
227
  injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
223
228
  harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId, ...admitNothingOpts });
224
229
  toolPlanes = [
225
230
  {
226
- backend: retrievalBackend(personal.backend),
231
+ backend: retrievalBackend(personal.backend, adoptionRestricted || memorySpec.writeScope === null),
227
232
  scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
228
233
  recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
229
234
  challengeExclusions: () => personalEngine.readChallengeExclusions(),
@@ -237,14 +242,14 @@ export async function prepareMemory(input) {
237
242
  controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot),
238
243
  onIncident: onEngineIncident,
239
244
  });
240
- const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
245
+ const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
241
246
  writeEngine = engine;
242
247
  writeHandle = handle;
243
248
  injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
244
249
  harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId, ...admitNothingOpts });
245
250
  toolPlanes = [
246
251
  {
247
- backend: retrievalBackend(backend),
252
+ backend: retrievalBackend(backend, adoptionRestricted || memorySpec.writeScope === null),
248
253
  scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
249
254
  recordRetrieved: (ids) => engine.recordRetrieved(ids),
250
255
  challengeExclusions: () => engine.readChallengeExclusions(),