@north-light/crouter 0.3.262 → 0.3.269

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 (150) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/health.d.ts +2 -0
  5. package/dist/api/dto/nodes.d.ts +6 -2
  6. package/dist/api/routes.d.ts +1 -0
  7. package/dist/api/routes.js +1 -0
  8. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  9. package/dist/builtin-memory/internal/memory-loading.md +3 -3
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +10 -5
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +12 -5
  14. package/dist/clients/attach/input/controller.d.ts +4 -0
  15. package/dist/clients/attach/input/controller.js +8 -0
  16. package/dist/clients/attach/session/bindings.d.ts +3 -0
  17. package/dist/clients/attach/session/bindings.js +7 -0
  18. package/dist/clients/attach/session/keys.d.ts +2 -0
  19. package/dist/clients/attach/session/keys.js +3 -3
  20. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  21. package/dist/clients/attach/session/whip-achievements.js +145 -0
  22. package/dist/clients/attach/slash/dispatch.js +5 -3
  23. package/dist/clients/attach/viewer.js +668 -667
  24. package/dist/commands/canvas-history/grep.js +3 -22
  25. package/dist/commands/canvas-history/read.js +5 -3
  26. package/dist/commands/canvas-history/search.js +3 -22
  27. package/dist/commands/canvas-history/shared.d.ts +8 -0
  28. package/dist/commands/canvas-history/shared.js +64 -1
  29. package/dist/commands/canvas-history/stats.d.ts +1 -0
  30. package/dist/commands/canvas-history/stats.js +68 -0
  31. package/dist/commands/canvas-history.js +4 -3
  32. package/dist/commands/memory/read.js +15 -38
  33. package/dist/commands/memory/shared.d.ts +1 -1
  34. package/dist/commands/memory/shared.js +1 -1
  35. package/dist/commands/node/create.js +2 -2
  36. package/dist/commands/sys/config.js +19 -2
  37. package/dist/commands/sys/context/admin/docs-panel.d.ts +3 -3
  38. package/dist/commands/sys/context/admin/model.d.ts +7 -2
  39. package/dist/commands/sys/context/admin/model.js +7 -1
  40. package/dist/commands/sys/context/admin/read-view.d.ts +5 -5
  41. package/dist/commands/sys/context/admin/read-view.js +9 -4
  42. package/dist/commands/sys/context/resolve.d.ts +32 -3
  43. package/dist/commands/sys/context/resolve.js +74 -25
  44. package/dist/commands/sys/panels/models-panel.d.ts +28 -5
  45. package/dist/commands/sys/panels/models-panel.js +560 -75
  46. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +1 -1
  47. package/dist/core/__tests__/integration/revive.test.js +4 -2
  48. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  49. package/dist/core/__tests__/model-routes-config.test.d.ts +1 -0
  50. package/dist/core/__tests__/model-routes-config.test.js +142 -0
  51. package/dist/core/__tests__/on-read-nested-store.test.js +8 -0
  52. package/dist/core/__tests__/profile-project-memory-delivery.test.js +264 -1
  53. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  54. package/dist/core/__tests__/seam/broker-provider-retry.test.js +21 -6
  55. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  56. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  57. package/dist/core/broker-client/transport-relay.js +5 -1
  58. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  59. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  60. package/dist/core/canvas/browse/app.d.ts +1 -1
  61. package/dist/core/canvas/browse/app.js +13 -10
  62. package/dist/core/canvas/browse/model.js +16 -1
  63. package/dist/core/canvas/browse/render.d.ts +23 -3
  64. package/dist/core/canvas/browse/render.js +429 -64
  65. package/dist/core/canvas/canvas.js +12 -8
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +7 -1
  68. package/dist/core/canvas/history.d.ts +36 -1
  69. package/dist/core/canvas/history.js +328 -7
  70. package/dist/core/canvas/labels.d.ts +2 -2
  71. package/dist/core/canvas/migrations.js +32 -0
  72. package/dist/core/canvas/node-recap.d.ts +23 -0
  73. package/dist/core/canvas/node-recap.js +67 -0
  74. package/dist/core/canvas/render-source.d.ts +34 -2
  75. package/dist/core/canvas/render-source.js +83 -24
  76. package/dist/core/canvas/types.d.ts +9 -0
  77. package/dist/core/config.d.ts +19 -2
  78. package/dist/core/config.js +174 -51
  79. package/dist/core/keybindings/catalog.d.ts +2 -2
  80. package/dist/core/keybindings/catalog.js +11 -8
  81. package/dist/core/model-routes.js +11 -4
  82. package/dist/core/runtime/broker/auth-reload.js +4 -1
  83. package/dist/core/runtime/broker/fault-retry.js +7 -2
  84. package/dist/core/runtime/broker/frame-client.js +4 -0
  85. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  86. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  87. package/dist/core/runtime/broker/inbox.js +68 -38
  88. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  89. package/dist/core/runtime/broker.js +14 -0
  90. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  91. package/dist/core/runtime/canvas-extensions.js +2 -0
  92. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  93. package/dist/core/runtime/interactive-deliver.js +12 -3
  94. package/dist/core/runtime/launch-target.js +1 -1
  95. package/dist/core/runtime/launch.d.ts +12 -6
  96. package/dist/core/runtime/launch.js +19 -17
  97. package/dist/core/runtime/model-swap.d.ts +5 -4
  98. package/dist/core/runtime/model-swap.js +7 -5
  99. package/dist/core/runtime/promote.js +2 -1
  100. package/dist/core/runtime/reset.js +4 -2
  101. package/dist/core/runtime/revive.js +22 -0
  102. package/dist/core/runtime/spawn.d.ts +3 -3
  103. package/dist/core/runtime/spawn.js +20 -3
  104. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  105. package/dist/core/runtime/stamp/channel.js +74 -0
  106. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  107. package/dist/core/runtime/stamp/protocol.js +113 -0
  108. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  109. package/dist/core/runtime/tmux-driver.js +27 -11
  110. package/dist/core/self-update.js +2 -5
  111. package/dist/core/substrate/listings.d.ts +1 -1
  112. package/dist/core/substrate/listings.js +6 -2
  113. package/dist/core/substrate/on-read.d.ts +61 -5
  114. package/dist/core/substrate/on-read.js +129 -22
  115. package/dist/core/substrate/render.d.ts +3 -2
  116. package/dist/core/substrate/render.js +133 -38
  117. package/dist/core/tui/draw.d.ts +1 -0
  118. package/dist/core/tui/draw.js +5 -1
  119. package/dist/core/tui/markdown.d.ts +17 -0
  120. package/dist/core/tui/markdown.js +407 -0
  121. package/dist/core/user-settings.d.ts +4 -0
  122. package/dist/core/user-settings.js +1 -0
  123. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +34 -0
  125. package/dist/daemon/api/bridge.d.ts +3 -2
  126. package/dist/daemon/api/bridge.js +34 -9
  127. package/dist/daemon/api/handlers/canvas.js +80 -18
  128. package/dist/daemon/api/handlers/health.js +17 -12
  129. package/dist/daemon/api/handlers/messages.js +12 -1
  130. package/dist/daemon/api/handlers/nodes.js +10 -3
  131. package/dist/daemon/api/map.js +2 -0
  132. package/dist/daemon/messaging/node-message.js +15 -1
  133. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  134. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  135. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  136. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +2 -2
  137. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  138. package/dist/pi-extensions/canvas-recap.js +4 -0
  139. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  140. package/dist/pi-extensions/canvas-stamp.js +77 -0
  141. package/dist/pi-extensions/canvas-stophook.js +3 -0
  142. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  143. package/dist/shared/crtr-version.d.ts +2 -0
  144. package/dist/shared/crtr-version.js +18 -0
  145. package/dist/shared/env.d.ts +10 -0
  146. package/dist/shared/env.js +19 -4
  147. package/dist/types.d.ts +14 -2
  148. package/dist/types.js +4 -0
  149. package/package.json +1 -1
  150. package/runtime.lock.json +2 -2
