@north-light/crouter 0.3.232 → 0.3.233
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/dist/builtin-memory/internal/memory-loading.md +4 -3
- package/dist/commands/memory/shared.d.ts +1 -1
- package/dist/commands/memory/shared.js +3 -3
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +24 -0
- package/dist/core/substrate/on-read.d.ts +17 -1
- package/dist/core/substrate/on-read.js +36 -2
- package/dist/core/substrate/schema.d.ts +3 -3
- package/dist/core/substrate/schema.js +4 -4
- package/dist/core/substrate/surface-match.d.ts +19 -0
- package/dist/core/substrate/surface-match.js +60 -0
- package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -9,7 +9,7 @@ surfaces:
|
|
|
9
9
|
|
|
10
10
|
# How memory loads
|
|
11
11
|
|
|
12
|
-
Every memory doc declares its own delivery in frontmatter `surfaces` entries; the runtime never guesses. Delivery is
|
|
12
|
+
Every memory doc declares its own delivery in frontmatter `surfaces` entries; the runtime never guesses. Delivery is six events, one rung per entry, document and entry gates, and structural ordering. The authoring contract (flags, routing-line craft) is `crtr memory write -h`; which scope to write to is `internal/agent-shaping`; physical paths are `internal/storage-tiers`. This doc is the mechanics between those: what actually fires, when, and in what order.
|
|
13
13
|
|
|
14
14
|
## Surfaces entries
|
|
15
15
|
|
|
@@ -19,7 +19,8 @@ A doc with no `surfaces` does exactly one thing: appears in its directory's list
|
|
|
19
19
|
- **workspace-open** — first-message context when cwd/profile mounts the doc's project store. Project stores only.
|
|
20
20
|
- **read** — a `read` tool call returned a matching file: path globs vs the file's absolute path and basename, `./`-anchored globs vs its path relative to the store's owning repo dir, `match-frontmatter` predicates over the read file's own YAML frontmatter.
|
|
21
21
|
- **memory-read** — a `crtr memory read` resolved a matching doc: name globs vs its canonical name, `./` anchored to this doc's canonical routing anchor (a collapsed directory document anchors at its own directory name).
|
|
22
|
-
- **command** — a matching shell command ran: globs vs the whole command string, `*` crossing `/`. Delivery is post-execution — right for "you are now in this territory
|
|
22
|
+
- **command** — a matching shell command ran: globs vs the whole command string, `*` crossing `/`. Delivery is post-execution — right for "you are now in this territory"; when the point is "don't run this at all," use `pre-command` instead.
|
|
23
|
+
- **pre-command** — a matching `bash` command is about to run. When the memory has not been read yet, the command does not execute — the memory comes back as the tool result instead, and the agent re-issues the command, which then runs. Use it to guarantee a memory is seen before a sensitive action is taken. Same globs as `command`, matched at command position; `match` is required. A guardrail against the faithful-but-uninformed action, not an enforcement boundary.
|
|
23
24
|
|
|
24
25
|
Nothing positional fires from where a doc happens to sit on disk — only from its declared entries and its listing.
|
|
25
26
|
|
|
@@ -49,7 +50,7 @@ At boot/first-message assembly the runtime mounts: builtin docs, the user store
|
|
|
49
50
|
|
|
50
51
|
Each project the selected profile has a relationship with carries a `memory` value — `none`, `name`, `preview`, or `content` — and that value is the maximum rung anything in that project's stores delivers at boot and workspace-open, whatever directory the node is working in. It only lowers: an entry authored below the maximum delivers at its authored rung. `none` contributes nothing to either automatic event, so a `none` project cannot shadow a same-named doc from a wider scope — that wider doc becomes the winner. A project store the selected profile has no relationship with delivers exactly what it authored.
|
|
51
52
|
|
|
52
|
-
The maximum reaches those two events and nothing else. Read, memory-read, and command entries fire at their authored rungs; directory listings, `crtr memory read`, `crtr memory find`, config resolution, and plugin discovery all see the full corpus. An explicit `crtr memory read` is itself a content delivery, so reading a capped or document-gated doc returns its whole body and records content — the upgrade path for a doc the automatic events disclosed only by name or preview. Entry gates apply to companion docs routed by the `memory-read` event, not to the deliberate read or its listings.
|
|
53
|
+
The maximum reaches those two events and nothing else. Read, memory-read, command, and pre-command entries fire at their authored rungs; directory listings, `crtr memory read`, `crtr memory find`, config resolution, and plugin discovery all see the full corpus. An explicit `crtr memory read` is itself a content delivery, so reading a capped or document-gated doc returns its whole body and records content — the upgrade path for a doc the automatic events disclosed only by name or preview. Entry gates apply to companion docs routed by the `memory-read` event, not to the deliberate read or its listings.
|
|
53
54
|
|
|
54
55
|
A workspace's front door is an ordinary doc carrying the entry pair `{on: workspace-open, at: content}` + `{on: read, match: "./**", at: content}` — the operating guide loads when that workspace mounts or its files are read, not in every boot catalog. `crtr memory lint` requires exactly one workspace-open content doc per profile-managed project store. Multiple mounted roots render broad-to-specific.
|
|
55
56
|
|
|
@@ -194,7 +194,7 @@ export declare function overlayParam(name: string, overrides?: Partial<FlagParam
|
|
|
194
194
|
* `--doc-rationale`, because `--rationale` there means why THIS REVISION is
|
|
195
195
|
* happening. Same field, same prose, two flag names that cannot be confused. */
|
|
196
196
|
export declare const DOC_RATIONALE_CONSTRAINT = "Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.";
|
|
197
|
-
export declare const GUIDE_SURFACES = "Every doc appears in its directory\u2019s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing \u2014 entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc\u2019s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file\u2019s absolute path and basename, `./`-anchored globs vs its path relative to the store\u2019s owning repo dir, `match-frontmatter` predicates over the read file\u2019s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc\u2019s canonical name, `./` anchored to this doc\u2019s routing anchor \u2014 its own canonical name when it is its directory\u2019s document, otherwise the canonical directory it sits in); `command` fires when a matching shell command runs (globs vs each shell segment of the command line \u2014 split on `&&`/`||`/`;`/`|`/newlines outside quotes, leading `VAR=value` assignments dropped \u2014 where `*` spans whole whitespace-separated tokens and never part of one, `?` is one character inside a token, and quoted argument text is opaque, so `*git commit*` fires for `cd x && git commit -m \"\u2026\"` but not for `git commit-tree` or an `echo` that merely mentions it). An entry may also carry `gate`, a node-config predicate using the same vocabulary as the document-level `gate`: event constraints and entry gate both must match, then all participating entries fold to the highest `at` (`content` > `preview` > `name`), with no cross-entry deny precedence. The document-level `gate` remains the hard eligibility check: when it fails, no automatic surface can deliver the document. `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Explicit `memory read` and listings remain deliberate access and ignore gates/rungs; only companion docs routed by their `memory-read` entries use entry gates. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly \u2014 a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.";
|
|
197
|
+
export declare const GUIDE_SURFACES = "Every doc appears in its directory\u2019s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing \u2014 entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc\u2019s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file\u2019s absolute path and basename, `./`-anchored globs vs its path relative to the store\u2019s owning repo dir, `match-frontmatter` predicates over the read file\u2019s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc\u2019s canonical name, `./` anchored to this doc\u2019s routing anchor \u2014 its own canonical name when it is its directory\u2019s document, otherwise the canonical directory it sits in); `command` fires when a matching shell command runs (globs vs each shell segment of the command line \u2014 split on `&&`/`||`/`;`/`|`/newlines outside quotes, leading `VAR=value` assignments dropped \u2014 where `*` spans whole whitespace-separated tokens and never part of one, `?` is one character inside a token, and quoted argument text is opaque, so `*git commit*` fires for `cd x && git commit -m \"\u2026\"` but not for `git commit-tree` or an `echo` that merely mentions it); `pre-command` fires BEFORE a matching bash command runs (same matching): when the memory has not been read yet, the command does not execute \u2014 the memory comes back as the tool result instead, and the agent re-issues the command, which then runs. Use it to guarantee a memory is seen before a sensitive action is taken. Requires `match`. An entry may also carry `gate`, a node-config predicate using the same vocabulary as the document-level `gate`: event constraints and entry gate both must match, then all participating entries fold to the highest `at` (`content` > `preview` > `name`), with no cross-entry deny precedence. The document-level `gate` remains the hard eligibility check: when it fails, no automatic surface can deliver the document. `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Explicit `memory read` and listings remain deliberate access and ignore gates/rungs; only companion docs routed by their `memory-read` entries use entry gates. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly \u2014 a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.";
|
|
198
198
|
export declare const GUIDE_ROUTING_LINE = "The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its when-clause: it names an observable circumstance in the reader\u2019s current work, not the doc\u2019s topic reworded as an activity. If the WHEN can be inferred from the title alone, it is not a real trigger. Bad on a todo list: \"When planning or prioritizing work across this profile.\" Good: \"When the user mentions something from their todos, or asks what is still outstanding across this profile.\" The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: \"because only genuine first principles belong in taste memory.\" Bad: \"because keeping the test loop fast and free of speculative tests protects the development pace\" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: \"because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation.\" Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.";
|
|
199
199
|
export declare const GUIDE_PREDICATE_VOCABULARY = "Document gates, surface-entry gates, and match-frontmatter share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.";
|
|
200
200
|
export declare const GUIDE_DOC_LINKS = "Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes, namespace included for a project document. A bare directory name is a valid link too: following it returns that directory\u2019s own document when it has one, plus the directory listing. Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document or directory, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias, label, or old-name form \u2014 a renamed target needs its links rewritten.";
|
|
@@ -436,7 +436,7 @@ export function coerceSurface(raw) {
|
|
|
436
436
|
throw usage(`--surface: invalid \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`);
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
|
-
if ((on === 'read' || on === 'memory-read' || on === 'command') && match === undefined && matchFrontmatter === undefined) {
|
|
439
|
+
if ((on === 'read' || on === 'memory-read' || on === 'command' || on === 'pre-command') && match === undefined && matchFrontmatter === undefined) {
|
|
440
440
|
throw usage(`--surface: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`);
|
|
441
441
|
}
|
|
442
442
|
const gate = rec['gate'];
|
|
@@ -768,7 +768,7 @@ export const FRONTMATTER_OVERLAY_PARAMS = {
|
|
|
768
768
|
'when-and-why-to-read': { kind: 'flag', name: 'when-and-why-to-read', type: 'string', required: false, constraint: 'ONE routing sentence: "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the reader\u2019s payoff \u2014 the consequence they secure for their task by reading \u2014 NEVER the doc summary, its rule, or that rule reworded as an outcome (a benefit-shaped restatement still fails). Rendered verbatim as the preview.' },
|
|
769
769
|
'short-form': { kind: 'flag', name: 'short-form', type: 'string', required: false, constraint: 'Frontmatter short-form \u2014 a very abbreviated version of the content, the hook shown in `crtr memory list`.' },
|
|
770
770
|
'unlisted': { kind: 'flag', name: 'unlisted', type: 'bool', required: false, default: false, constraint: 'Suppress this doc from directory listings. Suppression only — explicit reads, [[links]], and surfaces entries still work.' },
|
|
771
|
-
'surface': { kind: 'flag', name: 'surface', type: 'string', required: false, repeatable: true, constraint: 'One routing entry per occurrence, as a YAML/JSON object `{on, at, match?, match-frontmatter?, gate?}`; the flag set replaces the document’s whole surfaces list. `on` is boot|workspace-open|read|memory-read|command; `at` is name|preview|content. `match` holds the event’s globs — required on read/memory-read/command (a read entry may carry `match-frontmatter`, a predicate over the read file’s own frontmatter, instead), meaningless on boot/workspace-open. Optional entry `gate` is a node-config predicate using the same vocabulary as the document gate; its event constraints and gate both match before it participates. A `./`-anchored glob is relative: for `read` to the store’s owning repo dir, for `memory-read` to this doc’s routing anchor — its own canonical name when the doc is its directory’s document (`<dir>/INDEX.md`), otherwise the canonical directory it sits in. Participating entries fold to their highest `at`; there is no cross-entry deny precedence.' },
|
|
771
|
+
'surface': { kind: 'flag', name: 'surface', type: 'string', required: false, repeatable: true, constraint: 'One routing entry per occurrence, as a YAML/JSON object `{on, at, match?, match-frontmatter?, gate?}`; the flag set replaces the document’s whole surfaces list. `on` is boot|workspace-open|read|memory-read|command|pre-command; `at` is name|preview|content. `match` holds the event’s globs — required on read/memory-read/command/pre-command (a read entry may carry `match-frontmatter`, a predicate over the read file’s own frontmatter, instead), meaningless on boot/workspace-open. Optional entry `gate` is a node-config predicate using the same vocabulary as the document gate; its event constraints and gate both match before it participates. A `./`-anchored glob is relative: for `read` to the store’s owning repo dir, for `memory-read` to this doc’s routing anchor — its own canonical name when the doc is its directory’s document (`<dir>/INDEX.md`), otherwise the canonical directory it sits in. Participating entries fold to their highest `at`; there is no cross-entry deny precedence.' },
|
|
772
772
|
'gate': { kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate \u2014 YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
|
|
773
773
|
'slash': { kind: 'flag', name: 'slash', type: 'bool', required: false, default: false, constraint: 'Presence flags this doc invocable as a pi slash command (`/<name>`, `/` in a nested name rendered as `:`) \u2014 the doc body becomes the command\u2019s injected prompt. Default false: most docs are consulted, not invoked.' },
|
|
774
774
|
};
|
|
@@ -786,7 +786,7 @@ export function overlayParam(name, overrides = {}, extraConstraint) {
|
|
|
786
786
|
* `--doc-rationale`, because `--rationale` there means why THIS REVISION is
|
|
787
787
|
* happening. Same field, same prose, two flag names that cannot be confused. */
|
|
788
788
|
export const DOC_RATIONALE_CONSTRAINT = 'Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.';
|
|
789
|
-
export const GUIDE_SURFACES = 'Every doc appears in its directory’s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing — entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc’s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file’s absolute path and basename, `./`-anchored globs vs its path relative to the store’s owning repo dir, `match-frontmatter` predicates over the read file’s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc’s canonical name, `./` anchored to this doc’s routing anchor — its own canonical name when it is its directory’s document, otherwise the canonical directory it sits in); `command` fires when a matching shell command runs (globs vs each shell segment of the command line — split on `&&`/`||`/`;`/`|`/newlines outside quotes, leading `VAR=value` assignments dropped — where `*` spans whole whitespace-separated tokens and never part of one, `?` is one character inside a token, and quoted argument text is opaque, so `*git commit*` fires for `cd x && git commit -m "…"` but not for `git commit-tree` or an `echo` that merely mentions it). An entry may also carry `gate`, a node-config predicate using the same vocabulary as the document-level `gate`: event constraints and entry gate both must match, then all participating entries fold to the highest `at` (`content` > `preview` > `name`), with no cross-entry deny precedence. The document-level `gate` remains the hard eligibility check: when it fails, no automatic surface can deliver the document. `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Explicit `memory read` and listings remain deliberate access and ignore gates/rungs; only companion docs routed by their `memory-read` entries use entry gates. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly — a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.';
|
|
789
|
+
export const GUIDE_SURFACES = 'Every doc appears in its directory’s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing — entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc’s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file’s absolute path and basename, `./`-anchored globs vs its path relative to the store’s owning repo dir, `match-frontmatter` predicates over the read file’s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc’s canonical name, `./` anchored to this doc’s routing anchor — its own canonical name when it is its directory’s document, otherwise the canonical directory it sits in); `command` fires when a matching shell command runs (globs vs each shell segment of the command line — split on `&&`/`||`/`;`/`|`/newlines outside quotes, leading `VAR=value` assignments dropped — where `*` spans whole whitespace-separated tokens and never part of one, `?` is one character inside a token, and quoted argument text is opaque, so `*git commit*` fires for `cd x && git commit -m "…"` but not for `git commit-tree` or an `echo` that merely mentions it); `pre-command` fires BEFORE a matching bash command runs (same matching): when the memory has not been read yet, the command does not execute — the memory comes back as the tool result instead, and the agent re-issues the command, which then runs. Use it to guarantee a memory is seen before a sensitive action is taken. Requires `match`. An entry may also carry `gate`, a node-config predicate using the same vocabulary as the document-level `gate`: event constraints and entry gate both must match, then all participating entries fold to the highest `at` (`content` > `preview` > `name`), with no cross-entry deny precedence. The document-level `gate` remains the hard eligibility check: when it fails, no automatic surface can deliver the document. `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Explicit `memory read` and listings remain deliberate access and ignore gates/rungs; only companion docs routed by their `memory-read` entries use entry gates. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly — a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.';
|
|
790
790
|
export const GUIDE_ROUTING_LINE = 'The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its when-clause: it names an observable circumstance in the reader\u2019s current work, not the doc\u2019s topic reworded as an activity. If the WHEN can be inferred from the title alone, it is not a real trigger. Bad on a todo list: "When planning or prioritizing work across this profile." Good: "When the user mentions something from their todos, or asks what is still outstanding across this profile." The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: "because only genuine first principles belong in taste memory." Bad: "because keeping the test loop fast and free of speculative tests protects the development pace" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: "because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation." Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.';
|
|
791
791
|
export const GUIDE_PREDICATE_VOCABULARY = 'Document gates, surface-entry gates, and match-frontmatter share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.';
|
|
792
792
|
export const GUIDE_DOC_LINKS = 'Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes, namespace included for a project document. A bare directory name is a valid link too: following it returns that directory\u2019s own document when it has one, plus the directory listing. Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document or directory, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias, label, or old-name form \u2014 a renamed target needs its links rewritten.';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Regression: `pre-command` matching holds a command BEFORE it runs, so it
|
|
2
|
+
// costs the agent a whole turn when it fires. Two ways it can be wrong:
|
|
3
|
+
//
|
|
4
|
+
// • It fires on a mention rather than an invocation. A doc-writing command
|
|
5
|
+
// whose heredoc body merely quotes a matched invocation must run untouched
|
|
6
|
+
// — only the heredoc's opening line is a real command.
|
|
7
|
+
// • It ignores the entry's own `gate`. A doc whose entry excludes this node
|
|
8
|
+
// must never hold that node's commands.
|
|
9
|
+
//
|
|
10
|
+
// It also pins the shared-routing invariant: the pre-command matcher is the
|
|
11
|
+
// post-execution `command` matcher plus heredoc stripping, and nothing else.
|
|
12
|
+
// One command-glob semantics, not two.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --conditions=crtr-src --import tsx/esm --test src/core/substrate/__tests__/surface-match-pre-command.test.ts
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { matchesCommandEntry, matchesPreCommandEntry, preCommandDeliveryRung } from '../surface-match.js';
|
|
18
|
+
const SUBJECT = {
|
|
19
|
+
kind: 'developer',
|
|
20
|
+
mode: 'base',
|
|
21
|
+
lifecycle: 'terminal',
|
|
22
|
+
hasManager: true,
|
|
23
|
+
cwd: '/tmp/project',
|
|
24
|
+
scope: 'project',
|
|
25
|
+
orchestration: { depth: 1 },
|
|
26
|
+
profile: 'northlight',
|
|
27
|
+
};
|
|
28
|
+
function entry(match, gate) {
|
|
29
|
+
return { on: 'pre-command', at: 'content', match, ...(gate !== undefined ? { gate } : {}) };
|
|
30
|
+
}
|
|
31
|
+
// --- the behaviour this event adds -----------------------------------------
|
|
32
|
+
test('a matched invocation inside a heredoc BODY does not hold the command', () => {
|
|
33
|
+
const command = [
|
|
34
|
+
"crtr memory write approvals --body <<'EOF'",
|
|
35
|
+
'Before you run `crtr integration run --action send`, get approval.',
|
|
36
|
+
'EOF',
|
|
37
|
+
].join('\n');
|
|
38
|
+
assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
|
|
39
|
+
});
|
|
40
|
+
test('the same invocation on the heredoc OPENING line still holds the command', () => {
|
|
41
|
+
const command = ["crtr integration run --action send --body <<'EOF'", 'hello', 'EOF'].join('\n');
|
|
42
|
+
assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), true);
|
|
43
|
+
});
|
|
44
|
+
test('a `<<-` heredoc body is stripped too, tabs and all', () => {
|
|
45
|
+
const command = ['cat <<-EOF', '\tcrtr integration run --action send', '\tEOF'].join('\n');
|
|
46
|
+
assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
|
|
47
|
+
});
|
|
48
|
+
test('an unterminated heredoc drops its body rather than leaking it back in', () => {
|
|
49
|
+
const command = ["crtr memory write d --body <<'EOF'", 'crtr integration run --action send'].join('\n');
|
|
50
|
+
assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
|
|
51
|
+
});
|
|
52
|
+
test('an entry whose gate excludes the subject never holds a command', () => {
|
|
53
|
+
const excluded = entry(['crtr integration run*'], { lifecycle: 'resident' });
|
|
54
|
+
assert.equal(matchesPreCommandEntry(excluded, SUBJECT, 'crtr integration run --action send'), false);
|
|
55
|
+
const included = entry(['crtr integration run*'], { lifecycle: 'terminal' });
|
|
56
|
+
assert.equal(matchesPreCommandEntry(included, SUBJECT, 'crtr integration run --action send'), true);
|
|
57
|
+
});
|
|
58
|
+
test('a `command` entry is never consumed by the pre-command matcher, nor the reverse', () => {
|
|
59
|
+
const post = { on: 'command', at: 'content', match: ['crtr integration run*'] };
|
|
60
|
+
assert.equal(matchesPreCommandEntry(post, SUBJECT, 'crtr integration run --action send'), false);
|
|
61
|
+
assert.equal(matchesCommandEntry(entry(['crtr integration run*']), SUBJECT, 'crtr integration run --action send'), false);
|
|
62
|
+
});
|
|
63
|
+
// --- the rung fold ---------------------------------------------------------
|
|
64
|
+
test('the rung fold returns the highest `at` across matching entries, and none when nothing matches', () => {
|
|
65
|
+
const doc = {
|
|
66
|
+
surfaces: [
|
|
67
|
+
entry(['crtr crustdata person search*']),
|
|
68
|
+
entry(['crtr integration run*']),
|
|
69
|
+
{ on: 'command', at: 'content', match: ['*'] },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
assert.equal(preCommandDeliveryRung(doc, SUBJECT, 'crtr integration run --action send'), 'content');
|
|
73
|
+
assert.equal(preCommandDeliveryRung(doc, SUBJECT, 'ls -la'), 'none');
|
|
74
|
+
});
|
|
75
|
+
// --- shared routing: one command-glob semantics, not two --------------------
|
|
76
|
+
test('outside a heredoc, pre-command matching is exactly the `command` matcher', () => {
|
|
77
|
+
// The guard against a second command tokenizer drifting away from the first.
|
|
78
|
+
// Whatever `command` globs mean today, `pre-command` means the same thing —
|
|
79
|
+
// so a sharpening of command-position matching reaches both events at once.
|
|
80
|
+
const glob = 'crtr integration run*';
|
|
81
|
+
for (const command of [
|
|
82
|
+
'crtr integration run --action send',
|
|
83
|
+
'cd /tmp && crtr integration run --action send',
|
|
84
|
+
'echo hi\ncrtr integration run --action send',
|
|
85
|
+
'NL_ENV=1 crtr integration run --action send',
|
|
86
|
+
'crtr integration list',
|
|
87
|
+
'ls -la',
|
|
88
|
+
'echo "crtr integration run --action send"',
|
|
89
|
+
]) {
|
|
90
|
+
assert.equal(matchesPreCommandEntry(entry([glob]), SUBJECT, command), matchesCommandEntry({ on: 'command', at: 'content', match: [glob] }, SUBJECT, command), `pre-command and command disagreed on: ${JSON.stringify(command)}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -47,7 +47,7 @@ export function lintSubstrateSurfaces(value) {
|
|
|
47
47
|
return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && matchFrontmatter === undefined) {
|
|
50
|
+
if ((on === 'read' || on === 'memory-read' || on === 'command' || on === 'pre-command') && !hasMatch && matchFrontmatter === undefined) {
|
|
51
51
|
return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
|
|
52
52
|
}
|
|
53
53
|
const gate = entry['gate'];
|
|
@@ -24,6 +24,12 @@ export declare function contentExposureIdentity(body: string): string | null;
|
|
|
24
24
|
export declare function documentExposedAtOrAbove(state: ContextExposureState, path: string, body: string, rung: Rung): boolean;
|
|
25
25
|
/** Register both the physical document and its non-empty content alias. */
|
|
26
26
|
export declare function registerDocumentExposure(target: ExposureTarget, path: string, body: string, rung: Rung): void;
|
|
27
|
+
/** Forget that a document was delivered into the TRANSCRIPT — both its physical
|
|
28
|
+
* identity and its content alias — while leaving any `system` rank intact,
|
|
29
|
+
* because the system prompt is rebuilt on every agent run and survives
|
|
30
|
+
* compaction. This is what re-arms a `pre-command` hold for guidance that
|
|
31
|
+
* compaction dropped out of the transcript. */
|
|
32
|
+
export declare function demoteDocumentTranscriptExposure(state: ContextExposureState, path: string, body: string): void;
|
|
27
33
|
export declare function freezePreferenceSnapshot(state: ContextExposureState, rendered: string): string;
|
|
28
34
|
export declare function cloneContextExposureState(source: ContextExposureState): ContextExposureState;
|
|
29
35
|
export declare function hasTranscriptExposure(state: ContextExposureState): boolean;
|
|
@@ -49,6 +49,30 @@ export function registerDocumentExposure(target, path, body, rung) {
|
|
|
49
49
|
if (contentIdentity !== null)
|
|
50
50
|
registerExposure(target, contentIdentity, rung);
|
|
51
51
|
}
|
|
52
|
+
/** Drop one identity's transcript rank, deleting the entry outright when no
|
|
53
|
+
* `system` rank remains. The v3 loader rejects an entry carrying neither rank,
|
|
54
|
+
* and the writer serializes the map as-is, so leaving `{}` behind would crash
|
|
55
|
+
* the node's substrate on its next load. */
|
|
56
|
+
function dropTranscriptExposure(state, identity) {
|
|
57
|
+
const ranks = state.exposures.get(identity);
|
|
58
|
+
if (ranks === undefined)
|
|
59
|
+
return;
|
|
60
|
+
if (validRank(ranks.system))
|
|
61
|
+
state.exposures.set(identity, { system: ranks.system });
|
|
62
|
+
else
|
|
63
|
+
state.exposures.delete(identity);
|
|
64
|
+
}
|
|
65
|
+
/** Forget that a document was delivered into the TRANSCRIPT — both its physical
|
|
66
|
+
* identity and its content alias — while leaving any `system` rank intact,
|
|
67
|
+
* because the system prompt is rebuilt on every agent run and survives
|
|
68
|
+
* compaction. This is what re-arms a `pre-command` hold for guidance that
|
|
69
|
+
* compaction dropped out of the transcript. */
|
|
70
|
+
export function demoteDocumentTranscriptExposure(state, path, body) {
|
|
71
|
+
dropTranscriptExposure(state, realpathOrSelf(path));
|
|
72
|
+
const contentIdentity = contentExposureIdentity(body);
|
|
73
|
+
if (contentIdentity !== null)
|
|
74
|
+
dropTranscriptExposure(state, contentIdentity);
|
|
75
|
+
}
|
|
52
76
|
export function freezePreferenceSnapshot(state, rendered) {
|
|
53
77
|
if (state.preferenceSnapshot === null)
|
|
54
78
|
state.preferenceSnapshot = rendered;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ExposureTarget } from './injected-store.js';
|
|
1
|
+
import { type ContextExposureState, type ExposureTarget } from './injected-store.js';
|
|
2
2
|
import { type Rung, type SubstrateDoc } from './schema.js';
|
|
3
3
|
import type { NodeConfigSubject } from './subject-fields.js';
|
|
4
4
|
interface EventCandidate {
|
|
@@ -30,4 +30,20 @@ export declare function memoryReadDocBlocks(subject: NodeConfigSubject | null, e
|
|
|
30
30
|
* The corpus is the resolved cwd/profile set; a command has no file from which
|
|
31
31
|
* to discover enclosing project stores. */
|
|
32
32
|
export declare function renderOnCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
|
|
33
|
+
/** Surface docs whose `pre-command` entries match a bash command that has NOT
|
|
34
|
+
* run yet. The exact twin of the post-execution renderer above, and its empty
|
|
35
|
+
* string is load-bearing: `''` means every matching doc is already exposed at
|
|
36
|
+
* its matching rung or above, which is the release — the caller lets the
|
|
37
|
+
* command through. */
|
|
38
|
+
export declare function renderPreCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
|
|
39
|
+
/** Does ANY doc in this session's corpus carry a `pre-command` entry? The
|
|
40
|
+
* short-circuit a pre-execution caller checks first: with no such doc — the
|
|
41
|
+
* common case — no command need ever pay for a subject lookup. */
|
|
42
|
+
export declare function corpusHasPreCommandSurfaces(): boolean;
|
|
43
|
+
/** Forget the transcript exposure of every `pre-command` doc, re-arming their
|
|
44
|
+
* holds. Compaction drops delivered guidance out of the transcript while the
|
|
45
|
+
* ledger still claims it was delivered, so without this pass a doc could be
|
|
46
|
+
* held once and then silently absent for the rest of the session. Lives here
|
|
47
|
+
* rather than in the extension because the corpus accessor is private. */
|
|
48
|
+
export declare function demotePreCommandExposures(state: ContextExposureState): void;
|
|
33
49
|
export {};
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
// • `command` — a `bash` tool call evaluates the executed command string
|
|
23
23
|
// against every doc's `command` entries (per shell segment, wildcards at
|
|
24
24
|
// token granularity — see surface-match.ts).
|
|
25
|
+
// • `pre-command` — a `bash` tool call ABOUT to run evaluates the same way
|
|
26
|
+
// against every doc's `pre-command` entries, and the call is held until
|
|
27
|
+
// the matching docs have been delivered. Because delivery registers
|
|
28
|
+
// exposure, the agent's re-issue of the same command finds nothing left to
|
|
29
|
+
// deliver and runs.
|
|
25
30
|
//
|
|
26
31
|
// All channels register through one context exposure target, so a document
|
|
27
32
|
// already loaded at the same or a higher rung does not repeat. Each candidate
|
|
@@ -37,8 +42,8 @@ import { parseFrontmatterGeneric } from '../frontmatter.js';
|
|
|
37
42
|
import { listAllMemoryDocs, listProjectMemoryDocs, loadStoreMemoryDocs, openProjectMemoryStore } from '../memory-resolver.js';
|
|
38
43
|
import { userScopeRoot } from '../scope.js';
|
|
39
44
|
import { gatePasses } from './gate.js';
|
|
40
|
-
import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
|
|
41
|
-
import { documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
|
|
45
|
+
import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, preCommandDeliveryRung, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
|
|
46
|
+
import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
|
|
42
47
|
import { minRung, parseSubstrateDoc, previewLine } from './schema.js';
|
|
43
48
|
import { cachedEventCorpusInclusive } from './session-cache.js';
|
|
44
49
|
const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
|
|
@@ -243,3 +248,32 @@ export function renderOnCommandDocsForSubject(subject, command, target = transie
|
|
|
243
248
|
const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: commandDeliveryRung(doc, subject, command) }));
|
|
244
249
|
return renderCandidates(subject, candidates, target);
|
|
245
250
|
}
|
|
251
|
+
/** Surface docs whose `pre-command` entries match a bash command that has NOT
|
|
252
|
+
* run yet. The exact twin of the post-execution renderer above, and its empty
|
|
253
|
+
* string is load-bearing: `''` means every matching doc is already exposed at
|
|
254
|
+
* its matching rung or above, which is the release — the caller lets the
|
|
255
|
+
* command through. */
|
|
256
|
+
export function renderPreCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
|
|
257
|
+
const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: preCommandDeliveryRung(doc, subject, command) }));
|
|
258
|
+
return renderCandidates(subject, candidates, target);
|
|
259
|
+
}
|
|
260
|
+
function carriesPreCommandEntry(doc) {
|
|
261
|
+
return doc.surfaces.some((entry) => entry.on === 'pre-command');
|
|
262
|
+
}
|
|
263
|
+
/** Does ANY doc in this session's corpus carry a `pre-command` entry? The
|
|
264
|
+
* short-circuit a pre-execution caller checks first: with no such doc — the
|
|
265
|
+
* common case — no command need ever pay for a subject lookup. */
|
|
266
|
+
export function corpusHasPreCommandSurfaces() {
|
|
267
|
+
return resolvedDocs().some(({ doc }) => carriesPreCommandEntry(doc));
|
|
268
|
+
}
|
|
269
|
+
/** Forget the transcript exposure of every `pre-command` doc, re-arming their
|
|
270
|
+
* holds. Compaction drops delivered guidance out of the transcript while the
|
|
271
|
+
* ledger still claims it was delivered, so without this pass a doc could be
|
|
272
|
+
* held once and then silently absent for the rest of the session. Lives here
|
|
273
|
+
* rather than in the extension because the corpus accessor is private. */
|
|
274
|
+
export function demotePreCommandExposures(state) {
|
|
275
|
+
for (const { doc } of resolvedDocs()) {
|
|
276
|
+
if (carriesPreCommandEntry(doc))
|
|
277
|
+
demoteDocumentTranscriptExposure(state, doc.path, doc.body);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -15,7 +15,7 @@ export declare function rungAtLeast(r: Rung, min: Rung): boolean;
|
|
|
15
15
|
* authored rung against its project relationship's cap. */
|
|
16
16
|
export declare function minRung(a: Rung, b: Rung): Rung;
|
|
17
17
|
export { normalizeNameSegment, normalizeDocName, resolveLocalName as resolveDocName } from '../memory/identity.js';
|
|
18
|
-
export declare const SURFACE_EVENTS: readonly ["boot", "workspace-open", "read", "memory-read", "command"];
|
|
18
|
+
export declare const SURFACE_EVENTS: readonly ["boot", "workspace-open", "read", "memory-read", "command", "pre-command"];
|
|
19
19
|
export type SurfaceEvent = (typeof SURFACE_EVENTS)[number];
|
|
20
20
|
/** The rungs an entry may deliver at. There is no `none` — silence is the
|
|
21
21
|
* absence of an entry. (`Rung`'s `none` survives only as the internal
|
|
@@ -30,8 +30,8 @@ export type SurfaceRung = (typeof SURFACE_RUNGS)[number];
|
|
|
30
30
|
export interface SurfaceEntry {
|
|
31
31
|
on: SurfaceEvent;
|
|
32
32
|
/** Globs vs the event's subject namespace. Required on read/memory-read/
|
|
33
|
-
* command (a read entry may carry `matchFrontmatter` instead);
|
|
34
|
-
* on boot/workspace-open (presence of the entry is the match). */
|
|
33
|
+
* command/pre-command (a read entry may carry `matchFrontmatter` instead);
|
|
34
|
+
* meaningless on boot/workspace-open (presence of the entry is the match). */
|
|
35
35
|
match?: string[];
|
|
36
36
|
/** Predicate over the read file's own YAML frontmatter — `read` event only.
|
|
37
37
|
* Frontmatter key `match-frontmatter`. */
|
|
@@ -56,7 +56,7 @@ export { normalizeNameSegment, normalizeDocName, resolveLocalName as resolveDocN
|
|
|
56
56
|
// no `surfaces` at all does exactly one thing: appears in its directory's
|
|
57
57
|
// listing.
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
|
-
export const SURFACE_EVENTS = ['boot', 'workspace-open', 'read', 'memory-read', 'command'];
|
|
59
|
+
export const SURFACE_EVENTS = ['boot', 'workspace-open', 'read', 'memory-read', 'command', 'pre-command'];
|
|
60
60
|
/** The rungs an entry may deliver at. There is no `none` — silence is the
|
|
61
61
|
* absence of an entry. (`Rung`'s `none` survives only as the internal
|
|
62
62
|
* ranking floor, e.g. `bootRung` of a doc with no boot entry.) */
|
|
@@ -158,8 +158,8 @@ function parseGate(v) {
|
|
|
158
158
|
: undefined;
|
|
159
159
|
}
|
|
160
160
|
/** Tolerant `surfaces` parse: a non-array yields `[]`; invalid entries are
|
|
161
|
-
* dropped (bad `on`, bad `at`, a read/memory-read/command entry
|
|
162
|
-
* neither `match` nor — read only — `match-frontmatter`). `match-frontmatter`
|
|
161
|
+
* dropped (bad `on`, bad `at`, a read/memory-read/command/pre-command entry
|
|
162
|
+
* left with neither `match` nor — read only — `match-frontmatter`). `match-frontmatter`
|
|
163
163
|
* on a non-read event is dropped from the entry, which survives iff it still
|
|
164
164
|
* carries a `match`; an invalid entry `gate` is dropped. Lint owns strict
|
|
165
165
|
* enforcement; the runtime parser maps over many docs and must never throw. */
|
|
@@ -180,7 +180,7 @@ function parseSurfaces(v) {
|
|
|
180
180
|
const match = parseMatchGlobs(rec['match']);
|
|
181
181
|
const matchFrontmatter = on === 'read' ? parseGate(rec['match-frontmatter']) : undefined;
|
|
182
182
|
const gate = parseGate(rec['gate']);
|
|
183
|
-
if ((on === 'read' || on === 'memory-read' || on === 'command') &&
|
|
183
|
+
if ((on === 'read' || on === 'memory-read' || on === 'command' || on === 'pre-command') &&
|
|
184
184
|
match === undefined &&
|
|
185
185
|
matchFrontmatter === undefined) {
|
|
186
186
|
continue;
|
|
@@ -20,6 +20,21 @@ export declare function matchesReadEntry(entry: SurfaceEntry, doc: Pick<Substrat
|
|
|
20
20
|
export declare function matchesMemoryReadEntry(entry: SurfaceEntry, routingAnchor: string, subject: NodeConfigSubject | null, name: string): boolean;
|
|
21
21
|
/** Does a `command` entry fit the executed command string? */
|
|
22
22
|
export declare function matchesCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject, command: string): boolean;
|
|
23
|
+
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
24
|
+
* `DELIM` line) from a command before it is matched. The opening line is kept
|
|
25
|
+
* — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
26
|
+
* body lines, which are data fed on stdin and never executed as commands, are
|
|
27
|
+
* dropped. Without this a doc-writing command whose body merely MENTIONS a
|
|
28
|
+
* matched invocation would have its whole call held. Post-execution `command`
|
|
29
|
+
* delivery does not need this (a spurious late injection costs a paragraph);
|
|
30
|
+
* a `pre-command` block costs the agent a turn, so it does. */
|
|
31
|
+
export declare function stripHeredocs(command: string): string;
|
|
32
|
+
/** Does a `pre-command` entry fit a command that is ABOUT to run? The same
|
|
33
|
+
* glob machinery `command` uses — one matcher, one semantics — applied to the
|
|
34
|
+
* command with its heredoc bodies stripped. Subshell parentheses, backticks,
|
|
35
|
+
* and path-prefixed binaries stay unhandled: this is a guardrail against the
|
|
36
|
+
* faithful-but-uninformed action, not an enforcement boundary. */
|
|
37
|
+
export declare function matchesPreCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject, command: string): boolean;
|
|
23
38
|
/** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
|
|
24
39
|
export declare function readDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces' | 'path' | 'scope'>, subject: NodeConfigSubject, absReadFile: string, readFrontmatter: Record<string, unknown>): Rung;
|
|
25
40
|
/** The doc's workspace-open rung. Presence of an entry is the match; callers
|
|
@@ -33,3 +48,7 @@ export declare function workspaceOpenRung(doc: Pick<SubstrateDoc, 'surfaces'>, s
|
|
|
33
48
|
export declare function memoryReadDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, routingAnchor: string, subject: NodeConfigSubject | null, name: string): Rung;
|
|
34
49
|
/** The doc's delivery rung for an executed command string. */
|
|
35
50
|
export declare function commandDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, subject: NodeConfigSubject, command: string): Rung;
|
|
51
|
+
/** The doc's delivery rung for a command about to run — the highest `at`
|
|
52
|
+
* over the doc's matching `pre-command` entries, folded the same way as every
|
|
53
|
+
* other event. */
|
|
54
|
+
export declare function preCommandDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, subject: NodeConfigSubject, command: string): Rung;
|
|
@@ -276,6 +276,53 @@ export function matchesCommandEntry(entry, subject, command) {
|
|
|
276
276
|
return false;
|
|
277
277
|
return entry.match.some((g) => commandGlobMatches(g, command));
|
|
278
278
|
}
|
|
279
|
+
const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
|
|
280
|
+
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
281
|
+
* `DELIM` line) from a command before it is matched. The opening line is kept
|
|
282
|
+
* — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
283
|
+
* body lines, which are data fed on stdin and never executed as commands, are
|
|
284
|
+
* dropped. Without this a doc-writing command whose body merely MENTIONS a
|
|
285
|
+
* matched invocation would have its whole call held. Post-execution `command`
|
|
286
|
+
* delivery does not need this (a spurious late injection costs a paragraph);
|
|
287
|
+
* a `pre-command` block costs the agent a turn, so it does. */
|
|
288
|
+
export function stripHeredocs(command) {
|
|
289
|
+
const lines = command.split('\n');
|
|
290
|
+
const out = [];
|
|
291
|
+
let i = 0;
|
|
292
|
+
while (i < lines.length) {
|
|
293
|
+
const line = lines[i];
|
|
294
|
+
out.push(line);
|
|
295
|
+
i += 1;
|
|
296
|
+
// Every heredoc opened on this line, left to right; their bodies stack and
|
|
297
|
+
// are consumed in that order.
|
|
298
|
+
const delims = [];
|
|
299
|
+
HEREDOC_OPEN.lastIndex = 0;
|
|
300
|
+
let m;
|
|
301
|
+
while ((m = HEREDOC_OPEN.exec(line)) !== null)
|
|
302
|
+
delims.push({ name: m[3], dash: m[1] === '-' });
|
|
303
|
+
for (const d of delims) {
|
|
304
|
+
while (i < lines.length) {
|
|
305
|
+
// `<<-` strips leading TABS from the body and the closing delimiter.
|
|
306
|
+
const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
|
|
307
|
+
i += 1;
|
|
308
|
+
if (probe === d.name)
|
|
309
|
+
break; // closing delimiter line — drop it, stop
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out.join('\n');
|
|
314
|
+
}
|
|
315
|
+
/** Does a `pre-command` entry fit a command that is ABOUT to run? The same
|
|
316
|
+
* glob machinery `command` uses — one matcher, one semantics — applied to the
|
|
317
|
+
* command with its heredoc bodies stripped. Subshell parentheses, backticks,
|
|
318
|
+
* and path-prefixed binaries stay unhandled: this is a guardrail against the
|
|
319
|
+
* faithful-but-uninformed action, not an enforcement boundary. */
|
|
320
|
+
export function matchesPreCommandEntry(entry, subject, command) {
|
|
321
|
+
if (entry.on !== 'pre-command' || entry.match === undefined || !surfaceEntryGatePasses(entry, subject))
|
|
322
|
+
return false;
|
|
323
|
+
const stripped = stripHeredocs(command);
|
|
324
|
+
return entry.match.some((g) => commandGlobMatches(g, stripped));
|
|
325
|
+
}
|
|
279
326
|
// ---------------------------------------------------------------------------
|
|
280
327
|
// Per-doc rung folds — a doc's delivery rung for one event occurrence is the
|
|
281
328
|
// highest `at` over its matching entries of that event, `none` when none
|
|
@@ -331,3 +378,16 @@ export function commandDeliveryRung(doc, subject, command) {
|
|
|
331
378
|
}
|
|
332
379
|
return r;
|
|
333
380
|
}
|
|
381
|
+
/** The doc's delivery rung for a command about to run — the highest `at`
|
|
382
|
+
* over the doc's matching `pre-command` entries, folded the same way as every
|
|
383
|
+
* other event. */
|
|
384
|
+
export function preCommandDeliveryRung(doc, subject, command) {
|
|
385
|
+
let r = 'none';
|
|
386
|
+
for (const e of doc.surfaces) {
|
|
387
|
+
if (!matchesPreCommandEntry(e, subject, command))
|
|
388
|
+
continue;
|
|
389
|
+
if (rungRank(e.at) > rungRank(r))
|
|
390
|
+
r = e.at;
|
|
391
|
+
}
|
|
392
|
+
return r;
|
|
393
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Regression: the `pre-command` gate holds a bash command BEFORE it runs and
|
|
2
|
+
// releases it once its memory is in the transcript. The release is not a stored
|
|
3
|
+
// decision — it is the exposure ledger, and that is exactly what makes it
|
|
4
|
+
// fragile:
|
|
5
|
+
//
|
|
6
|
+
// • Compaction drops the delivered guidance out of the transcript while the
|
|
7
|
+
// ledger still claims it was delivered. Without the demotion pass the doc is
|
|
8
|
+
// held once and then silently absent for the rest of the session. Worse, a
|
|
9
|
+
// demotion that left a rankless entry behind would crash the node's whole
|
|
10
|
+
// substrate on its next load — so the ledger is reloaded FROM DISK here.
|
|
11
|
+
// • pi preflights the siblings of one assistant message sequentially and then
|
|
12
|
+
// executes them concurrently, so without a per-turn flag the second sibling
|
|
13
|
+
// would find the doc already exposed and slip past the hold its sibling just
|
|
14
|
+
// raised.
|
|
15
|
+
// • A session whose corpus carries no `pre-command` doc at all is the common
|
|
16
|
+
// case and must never pay a daemon round-trip. Proven by closing the daemon:
|
|
17
|
+
// if the short-circuit stopped working the call would fail loud.
|
|
18
|
+
//
|
|
19
|
+
// The gate runs against the real canvas api server, so the subject the matcher
|
|
20
|
+
// gates on is the one crtrd actually serves.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --conditions=crtr-src --import tsx/esm --test src/pi-extensions/__tests__/pre-command-gate.test.ts
|
|
23
|
+
import { test, before, beforeEach, afterEach, after } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { registerCanvasDocSubstrate } from '../canvas-doc-substrate.js';
|
|
29
|
+
import { createNode } from '../../core/canvas/canvas.js';
|
|
30
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
31
|
+
import { apiSocketPath } from '../../core/canvas/paths.js';
|
|
32
|
+
import { resetScopeCache } from '../../core/scope.js';
|
|
33
|
+
import { loadContextExposureState } from '../../core/substrate/injected-store.js';
|
|
34
|
+
import { clearSessionCache } from '../../core/substrate/session-cache.js';
|
|
35
|
+
import { createApiServer } from '../../daemon/api/server.js';
|
|
36
|
+
const DOCTRINE_BODY = 'PRE-COMMAND-DOCTRINE-BODY';
|
|
37
|
+
let home;
|
|
38
|
+
let cwd;
|
|
39
|
+
let server = null;
|
|
40
|
+
let origCwd;
|
|
41
|
+
let origHome;
|
|
42
|
+
let origNode;
|
|
43
|
+
let origProfile;
|
|
44
|
+
async function waitForSocket(path) {
|
|
45
|
+
for (let i = 0; i < 200; i++) {
|
|
46
|
+
if (existsSync(path))
|
|
47
|
+
return;
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`api socket never bound at ${path}`);
|
|
51
|
+
}
|
|
52
|
+
async function closeServer() {
|
|
53
|
+
const running = server;
|
|
54
|
+
server = null;
|
|
55
|
+
if (running !== null)
|
|
56
|
+
await running.close();
|
|
57
|
+
}
|
|
58
|
+
/** A user-scope doc that holds every `deploy …` invocation until it is read. */
|
|
59
|
+
function writeDoctrineDoc(event) {
|
|
60
|
+
const store = join(home, 'user', '.crouter', 'memory');
|
|
61
|
+
mkdirSync(store, { recursive: true });
|
|
62
|
+
const path = join(store, 'deploy-doctrine.md');
|
|
63
|
+
writeFileSync(path, '---\nkind: knowledge\n' +
|
|
64
|
+
'when-and-why-to-read: When deploying, this knowledge should be read because it carries the approval boundary.\n' +
|
|
65
|
+
`surfaces:\n - {on: ${event}, at: content, match: "deploy*"}\n---\n` +
|
|
66
|
+
`${DOCTRINE_BODY}\n`);
|
|
67
|
+
clearSessionCache();
|
|
68
|
+
return realpathSync(path);
|
|
69
|
+
}
|
|
70
|
+
/** A canvas node the api server can assemble a subject for, selected as the
|
|
71
|
+
* session's node. Each test uses its own id: the extension's exposure state is
|
|
72
|
+
* a per-node process singleton. */
|
|
73
|
+
function seedNode(id) {
|
|
74
|
+
createNode({
|
|
75
|
+
node_id: id,
|
|
76
|
+
name: id,
|
|
77
|
+
created: new Date().toISOString(),
|
|
78
|
+
cwd,
|
|
79
|
+
kind: 'general',
|
|
80
|
+
mode: 'base',
|
|
81
|
+
lifecycle: 'terminal',
|
|
82
|
+
status: 'active',
|
|
83
|
+
});
|
|
84
|
+
mkdirSync(join(home, 'nodes', id), { recursive: true });
|
|
85
|
+
process.env['CRTR_NODE_ID'] = id;
|
|
86
|
+
}
|
|
87
|
+
/** The registered extension, driven through the pi events it actually binds. */
|
|
88
|
+
function registerSubstrate() {
|
|
89
|
+
const handlers = new Map();
|
|
90
|
+
registerCanvasDocSubstrate({
|
|
91
|
+
on(event, handler) {
|
|
92
|
+
const bound = handlers.get(event) ?? [];
|
|
93
|
+
bound.push(handler);
|
|
94
|
+
handlers.set(event, bound);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
async function fire(event, payload) {
|
|
98
|
+
const bound = handlers.get(event) ?? [];
|
|
99
|
+
assert.equal(bound.length, 1, `the substrate binds exactly one ${event} handler`);
|
|
100
|
+
return await bound[0](payload, {});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
toolCall: (toolName, input) => fire('tool_call', { toolName, input }),
|
|
104
|
+
bash: (command) => fire('tool_call', { toolName: 'bash', input: { command } }),
|
|
105
|
+
endTurn: async () => void (await fire('turn_end', {})),
|
|
106
|
+
compact: async () => void (await fire('session_compact', {})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function assertHeld(result, what) {
|
|
110
|
+
assert.ok(result !== undefined, `${what} is held`);
|
|
111
|
+
assert.equal(result.block, true);
|
|
112
|
+
assert.ok(result.reason.startsWith('HELD — nothing ran.'), `the held result opens with the notice; got: ${result.reason}`);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
before(() => {
|
|
116
|
+
origCwd = process.cwd();
|
|
117
|
+
origHome = process.env['HOME'];
|
|
118
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
119
|
+
origProfile = process.env['CRTR_PROFILE_ID'];
|
|
120
|
+
});
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
closeDb();
|
|
123
|
+
if (home)
|
|
124
|
+
rmSync(home, { recursive: true, force: true });
|
|
125
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-pre-command-'));
|
|
126
|
+
cwd = join(home, 'user', 'work');
|
|
127
|
+
mkdirSync(cwd, { recursive: true });
|
|
128
|
+
process.chdir(cwd);
|
|
129
|
+
process.env['HOME'] = join(home, 'user');
|
|
130
|
+
process.env['CRTR_HOME'] = home;
|
|
131
|
+
delete process.env['CRTR_NODE_ID'];
|
|
132
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
133
|
+
resetScopeCache();
|
|
134
|
+
clearSessionCache();
|
|
135
|
+
server = createApiServer();
|
|
136
|
+
await waitForSocket(apiSocketPath());
|
|
137
|
+
});
|
|
138
|
+
afterEach(async () => {
|
|
139
|
+
try {
|
|
140
|
+
await closeServer();
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
process.chdir(origCwd);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
after(() => {
|
|
147
|
+
process.chdir(origCwd);
|
|
148
|
+
closeDb();
|
|
149
|
+
if (home)
|
|
150
|
+
rmSync(home, { recursive: true, force: true });
|
|
151
|
+
resetScopeCache();
|
|
152
|
+
clearSessionCache();
|
|
153
|
+
delete process.env['CRTR_HOME'];
|
|
154
|
+
if (origHome === undefined)
|
|
155
|
+
delete process.env['HOME'];
|
|
156
|
+
else
|
|
157
|
+
process.env['HOME'] = origHome;
|
|
158
|
+
if (origNode === undefined)
|
|
159
|
+
delete process.env['CRTR_NODE_ID'];
|
|
160
|
+
else
|
|
161
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
162
|
+
if (origProfile === undefined)
|
|
163
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
164
|
+
else
|
|
165
|
+
process.env['CRTR_PROFILE_ID'] = origProfile;
|
|
166
|
+
});
|
|
167
|
+
test('a matching command is held with its doc, every sibling in the message with it, and the re-issue runs', async () => {
|
|
168
|
+
const docPath = writeDoctrineDoc('pre-command');
|
|
169
|
+
seedNode('gate-hold');
|
|
170
|
+
const pi = registerSubstrate();
|
|
171
|
+
const held = assertHeld(await pi.bash('deploy prod'), 'the first matching command');
|
|
172
|
+
assert.ok(held.reason.includes(DOCTRINE_BODY), `the doc rides back as the blocked call's result; got: ${held.reason}`);
|
|
173
|
+
assert.match(held.reason, /re-issue your WHOLE command/, 'the copy steers a whole-command re-issue, never one clause');
|
|
174
|
+
// Exposure is registered AT BLOCK TIME — physical identity and content alias
|
|
175
|
+
// both — and that single fact is what releases the re-issue below.
|
|
176
|
+
const afterBlock = loadContextExposureState('gate-hold');
|
|
177
|
+
assert.deepEqual(afterBlock.exposures.get(docPath), { transcript: 3 }, 'the held doc is recorded as delivered');
|
|
178
|
+
assert.equal(afterBlock.exposures.size, 2, 'the content alias is recorded alongside the path');
|
|
179
|
+
// Siblings of the same assistant message. The doc is already exposed, so
|
|
180
|
+
// without the per-turn flag the render would come back empty and let them
|
|
181
|
+
// through. The flag covers the whole message, matching or not: every command
|
|
182
|
+
// in it was authored under the worldview this doc just corrected.
|
|
183
|
+
const sibling = assertHeld(await pi.bash('deploy staging'), 'a matching sibling');
|
|
184
|
+
assert.match(sibling.reason, /Another command in this same message/);
|
|
185
|
+
assertHeld(await pi.bash('ls -la'), 'a non-matching sibling of a held command');
|
|
186
|
+
await pi.endTurn();
|
|
187
|
+
// The release: the doc is in the transcript, so there is nothing left to
|
|
188
|
+
// deliver and nothing left to hold.
|
|
189
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'the identical command re-issued in a later turn runs');
|
|
190
|
+
assert.equal(await pi.bash('ls -la'), undefined, 'a command matching nothing is untouched');
|
|
191
|
+
});
|
|
192
|
+
test('compaction re-arms the hold and leaves an exposure ledger that reloads', async () => {
|
|
193
|
+
const docPath = writeDoctrineDoc('pre-command');
|
|
194
|
+
seedNode('gate-compact');
|
|
195
|
+
const pi = registerSubstrate();
|
|
196
|
+
assertHeld(await pi.bash('deploy prod'), 'the first matching command');
|
|
197
|
+
await pi.endTurn();
|
|
198
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'the doc is in the transcript, so the command runs');
|
|
199
|
+
await pi.endTurn();
|
|
200
|
+
await pi.compact();
|
|
201
|
+
// Reloaded from disk: the demotion must DELETE the rankless entry rather than
|
|
202
|
+
// leave `{}` behind, which the v3 loader rejects outright — this call is the
|
|
203
|
+
// proof it did.
|
|
204
|
+
const afterCompact = loadContextExposureState('gate-compact');
|
|
205
|
+
assert.equal(afterCompact.exposures.has(docPath), false, 'compaction forgets the delivery that compaction dropped');
|
|
206
|
+
assert.equal(afterCompact.exposures.size, 0, 'the content alias is forgotten too, and no rankless entry survives');
|
|
207
|
+
const reheld = assertHeld(await pi.bash('deploy prod'), 'the same command after compaction');
|
|
208
|
+
assert.ok(reheld.reason.includes(DOCTRINE_BODY), 'guidance the transcript no longer carries is delivered again');
|
|
209
|
+
});
|
|
210
|
+
test('a corpus with no pre-command doc never reaches the daemon', async () => {
|
|
211
|
+
writeDoctrineDoc('command');
|
|
212
|
+
seedNode('gate-quiet');
|
|
213
|
+
const pi = registerSubstrate();
|
|
214
|
+
// No daemon. Every path below must return before the subject lookup; one that
|
|
215
|
+
// does not fails loud with `daemon_unavailable` instead of passing quietly.
|
|
216
|
+
await closeServer();
|
|
217
|
+
assert.equal(await pi.toolCall('read', { file: '/tmp/x' }), undefined, 'a non-bash call is not the gate’s business');
|
|
218
|
+
assert.equal(await pi.toolCall('bash', { command: ' ' }), undefined, 'a bash call carrying no command is not held');
|
|
219
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'a post-execution `command` doc never holds anything');
|
|
220
|
+
});
|
|
@@ -29,9 +29,23 @@ interface ToolResultCtxLike {
|
|
|
29
29
|
type ToolResultResultLike = {
|
|
30
30
|
content: ContentBlockLike[];
|
|
31
31
|
} | void;
|
|
32
|
+
/** tool_call: fired BEFORE the tool runs. Returning `{block: true, reason}`
|
|
33
|
+
* stops it — nothing executes, no `tool_result` fires, and `reason` becomes the
|
|
34
|
+
* call's synthesized error result, carrying arbitrary-length text. */
|
|
35
|
+
interface ToolCallEventLike {
|
|
36
|
+
toolName: string;
|
|
37
|
+
input?: unknown;
|
|
38
|
+
}
|
|
39
|
+
type ToolCallResultLike = {
|
|
40
|
+
block: true;
|
|
41
|
+
reason: string;
|
|
42
|
+
} | void;
|
|
32
43
|
interface PiLike {
|
|
33
44
|
on(event: 'before_agent_start', handler: (event: BeforeAgentStartEventLike) => BeforeAgentStartResultLike | void | Promise<BeforeAgentStartResultLike | void>): void;
|
|
34
45
|
on(event: 'session_start', handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
|
|
46
|
+
on(event: 'session_compact', handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
|
|
47
|
+
on(event: 'turn_end', handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
|
|
48
|
+
on(event: 'tool_call', handler: (event: ToolCallEventLike, ctx: unknown) => ToolCallResultLike | Promise<ToolCallResultLike>): void;
|
|
35
49
|
on(event: 'tool_result', handler: (event: ToolResultEventLike, ctx: ToolResultCtxLike) => ToolResultResultLike | Promise<ToolResultResultLike>): void;
|
|
36
50
|
}
|
|
37
51
|
export declare function registerCanvasDocSubstrate(pi: PiLike): void;
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// before_agent_start splices the context's frozen preference snapshot after the
|
|
9
9
|
// native tool list. Successful read/bash results append matching memory through
|
|
10
|
-
// the shared context exposure state.
|
|
10
|
+
// the shared context exposure state. A bash call matching a `pre-command` doc is
|
|
11
|
+
// BLOCKED before it runs and the doc comes back as its synthesized error result.
|
|
12
|
+
// Pure rendering lives in core/substrate/.
|
|
11
13
|
//
|
|
12
14
|
// Plain TS-with-types — NO imports from @earendil-works/* (a local structural
|
|
13
15
|
// PiLike interface stands in), so it compiles inside crouter's own tsc build
|
|
@@ -16,7 +18,7 @@ import { homedir } from 'node:os';
|
|
|
16
18
|
import { join, resolve } from 'node:path';
|
|
17
19
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
18
20
|
import { renderPreferencesFromState } from '../core/runtime/broker-extension-render.js';
|
|
19
|
-
import { renderOnCommandDocsForSubject, renderOnReadDocsForSubject } from '../core/substrate/on-read.js';
|
|
21
|
+
import { corpusHasPreCommandSurfaces, demotePreCommandExposures, renderOnCommandDocsForSubject, renderOnReadDocsForSubject, renderPreCommandDocsForSubject, } from '../core/substrate/on-read.js';
|
|
20
22
|
import { clearSessionCache } from '../core/substrate/session-cache.js';
|
|
21
23
|
import { cloneContextExposureState, exposureTarget, freezePreferenceSnapshot, loadContextExposureState, mergeContextExposureState, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from '../core/substrate/injected-store.js';
|
|
22
24
|
import { autoLoadedContextInner, mergeAutoLoadedContext } from './envelope-merge.js';
|
|
@@ -28,6 +30,38 @@ import { autoLoadedContextInner, mergeAutoLoadedContext } from './envelope-merge
|
|
|
28
30
|
* to reach for while reading the tools, so memory docs/preferences must sit
|
|
29
31
|
* there, not far below. Falls back to appending if absent. */
|
|
30
32
|
const TOOLS_ANCHOR = '\n\nGuidelines:';
|
|
33
|
+
/** The bash command string a tool call carries, or `null` when there is none. */
|
|
34
|
+
function bashCommandOf(input) {
|
|
35
|
+
const command = input?.command;
|
|
36
|
+
return typeof command === 'string' && command.trim() !== '' ? command : null;
|
|
37
|
+
}
|
|
38
|
+
/** The blocked call's synthesized error result. A blocked command is discarded
|
|
39
|
+
* IN FULL — pi executes none of it — so the copy must steer a re-issue of the
|
|
40
|
+
* WHOLE command, never of one clause: when the same command also built its own
|
|
41
|
+
* input (a heredoc, a `>` redirect, a `$(…)`), that setup did not run either,
|
|
42
|
+
* and re-issuing only the tail would act on stale or missing data. */
|
|
43
|
+
function heldReason(rendered) {
|
|
44
|
+
return [
|
|
45
|
+
'HELD — nothing ran.',
|
|
46
|
+
'',
|
|
47
|
+
'Your bash command was NOT executed. It matched memory you had not yet read, and that memory is below.',
|
|
48
|
+
'',
|
|
49
|
+
'Read it, then re-issue your WHOLE command unchanged — every clause of it, including any heredoc, redirect, or `$(…)` that built its input, because none of that ran either. The re-issue executes: this memory is now in your transcript, so there is nothing left to deliver and nothing left to hold.',
|
|
50
|
+
'',
|
|
51
|
+
rendered,
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
/** The follow-on notice for a SIBLING command in the same assistant message.
|
|
55
|
+
* pi preflights siblings sequentially and then executes them concurrently, so
|
|
56
|
+
* without one flag per turn the second sibling would find the doc already
|
|
57
|
+
* exposed and slip past the hold its sibling just raised. */
|
|
58
|
+
const HELD_SIBLING_REASON = [
|
|
59
|
+
'HELD — nothing ran.',
|
|
60
|
+
'',
|
|
61
|
+
'Your bash command was NOT executed. Another command in this same message was held against memory you had not read, and that memory was returned with it.',
|
|
62
|
+
'',
|
|
63
|
+
'Read it, then re-issue your WHOLE command unchanged. The re-issue executes.',
|
|
64
|
+
].join('\n');
|
|
31
65
|
export function registerCanvasDocSubstrate(pi) {
|
|
32
66
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
33
67
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
@@ -57,6 +91,45 @@ export function registerCanvasDocSubstrate(pi) {
|
|
|
57
91
|
systemPrompt: `${event.systemPrompt.slice(0, idx)}\n\n${block}${event.systemPrompt.slice(idx)}`,
|
|
58
92
|
};
|
|
59
93
|
});
|
|
94
|
+
// One flag per assistant turn: the first hold covers every matching command
|
|
95
|
+
// in the same message.
|
|
96
|
+
let blockedThisTurn = false;
|
|
97
|
+
pi.on('turn_end', () => {
|
|
98
|
+
blockedThisTurn = false;
|
|
99
|
+
});
|
|
100
|
+
// Compaction drops delivered guidance out of the transcript while the ledger
|
|
101
|
+
// still records it as delivered. Forget those deliveries so the holds re-arm.
|
|
102
|
+
pi.on('session_compact', () => {
|
|
103
|
+
mergeContextExposureState(contextExposure, loadContextExposureState(nodeId));
|
|
104
|
+
demotePreCommandExposures(contextExposure);
|
|
105
|
+
saveContextExposureState(nodeId, contextExposure);
|
|
106
|
+
blockedThisTurn = false;
|
|
107
|
+
});
|
|
108
|
+
pi.on('tool_call', async (event) => {
|
|
109
|
+
if (event.toolName !== 'bash')
|
|
110
|
+
return;
|
|
111
|
+
const command = bashCommandOf(event.input);
|
|
112
|
+
if (command === null)
|
|
113
|
+
return;
|
|
114
|
+
// The common case is a corpus with no pre-command doc at all, and it must
|
|
115
|
+
// cost nothing — short-circuit before the daemon round-trip below.
|
|
116
|
+
if (!corpusHasPreCommandSurfaces())
|
|
117
|
+
return;
|
|
118
|
+
if (blockedThisTurn)
|
|
119
|
+
return { block: true, reason: HELD_SIBLING_REASON };
|
|
120
|
+
const state = await brokerExtensionState(nodeId);
|
|
121
|
+
mergeContextExposureState(contextExposure, loadContextExposureState(nodeId));
|
|
122
|
+
const next = cloneContextExposureState(contextExposure);
|
|
123
|
+
const rendered = renderPreCommandDocsForSubject(state.subject, command, exposureTarget(next, 'transcript'));
|
|
124
|
+
// Everything this command matches is already in the transcript: the hold is
|
|
125
|
+
// released and the command runs.
|
|
126
|
+
if (rendered === '')
|
|
127
|
+
return;
|
|
128
|
+
blockedThisTurn = true;
|
|
129
|
+
saveContextExposureState(nodeId, next);
|
|
130
|
+
replaceContextExposureState(contextExposure, next);
|
|
131
|
+
return { block: true, reason: heldReason(rendered) };
|
|
132
|
+
});
|
|
60
133
|
pi.on('tool_result', async (event, ctx) => {
|
|
61
134
|
if (event.isError === true)
|
|
62
135
|
return;
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.233",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.233",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|