@@ -1,14 +1,14 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { usage } from '../../core/errors.js';
3
3
  import { cliClient, rethrowAsCliError } from '../api-client.js';
4
- import { filterParams, parseFilters } from './shared.js';
4
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
5
5
  export const grepLeaf = defineLeaf({
6
6
  name: 'grep',
7
7
  description: 'exact regex line search across the cwd\'s node history',
8
8
  whenToUse: 'you know the exact string or pattern you\'re looking for — an error message, a literal identifier, a code fragment — and want every matching body LINE across every node that ran in this cwd, not a relevance ranking. The pattern is required (an ECMAScript regex, case-insensitive). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node. Want ranked/browse search instead? Use `canvas history search` — a distinct leaf, not a flag here. Use `canvas history read <ref>` to read a hit\'s full body, `node inspect artifacts <node-id>` to list one node\'s artifacts, and `node lifecycle revive <id>` to reopen a node you found.',
9
9
  help: {
10
10
  name: 'canvas history grep',
11
- summary: 'required-pattern regex line search over the per-cwd episodic record (reports + context docs + meta)',
11
+ summary: 'required-pattern regex line search over the per-cwd episodic record (reports + context docs + meta; transcripts and inbox opt-in)',
12
12
  params: [
13
13
  { kind: 'positional', name: 'pattern', required: true, constraint: 'An ECMAScript regex, matched case-insensitively against each body line. One hit per matching line.' },
14
14
  ...filterParams,
@@ -43,26 +43,7 @@ export const grepLeaf = defineLeaf({
43
43
  // closures cannot cross the wire.
44
44
  const f = parseFilters(input);
45
45
  const q = { pattern, limit };
46
- if (f.cwd !== undefined)
47
- q.cwd = f.cwd;
48
- if (f.allCwds)
49
- q.all_cwds = true;
50
- if (f.under !== undefined)
51
- q.under = f.under;
52
- if (f.nodes !== undefined)
53
- q.nodes = f.nodes;
54
- if (f.types !== undefined)
55
- q.types = f.types;
56
- if (f.reportKind !== undefined)
57
- q.report_kind = f.reportKind;
58
- if (f.kinds !== undefined)
59
- q.kinds = f.kinds;
60
- if (f.statuses !== undefined)
61
- q.statuses = f.statuses;
62
- if (f.sinceMs !== undefined)
63
- q.since_ms = f.sinceMs;
64
- if (f.untilMs !== undefined)
65
- q.until_ms = f.untilMs;
46
+ applyFilters(q, f);
66
47
  if (cursor !== undefined)
67
48
  q.cursor = cursor;
68
49
  try {
@@ -9,13 +9,14 @@ export const readLeaf = defineLeaf({
9
9
  name: 'canvas history read',
10
10
  summary: 'resolve a <node-id>:<relpath> ref to its full artifact body',
11
11
  params: [
12
- { kind: 'positional', name: 'ref', required: true, constraint: 'The <node-id>:<relpath> handle from `canvas history search` (e.g. mq186ky0-c754531c:reports/20260607T075536-final.md, or <node-id>:meta for a node\'s identity).' },
12
+ { kind: 'positional', name: 'ref', required: true, constraint: 'The <node-id>:<relpath> handle from `canvas history search` (e.g. mq186ky0-c754531c:reports/20260607T075536-final.md, or <node-id>:meta for a node\'s identity). <node-id>:session addresses a node\'s whole conversation; <node-id>:session/<index> one message in it.' },
13
13
  { kind: 'flag', name: 'frontmatter', type: 'bool', required: false, constraint: 'Include the artifact\'s YAML frontmatter. Stripped by default.' },
14
+ { kind: 'flag', name: 'format', type: 'enum', choices: ['rendered', 'raw'], required: false, default: 'rendered', constraint: 'raw returns the verbatim bytes behind the ref — the session .jsonl for a bare session ref, the single persisted jsonl entry for one message, the unparsed file for a report or doc. Use it when the rendered view may be hiding what you are chasing.' },
14
15
  ],
15
16
  output: [
16
17
  { name: 'ref', type: 'string', required: true, constraint: 'Echo of the resolved ref.' },
17
18
  { name: 'node', type: 'string', required: true, constraint: 'Node name + id.' },
18
- { name: 'source', type: 'string', required: true, constraint: 'report | doc | roadmap | meta (+ report-kind for reports).' },
19
+ { name: 'source', type: 'string', required: true, constraint: 'report | doc | roadmap | meta | inbox | transcript (+ report-kind for reports).' },
19
20
  { name: 'ts', type: 'string', required: true, constraint: 'Artifact timestamp.' },
20
21
  { name: 'content', type: 'string', required: true, constraint: 'Full body. Frontmatter stripped unless --frontmatter.' },
21
22
  ],
@@ -25,6 +26,7 @@ export const readLeaf = defineLeaf({
25
26
  run: async (input) => {
26
27
  const ref = input['ref'].trim();
27
28
  const includeFrontmatter = input['frontmatter'] === true;
29
+ const format = input['format'] ?? 'rendered';
28
30
  // Fast local shape check; ref resolution (+ its usage/not-found errors) runs
29
31
  // server-side in crtrd (`GET /v1/canvas/history/read`).
30
32
  if (!ref.includes(':')) {
@@ -34,7 +36,7 @@ export const readLeaf = defineLeaf({
34
36
  });
35
37
  }
36
38
  try {
37
- const res = await cliClient().historyRead({ ref, frontmatter: includeFrontmatter });
39
+ const res = await cliClient().historyRead({ ref, frontmatter: includeFrontmatter, format });
38
40
  return { ref: res.ref, node: res.node, source: res.source, ts: res.ts, content: res.content };
39
41
  }
40
42
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { cliClient, rethrowAsCliError } from '../api-client.js';
3
- import { filterParams, parseFilters } from './shared.js';
3
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
4
4
  const SORTS = ['relevance', 'recency', 'oldest'];
5
5
  export const searchLeaf = defineLeaf({
6
6
  name: 'search',
@@ -8,7 +8,7 @@ export const searchLeaf = defineLeaf({
8
8
  whenToUse: 'you want to find past work by what it SAYS — a design, a final report, a roadmap, a finding — across every node that ran in this cwd, ranked by relevance. Omit the query to browse the record by recency ("what happened here lately"). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node. Need an exact regex over bodies instead of ranking? Use `canvas history grep` — a distinct leaf, not a flag here. Use `canvas history read <ref>` to read a hit in full, `node inspect artifacts <node-id>` to list one node\'s artifacts, and `node lifecycle revive <id>` to reopen a node you found.',
9
9
  help: {
10
10
  name: 'canvas history search',
11
- summary: 'ranked/filtered/sorted content search over the per-cwd episodic record (reports + context docs + meta)',
11
+ summary: 'ranked/filtered/sorted content search over the per-cwd episodic record (reports + context docs + meta; transcripts and inbox opt-in)',
12
12
  params: [
13
13
  { kind: 'positional', name: 'query', required: false, constraint: 'Whitespace-separated terms, matched case-insensitively and weighted across node name/description, report titles, and doc headings (and full bodies with --body); artifacts matching more/stronger fields rank higher. Omit to browse by recency (no relevance ranking; --sort still applies).' },
14
14
  ...filterParams,
@@ -45,26 +45,7 @@ export const searchLeaf = defineLeaf({
45
45
  const q = { snippet_lines: snippetLines, limit, sort: sortMode };
46
46
  if (query !== '')
47
47
  q.query = query;
48
- if (f.cwd !== undefined)
49
- q.cwd = f.cwd;
50
- if (f.allCwds)
51
- q.all_cwds = true;
52
- if (f.under !== undefined)
53
- q.under = f.under;
54
- if (f.nodes !== undefined)
55
- q.nodes = f.nodes;
56
- if (f.types !== undefined)
57
- q.types = f.types;
58
- if (f.reportKind !== undefined)
59
- q.report_kind = f.reportKind;
60
- if (f.kinds !== undefined)
61
- q.kinds = f.kinds;
62
- if (f.statuses !== undefined)
63
- q.statuses = f.statuses;
64
- if (f.sinceMs !== undefined)
65
- q.since_ms = f.sinceMs;
66
- if (f.untilMs !== undefined)
67
- q.until_ms = f.untilMs;
48
+ applyFilters(q, f);
68
49
  if (weighBody)
69
50
  q.weigh_body = true;
70
51
  if (full)
@@ -1,5 +1,7 @@
1
1
  import type { InputParam } from '../../core/help.js';
2
2
  export declare const TYPES: string[];
3
+ export declare const ORIGINS: string[];
4
+ export declare const ROLES: string[];
3
5
  export declare const NODE_KINDS: string[];
4
6
  export declare const STATUSES: string[];
5
7
  /** ISO 8601 (`2026-06-01`) \u2192 epoch ms (absolute); relative (`7d`, `2w`, `1mo`)
@@ -21,7 +23,13 @@ export interface ParsedFilters {
21
23
  statuses: string[] | undefined;
22
24
  sinceMs: number | undefined;
23
25
  untilMs: number | undefined;
26
+ origins: string[] | undefined;
27
+ roles: string[] | undefined;
28
+ from: string | undefined;
24
29
  }
25
30
  /** Parse + validate the shared scope/filter flags from a leaf's raw input.
26
31
  * Pure and client-side; the corpus scan itself runs server-side in crtrd. */
27
32
  export declare function parseFilters(input: Record<string, unknown>): ParsedFilters;
33
+ /** Copy every parsed filter onto a wire query. search, grep, and stats share
34
+ * the whole filter vocabulary, so the mapping lives once. */
35
+ export declare function applyFilters(q: Record<string, unknown>, f: ParsedFilters): void;
@@ -5,7 +5,12 @@
5
5
  // seam. This is internal reuse only: neither leaf's public flag set or output
6
6
  // shape is shared, and nothing here is re-exported as a command alias.
7
7
  import { usage } from '../../core/errors.js';
8
- export const TYPES = ['report', 'doc', 'roadmap', 'meta', 'inbox'];
8
+ export const TYPES = ['report', 'doc', 'roadmap', 'meta', 'inbox', 'transcript'];
9
+ export const ORIGINS = [
10
+ 'human', 'inbox', 'wake', 'revive', 'restart-continuation',
11
+ 'stop-guard', 'park', 'recovery', 'context-nudge', 'kickoff', 'unknown',
12
+ ];
13
+ export const ROLES = ['user', 'assistant', 'toolResult'];
9
14
  export const NODE_KINDS = ['advisor', 'developer', 'spec', 'design', 'review', 'explore', 'plan', 'general'];
10
15
  export const STATUSES = ['active', 'idle', 'done', 'dead', 'canceled'];
11
16
  const REL_UNITS = {
@@ -47,6 +52,9 @@ export const filterParams = [
47
52
  { kind: 'flag', name: 'status', type: 'string', required: false, constraint: `Node lifecycle status; comma-separated for several. One of: ${STATUSES.join(', ')} (e.g. done = completed work only).` },
48
53
  { kind: 'flag', name: 'since', type: 'string', required: false, constraint: 'Lower bound on artifact timestamp. ISO 8601 (2026-06-01) or relative (7d, 2w, 1mo).' },
49
54
  { kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'Upper bound on artifact timestamp. ISO 8601 or relative.' },
55
+ { kind: 'flag', name: 'origin', type: 'string', required: false, constraint: `What drove a message to arrive; comma-separated for several. One of: ${ORIGINS.join(', ')}. Applies to transcript + inbox artifacts, which are the only ones that arrive — passing it therefore excludes every generated turn on its own. \`unknown\` is a message carrying no stamp, and is its own bucket rather than a fallback.` },
56
+ { kind: 'flag', name: 'role', type: 'string', required: false, constraint: `Transcript message role; comma-separated for several. One of: ${ROLES.join(', ')}.` },
57
+ { kind: 'flag', name: 'from', type: 'string', required: false, constraint: 'Case-insensitive substring against sender attribution — the node that sent a delivery. Applies to transcript + inbox artifacts.' },
50
58
  ];
51
59
  /** Parse + validate the shared scope/filter flags from a leaf's raw input.
52
60
  * Pure and client-side; the corpus scan itself runs server-side in crtrd. */
@@ -61,6 +69,28 @@ export function parseFilters(input) {
61
69
  if (bad !== undefined)
62
70
  throw usage(`--type must be one of: ${TYPES.join(', ')}; received: ${bad}`);
63
71
  }
72
+ const origins = splitCsv(input['origin']);
73
+ if (origins !== undefined) {
74
+ const bad = origins.find((o) => !ORIGINS.includes(o));
75
+ if (bad !== undefined)
76
+ throw usage(`--origin must be one of: ${ORIGINS.join(', ')}; received: ${bad}`);
77
+ }
78
+ const roles = splitCsv(input['role']);
79
+ if (roles !== undefined) {
80
+ const bad = roles.find((r) => !ROLES.includes(r));
81
+ if (bad !== undefined)
82
+ throw usage(`--role must be one of: ${ROLES.join(', ')}; received: ${bad}`);
83
+ }
84
+ // Session files are large and there is no persistent index, so the whole
85
+ // conversation corpus is reachable only through a narrowed scan. The guard
86
+ // lives in the failure rather than in a permanently visible constraint:
87
+ // agents who never search transcripts never pay to understand it.
88
+ if (types !== undefined && types.includes('transcript')) {
89
+ const narrowed = input['node'] !== undefined || input['under'] !== undefined || input['since'] !== undefined;
90
+ if (!narrowed) {
91
+ throw usage('--type transcript needs a narrowing flag: pass --node, --under, or --since.', { next: 'Transcripts are the full conversation corpus — scanning every node in a cwd reads every session file. Narrow first.' });
92
+ }
93
+ }
64
94
  return {
65
95
  cwd,
66
96
  allCwds,
@@ -72,5 +102,38 @@ export function parseFilters(input) {
72
102
  statuses: splitCsv(input['status']),
73
103
  sinceMs: input['since'] !== undefined ? parseWhen('since', input['since']) : undefined,
74
104
  untilMs: input['until'] !== undefined ? parseWhen('until', input['until']) : undefined,
105
+ origins,
106
+ roles,
107
+ from: input['from'],
75
108
  };
76
109
  }
110
+ /** Copy every parsed filter onto a wire query. search, grep, and stats share
111
+ * the whole filter vocabulary, so the mapping lives once. */
112
+ export function applyFilters(q, f) {
113
+ if (f.cwd !== undefined)
114
+ q['cwd'] = f.cwd;
115
+ if (f.allCwds)
116
+ q['all_cwds'] = true;
117
+ if (f.under !== undefined)
118
+ q['under'] = f.under;
119
+ if (f.nodes !== undefined)
120
+ q['nodes'] = f.nodes;
121
+ if (f.types !== undefined)
122
+ q['types'] = f.types;
123
+ if (f.reportKind !== undefined)
124
+ q['report_kind'] = f.reportKind;
125
+ if (f.kinds !== undefined)
126
+ q['kinds'] = f.kinds;
127
+ if (f.statuses !== undefined)
128
+ q['statuses'] = f.statuses;
129
+ if (f.sinceMs !== undefined)
130
+ q['since_ms'] = f.sinceMs;
131
+ if (f.untilMs !== undefined)
132
+ q['until_ms'] = f.untilMs;
133
+ if (f.origins !== undefined)
134
+ q['origins'] = f.origins;
135
+ if (f.roles !== undefined)
136
+ q['roles'] = f.roles;
137
+ if (f.from !== undefined)
138
+ q['from'] = f.from;
139
+ }
@@ -0,0 +1 @@
1
+ export declare const statsLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,68 @@
1
+ // `canvas history stats` — the grouped-count projection over the same corpus
2
+ // search and grep scan. A distinct leaf rather than a flag on search because
3
+ // the output shape differs: counts by one dimension, not a hit list. Grouping
4
+ // by origin is what turns the per-message stamps into a census — "what actually
5
+ // drove work here" — without a second command family.
6
+ import { defineLeaf } from '../../core/command.js';
7
+ import { usage } from '../../core/errors.js';
8
+ import { cliClient, rethrowAsCliError } from '../api-client.js';
9
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
10
+ const GROUP_KEYS = ['origin', 'node', 'kind', 'day', 'type', 'role'];
11
+ export const statsLeaf = defineLeaf({
12
+ name: 'stats',
13
+ description: 'grouped counts over the cwd\'s node history — the projection view',
14
+ whenToUse: 'you want the SHAPE of the record rather than individual hits — how much of it came from where, who produced it, or when it happened. Group by origin to census what drove agents to run (cron wakes, inbox deliveries, recovery re-drives, people), by node/kind to see who produced the work, by day for volume over time, by type for corpus composition. Takes every scope and filter `canvas history search` takes, so any query you can search you can also count. Want the matching artifacts themselves? Use `canvas history search` or `canvas history grep`.',
15
+ help: {
16
+ name: 'canvas history stats',
17
+ summary: 'counts by one dimension over the per-cwd episodic record — origin census, per-node volume, activity by day',
18
+ params: [
19
+ { kind: 'flag', name: 'group-by', type: 'enum', choices: GROUP_KEYS, required: false, default: 'type', constraint: 'The dimension to count by. origin = what drove an inbound message (transcript + inbox only); role = transcript message role; node / kind = who produced it; day = artifact date; type = corpus composition.' },
20
+ ...filterParams,
21
+ ],
22
+ output: [
23
+ { name: 'group_by', type: 'string', required: true, constraint: 'The dimension counted.' },
24
+ { name: 'groups', type: 'object[]', required: true, constraint: '{key, count}, descending by count then key.' },
25
+ { name: 'total', type: 'int', required: true, constraint: 'Artifacts that carried the grouped dimension. Lower than `scanned` when grouping by one not every artifact has (origin, role).' },
26
+ { name: 'scanned', type: 'int', required: true, constraint: 'Artifacts matched by the filters before grouping.' },
27
+ ],
28
+ outputKind: 'object',
29
+ effects: ['None. Read-only.'],
30
+ },
31
+ run: async (input) => {
32
+ const by = input['groupBy'] ?? 'type';
33
+ if (!GROUP_KEYS.includes(by)) {
34
+ throw usage(`--group-by must be one of: ${GROUP_KEYS.join(', ')}; received: ${by}`);
35
+ }
36
+ // Filter validation stays client-side (pure); the corpus scan + grouping
37
+ // run server-side in crtrd, where canvas.db and the session files live.
38
+ const f = parseFilters(input);
39
+ const q = { group_by: by };
40
+ applyFilters(q, f);
41
+ try {
42
+ const res = await cliClient().historyStats(q);
43
+ return { group_by: res.group_by, groups: res.groups, total: res.total, scanned: res.scanned };
44
+ }
45
+ catch (err) {
46
+ rethrowAsCliError(err);
47
+ }
48
+ },
49
+ render: (r) => {
50
+ const groups = r['groups'];
51
+ const by = String(r['group_by']);
52
+ const total = r['total'];
53
+ const scanned = r['scanned'];
54
+ if (groups.length === 0) {
55
+ return `0 artifacts carried \`${by}\` (${scanned} scanned).`;
56
+ }
57
+ const width = Math.max(...groups.map((g) => g.key.length));
58
+ const max = groups[0].count;
59
+ const rows = groups
60
+ .map((g) => {
61
+ const bar = '█'.repeat(Math.max(1, Math.round((g.count / max) * 24)));
62
+ return ` ${g.key.padEnd(width)} ${String(g.count).padStart(6)} ${bar}`;
63
+ })
64
+ .join('\n');
65
+ const tail = total === scanned ? `${total} artifacts` : `${total} of ${scanned} scanned carried \`${by}\``;
66
+ return `by ${by}:\n\n${rows}\n\n- ${tail}`;
67
+ },
68
+ });
@@ -10,6 +10,7 @@ import { defineBranch } from '../core/command.js';
10
10
  import { searchLeaf } from './canvas-history/search.js';
11
11
  import { grepLeaf } from './canvas-history/grep.js';
12
12
  import { readLeaf } from './canvas-history/read.js';
13
+ import { statsLeaf } from './canvas-history/stats.js';
13
14
  /** A tiny per-cwd `<corpus>` marker for branch -h — just enough context to
14
15
  * anchor the help text without scanning nodes or files. Uses only the injected
15
16
  * node cwd env (or the process cwd outside a node), so help stays cheap. */
@@ -19,13 +20,13 @@ function corpusBlock() {
19
20
  }
20
21
  export const historyBranch = defineBranch({
21
22
  name: 'history',
22
- description: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs',
23
+ description: 'search and recall the cwd-wide content corpus — every node\'s reports, context docs, and conversations',
23
24
  whenToUse: 'you want to find or re-read what was DONE in a cwd — a past design, a final report, a roadmap, a finding — across every node that ran there, WITHOUT already knowing which node. Use it when picking up prior work ("that caching work from last week"), recovering an artifact, or surveying a project\'s history. Already have the node id and just want everything IT left behind? Use `node inspect artifacts <id>` instead — this branch is the cwd-wide search corpus, not a per-node listing. Distinct from `crtr memory` (curated semantic knowledge, not episodes) and from `canvas dashboard`/`node inspect` (graph topology, not content).',
24
25
  help: {
25
26
  name: 'canvas history',
26
27
  summary: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs in this cwd',
27
- model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries) and context docs (specs, designs, roadmaps, findings). `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `grep` finds by an exact ECMAScript regex over body lines (pattern required, one hit per matching line — use this over `search` when you know the literal string/pattern, not just the topic); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. Already have a node id and want everything IT left behind (not a cwd-wide search)? Use `node inspect artifacts <node-id>` instead — that single-node listing moved out of this branch. A node\'s raw inbox history (`--type inbox`) is a separate, opt-in diagnostic corpus — the full cursor-independent inbox.jsonl for one node, including un-clipped direct-message bodies; reach for it only when the curated reports/docs above don\'t already answer the question. To reopen a node you found, use `node lifecycle revive`; for its place in the graph, `node inspect show`.',
28
+ model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries), context docs (specs, designs, roadmaps, findings), and the conversations themselves. `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `grep` finds by an exact ECMAScript regex over body lines (pattern required, one hit per matching line — use this over `search` when you know the literal string/pattern, not just the topic); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref, `--format raw` for the verbatim bytes; `stats` counts the same filtered corpus by one dimension instead of listing hits. Corpus is a DIMENSION here, not a location: `--type` selects which record you search, and every leaf takes the same scope and filters. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. Already have a node id and want everything IT left behind (not a cwd-wide search)? Use `node inspect artifacts <node-id>` instead. Two corpora are opt-in diagnostics rather than curated work: `--type inbox` is one node\'s full cursor-independent inbox.jsonl including un-clipped message bodies, and `--type transcript` is the conversation itself, message by message, each inbound turn carrying the origin that drove it (`--origin`, `--role`, `--from`, and `stats --group-by origin` for the census). Transcripts require a narrowing flag because there is no index. To reopen a node you found, use `node lifecycle revive`; for its place in the graph, `node inspect show`.',
28
29
  dynamicState: corpusBlock,
29
30
  },
30
- children: [searchLeaf, grepLeaf, readLeaf],
31
+ children: [searchLeaf, grepLeaf, readLeaf, statsLeaf],
31
32
  });
@@ -3,13 +3,12 @@ import { CrtrClient } from '../../api/index.js';
3
3
  import { defineLeaf } from '../../core/command.js';
4
4
  import { CrtrError } from '../../core/errors.js';
5
5
  import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
6
- import { realpathOrSelf } from '../../core/fs-utils.js';
7
6
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
8
7
  import { createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, readMemoryDocContent, resolveMemoryCandidates, resolveMemoryDocs, } from '../../core/memory-resolver.js';
9
8
  import { expandShellBlocks, hasShellBlocks, makeNodeShellRunner } from '../../core/runtime/shell-expansion.js';
10
- import { emptyContextExposureState, exposedAtOrAbove, exposureTarget, loadContextExposureState, registerDocumentExposure, registerExposure, saveContextExposureState, } from '../../core/substrate/injected-store.js';
11
- import { dirDedupKey, docListingDirs, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
12
- import { memoryReadDocBlocks } from '../../core/substrate/on-read.js';
9
+ import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerDocumentExposure, registerExposure, saveContextExposureState, } from '../../core/substrate/injected-store.js';
10
+ import { dirDedupKey, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
11
+ import { contentReadAttachments } from '../../core/substrate/on-read.js';
13
12
  import { renderResult } from '../../core/render.js';
14
13
  import { effectiveDocKind } from '../../core/substrate/schema.js';
15
14
  import { canonicalSegments } from '../../core/memory/identity.js';
@@ -49,13 +48,6 @@ export function candidateRow(doc, winner) {
49
48
  path: doc.path,
50
49
  };
51
50
  }
52
- function attr(s) {
53
- return s
54
- .replace(/&/g, '&amp;')
55
- .replace(/"/g, '&quot;')
56
- .replace(/</g, '&lt;')
57
- .replace(/>/g, '&gt;');
58
- }
59
51
  export const readLeaf = defineLeaf({
60
52
  name: 'read',
61
53
  description: 'load a memory document body by name',
@@ -190,36 +182,21 @@ export const readLeaf = defineLeaf({
190
182
  : emptyContextExposureState();
191
183
  const target = exposureTarget(contextExposure, 'transcript');
192
184
  const subject = await fetchSubject(nodeId);
193
- const excludeReal = realpathOrSelf(doc.path);
194
- // The explicit read IS a content delivery: record it first so unsolicited
195
- // channels (including this doc's own line in its directory listing) stay
196
- // quiet about a doc whose full body is already in the transcript.
185
+ // An explicit read is itself content delivery. Register it before shared
186
+ // attachments, so the directory tree and companion routes dedupe against
187
+ // the body just returned exactly as every automatic content delivery does.
197
188
  registerDocumentExposure(target, doc.path, doc.body, 'content');
198
- const blocks = [];
199
- // A doc that fronts a directory carries its members with its body: that
200
- // listing is part of the deliberate read (every line renders) and returns
201
- // as `listing`, while the ancestors above it stay unsolicited neighborhood
202
- // context. The node's own document is never among its children, so nothing
203
- // repeats.
204
- const dirs = docListingDirs(listingByName, doc.name);
205
- const ownDir = dirs[0] === doc.name;
206
- let listing;
207
- if (ownDir) {
208
- listing = renderDirListing(listingByName, doc.name, target, false);
209
- registerExposure(target, dirDedupKey(doc.name), 'preview');
210
- }
211
- for (const dir of ownDir ? dirs.slice(1) : dirs) {
212
- if (exposedAtOrAbove(contextExposure, dirDedupKey(dir), 'preview'))
213
- continue;
214
- const lines = renderDirListing(listingByName, dir, target, true);
215
- registerExposure(target, dirDedupKey(dir), 'preview');
216
- if (lines.length > 0)
217
- blocks.push(`<memory-listing dir="${attr(dir)}">\n${lines.join('\n')}\n</memory-listing>`);
218
- }
219
- blocks.push(...memoryReadDocBlocks(subject, excludeReal, doc.name, target));
189
+ const attachments = contentReadAttachments(doc, {
190
+ subject,
191
+ target,
192
+ byName: listingByName,
193
+ listings: 'blocks',
194
+ explicit: true,
195
+ });
220
196
  if (nodeId !== undefined)
221
197
  saveContextExposureState(nodeId, contextExposure);
222
- const finalContent = blocks.length === 0 ? content : `<auto-loaded-context>\n${blocks.join('\n')}\n</auto-loaded-context>\n\n${content}`;
198
+ const finalContent = attachments.blocks.length === 0 ? content : `<auto-loaded-context>\n${attachments.blocks.join('\n')}\n</auto-loaded-context>\n\n${content}`;
199
+ const listing = attachments.listing;
223
200
  const hasListing = listing !== undefined && listing.length > 0;
224
201
  const followUp = hasListing
225
202
  ? links.length > 0
@@ -195,7 +195,7 @@ export declare function overlayParam(name: string, overrides?: Partial<FlagParam
195
195
  * `--doc-rationale`, because `--rationale` there means why THIS REVISION is
196
196
  * happening. Same field, same prose, two flag names that cannot be confused. */
197
197
  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.";
198
- 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
+ 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\u2019s full body is delivered, including an explicit 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.";
199
199
  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.";
200
200
  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.";
201
201
  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.";
@@ -795,7 +795,7 @@ export function overlayParam(name, overrides = {}, extraConstraint) {
795
795
  * `--doc-rationale`, because `--rationale` there means why THIS REVISION is
796
796
  * happening. Same field, same prose, two flag names that cannot be confused. */
797
797
  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.';
798
- 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.';
798
+ 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’s full body is delivered, including an explicit 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.';
799
799
  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.';
800
800
  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.';
801
801
  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.';
@@ -94,8 +94,8 @@ function nodeNewParams() {
94
94
  whenToUse: 'Use only when the user, task, or governing agent guidance already requires a specific provider, model, capability tier, or family. Omit it for routine delegation: the selected persona kind and profile own normal model selection, and review personas may enforce a quality floor.',
95
95
  value: `Accepted forms: ${MODEL_SPEC_FORMS}. Concrete provider/id requests must be registered. Portable provider/tier, bare-tier, and family-alias requests resolve through the configured model ladders.`,
96
96
  effects: [
97
- 'The resolved override is stored on the node and in its launch recipe, so future revives preserve it.',
98
- 'A concrete provider/id pins that provider. Portable tiers and aliases preserve logical intent for runtime routing; a persona quality floor may raise a weaker request.',
97
+ 'The resolved concrete override is stored on the node and in its launch recipe, so every launch and revive uses that exact provider/model.',
98
+ 'Portable tiers and aliases resolve through the configured ladders; a persona quality floor may raise a weaker request before its resolved model is stored.',
99
99
  ],
100
100
  },
101
101
  },
@@ -39,6 +39,11 @@ function assertUserScopeBrokerThresholds(key, scope) {
39
39
  throw usage('brokerThresholds are available only in user scope');
40
40
  }
41
41
  }
42
+ function assertUserScopeLifecycle(key, scope) {
43
+ if (key.split('.', 1)[0] === 'lifecycle' && scope !== 'user') {
44
+ throw usage('lifecycle settings are available only in user scope');
45
+ }
46
+ }
42
47
  function assertWorkingGerundsScope(key, scope) {
43
48
  if (key === 'working_gerunds' && scope !== 'user') {
44
49
  throw usage('working_gerunds is available only in user scope');
@@ -132,6 +137,16 @@ function setNestedValue(cfg, key, rawValue, value) {
132
137
  cfg.brokerThresholds[parts[1]] = threshold;
133
138
  return;
134
139
  }
140
+ if (topKey === 'lifecycle') {
141
+ if (parts.length !== 2 || parts[1] !== 'unattendedParkMs') {
142
+ throw usage(`lifecycle key must be unattendedParkMs`);
143
+ }
144
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
145
+ throw usage(`lifecycle.unattendedParkMs must be an integer >= 1`);
146
+ }
147
+ cfg.lifecycle.unattendedParkMs = Math.floor(value);
148
+ return;
149
+ }
135
150
  throw usage(`unsupported key path for set: ${key}`);
136
151
  }
137
152
  // Leaf definitions
@@ -143,7 +158,7 @@ const configGet = defineLeaf({
143
158
  name: 'sys config get',
144
159
  summary: 'read a config value by dotted key',
145
160
  params: [
146
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. Top-level keys: ${CONFIG_TOP_LEVEL_HELP}. brokerThresholds is user-only; keybindings is readable only as the whole user-scope object and editable only through sys settings.` },
161
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. Top-level keys: ${CONFIG_TOP_LEVEL_HELP}. brokerThresholds and lifecycle are user-only; keybindings is readable only as the whole user-scope object and editable only through sys settings.` },
147
162
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
148
163
  ],
149
164
  output: [
@@ -158,6 +173,7 @@ const configGet = defineLeaf({
158
173
  const key = input['key'];
159
174
  const scope = resolveScope(input['scope']);
160
175
  assertUserScopeBrokerThresholds(key, scope);
176
+ assertUserScopeLifecycle(key, scope);
161
177
  if (key.startsWith('keybindings.') || (key === 'keybindings' && scope !== 'user')) {
162
178
  throw notFound('keybindings are available only as the whole user-scope object');
163
179
  }
@@ -177,7 +193,7 @@ const configSet = defineLeaf({
177
193
  name: 'sys config set',
178
194
  summary: 'write a config value by dotted key',
179
195
  params: [
180
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. User-scope scalar settings: ${SCALAR_SETTING_HELP}. Also supported: working_gerunds and whip_messages (non-empty JSON string arrays), brokerThresholds.warning and brokerThresholds.automaticReviveCap (user scope; integer >= 1), modelLadders.defaultProvider, and modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys settings. humanActions is an object map edited directly in ~/.crouter/config.json or <repo>/.crouter/config.json.` },
196
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. User-scope scalar settings: ${SCALAR_SETTING_HELP}. Also supported: working_gerunds and whip_messages (non-empty JSON string arrays), brokerThresholds.warning and brokerThresholds.automaticReviveCap (user scope; integer >= 1), lifecycle.unattendedParkMs (user scope; integer ms >= 1), modelLadders.defaultProvider, and modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys settings. humanActions is an object map edited directly in ~/.crouter/config.json or <repo>/.crouter/config.json.` },
181
197
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. working_gerunds and whip_messages accept JSON arrays; other values are stored as-is if quoted and coerced to number or boolean when unambiguous.' },
182
198
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user. Project scope is supported only for modelLadders.' },
183
199
  ],
@@ -193,6 +209,7 @@ const configSet = defineLeaf({
193
209
  const rawValue = input['value'];
194
210
  const scope = resolveScope(input['scope']);
195
211
  assertUserScopeBrokerThresholds(key, scope);
212
+ assertUserScopeLifecycle(key, scope);
196
213
  assertWorkingGerundsScope(key, scope);
197
214
  assertWhipMessagesScope(key, scope);
198
215
  assertUserSettingScope(key, scope);
@@ -1,5 +1,5 @@
1
1
  import { type SurfaceEvent } from '../../../../core/substrate/schema.js';
2
- import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
2
+ import type { EffectiveDeliveryRecord } from '../resolve.js';
3
3
  import { PlanSet } from './model.js';
4
4
  import { type Filters } from './list-view.js';
5
5
  /** One physical document across every event. `records` is sparse: an event
@@ -11,8 +11,8 @@ export interface DocRow {
11
11
  /** The record for the focused event, or the first one any event carries —
12
12
  * every panel needs a document to describe even when the focused event's
13
13
  * corpus excludes it. */
14
- anchor: DeliveryRecord;
15
- records: Map<SurfaceEvent, DeliveryRecord>;
14
+ anchor: EffectiveDeliveryRecord;
15
+ records: Map<SurfaceEvent, EffectiveDeliveryRecord>;
16
16
  /** Present only in an event other than the focused one. */
17
17
  offEvent: boolean;
18
18
  }
@@ -1,4 +1,5 @@
1
1
  import { type DeliveryPlan, type DeliveryRecord } from '../../../../core/substrate/plan.js';
2
+ import { type EffectiveDeliveryRecord } from '../resolve.js';
2
3
  import { type Rung, type SurfaceEntry, type SurfaceEvent } from '../../../../core/substrate/schema.js';
3
4
  import { type NodeConfigSubject } from '../../../../core/substrate/subject-fields.js';
4
5
  import { type MemoryTarget } from '../../../../core/memory-resolver.js';
@@ -45,11 +46,15 @@ export declare class PlanSet {
45
46
  invalidate(): void;
46
47
  dispose(): void;
47
48
  /** The plan for one event, computing it now. */
48
- plan(event: SurfaceEvent): DeliveryPlan;
49
+ plan(event: SurfaceEvent): DeliveryPlan & {
50
+ docs: EffectiveDeliveryRecord[];
51
+ };
49
52
  /** The plan for one event only if it is already computed — what the rail's
50
53
  * EVENTS counts read, so an unwarmed event draws `…` instead of stalling the
51
54
  * frame on five extra corpus loads. */
52
- peek(event: SurfaceEvent): DeliveryPlan | undefined;
55
+ peek(event: SurfaceEvent): (DeliveryPlan & {
56
+ docs: EffectiveDeliveryRecord[];
57
+ }) | undefined;
53
58
  /** Compute one pending event per macrotask, calling back after each so the
54
59
  * rail's counts fill in row by row. */
55
60
  warm(onProgress: () => void): void;