@north-light/crouter 0.3.235 → 0.3.237

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 (181) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +14 -5
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +399 -399
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/__tests__/node-new.test.js +58 -0
  20. package/dist/commands/memory/lint.js +22 -13
  21. package/dist/commands/memory/shared.js +1 -1
  22. package/dist/commands/node/create.d.ts +1 -1
  23. package/dist/commands/node/create.js +4 -1
  24. package/dist/commands/node/lifecycle.js +2 -2
  25. package/dist/commands/node-worktree.js +39 -8
  26. package/dist/commands/revive.js +11 -2
  27. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  28. package/dist/commands/sys/context/admin/actions.js +246 -0
  29. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  30. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  31. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  32. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  33. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  34. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  35. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  36. package/dist/commands/sys/context/admin/model.js +209 -0
  37. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  38. package/dist/commands/sys/context/admin/shell.js +669 -0
  39. package/dist/commands/sys/context/admin.d.ts +1 -0
  40. package/dist/commands/sys/context/admin.js +124 -0
  41. package/dist/commands/sys/context/doc.d.ts +1 -0
  42. package/dist/commands/sys/context/doc.js +196 -0
  43. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  44. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  45. package/dist/commands/sys/context/resolve.d.ts +55 -0
  46. package/dist/commands/sys/context/resolve.js +428 -0
  47. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  48. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  49. package/dist/commands/sys/context.d.ts +2 -0
  50. package/dist/commands/sys/context.js +20 -0
  51. package/dist/commands/sys.js +3 -4
  52. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  53. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  54. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  55. package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
  56. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  57. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  58. package/dist/core/__tests__/helpers/harness.js +1 -0
  59. package/dist/core/__tests__/human-deliver.test.js +1 -1
  60. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  61. package/dist/core/__tests__/integration/revive.test.js +12 -12
  62. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  63. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  64. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  65. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  66. package/dist/core/__tests__/migration.test.js +38 -1
  67. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  68. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  69. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  70. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  71. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  72. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  73. package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
  74. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  75. package/dist/core/canvas/browse/render.js +11 -3
  76. package/dist/core/canvas/canvas.d.ts +15 -3
  77. package/dist/core/canvas/canvas.js +52 -16
  78. package/dist/core/canvas/migrations.js +21 -0
  79. package/dist/core/canvas/nav-render.js +3 -1
  80. package/dist/core/canvas/remote-canvas-source.js +2 -0
  81. package/dist/core/canvas/render-source.d.ts +3 -1
  82. package/dist/core/canvas/render-source.js +7 -3
  83. package/dist/core/canvas/status-glyph.d.ts +10 -1
  84. package/dist/core/canvas/status-glyph.js +19 -3
  85. package/dist/core/canvas/types.d.ts +22 -3
  86. package/dist/core/feed/feed.js +1 -1
  87. package/dist/core/human/feedback-companion.js +5 -2
  88. package/dist/core/memory/lint.d.ts +19 -0
  89. package/dist/core/memory/lint.js +97 -0
  90. package/dist/core/memory-resolver.d.ts +4 -0
  91. package/dist/core/memory-resolver.js +7 -7
  92. package/dist/core/preview-registry.js +4 -4
  93. package/dist/core/review/realize.js +4 -2
  94. package/dist/core/runtime/close.js +1 -1
  95. package/dist/core/runtime/fleet.d.ts +29 -1
  96. package/dist/core/runtime/fleet.js +23 -0
  97. package/dist/core/runtime/host.js +32 -11
  98. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  99. package/dist/core/runtime/launch-prompt.js +2 -2
  100. package/dist/core/runtime/launch-target.js +1 -1
  101. package/dist/core/runtime/lifecycle.d.ts +4 -2
  102. package/dist/core/runtime/lifecycle.js +12 -1
  103. package/dist/core/runtime/memory.js +1 -1
  104. package/dist/core/runtime/reopen.js +10 -1
  105. package/dist/core/runtime/reset.js +1 -1
  106. package/dist/core/runtime/revive-all.d.ts +3 -0
  107. package/dist/core/runtime/revive-all.js +10 -4
  108. package/dist/core/runtime/revive.d.ts +12 -0
  109. package/dist/core/runtime/revive.js +62 -3
  110. package/dist/core/runtime/spawn.d.ts +9 -13
  111. package/dist/core/runtime/spawn.js +40 -17
  112. package/dist/core/substrate/frontmatter-validation.js +1 -1
  113. package/dist/core/substrate/index.d.ts +6 -3
  114. package/dist/core/substrate/index.js +4 -3
  115. package/dist/core/substrate/on-read.d.ts +0 -8
  116. package/dist/core/substrate/on-read.js +45 -124
  117. package/dist/core/substrate/plan.d.ts +94 -0
  118. package/dist/core/substrate/plan.js +266 -0
  119. package/dist/core/substrate/render.d.ts +5 -6
  120. package/dist/core/substrate/render.js +76 -129
  121. package/dist/core/substrate/schema.d.ts +1 -6
  122. package/dist/core/substrate/schema.js +1 -15
  123. package/dist/core/substrate/session-cache.d.ts +2 -10
  124. package/dist/core/substrate/session-cache.js +9 -47
  125. package/dist/core/substrate/subject-fields.d.ts +1 -4
  126. package/dist/core/substrate/subject-fields.js +2 -5
  127. package/dist/core/substrate/surface-match.d.ts +37 -17
  128. package/dist/core/substrate/surface-match.js +44 -53
  129. package/dist/core/tui/page-host.d.ts +6 -0
  130. package/dist/core/tui/page-host.js +12 -3
  131. package/dist/core/worktree.d.ts +8 -0
  132. package/dist/core/worktree.js +65 -0
  133. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  134. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  135. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  136. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  137. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  138. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  139. package/dist/daemon/api/bridge.js +5 -1
  140. package/dist/daemon/api/handlers/attach.js +5 -1
  141. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  142. package/dist/daemon/api/handlers/messages.js +10 -2
  143. package/dist/daemon/api/handlers/nodes.js +8 -4
  144. package/dist/daemon/api/handlers/worktree.js +10 -1
  145. package/dist/daemon/api/map.js +7 -0
  146. package/dist/daemon/companion-retire.js +1 -1
  147. package/dist/daemon/cron/passes.d.ts +0 -5
  148. package/dist/daemon/cron/passes.js +0 -20
  149. package/dist/daemon/cron/sinks.js +1 -1
  150. package/dist/daemon/cron-run.d.ts +5 -6
  151. package/dist/daemon/cron-run.js +7 -16
  152. package/dist/daemon/crtrd.d.ts +0 -10
  153. package/dist/daemon/crtrd.js +23 -61
  154. package/dist/daemon/fleet.d.ts +10 -7
  155. package/dist/daemon/fleet.js +71 -12
  156. package/dist/daemon/messaging/node-message.js +3 -1
  157. package/dist/daemon/profile-delete.js +1 -1
  158. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  159. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  160. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  161. package/dist/daemon/reconcilers/controller-death.js +26 -0
  162. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  163. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  164. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  165. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  170. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  171. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  172. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  173. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  174. package/package.json +1 -1
  175. package/runtime.lock.json +2 -2
  176. package/dist/commands/sys/prompt-review.d.ts +0 -1
  177. package/dist/commands/sys/sysprompt.d.ts +0 -1
  178. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  179. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  180. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  181. /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
@@ -5,6 +5,8 @@ import { join } from 'node:path';
5
5
  import { editBufferInEditor } from '../roundtrip.js';
6
6
  const temp = mkdtempSync(join(tmpdir(), 'humanloop-editor-roundtrip-'));
7
7
  const priorEditor = process.env.EDITOR;
8
+ const priorVisual = process.env.VISUAL;
9
+ delete process.env.VISUAL;
8
10
  try {
9
11
  const successfulEditor = join(temp, 'success-editor');
10
12
  writeFileSync(successfulEditor, '#!/bin/sh\nprintf "edited\\n" > "$1"\n');
@@ -24,6 +26,10 @@ finally {
24
26
  delete process.env.EDITOR;
25
27
  else
26
28
  process.env.EDITOR = priorEditor;
29
+ if (priorVisual === undefined)
30
+ delete process.env.VISUAL;
31
+ else
32
+ process.env.VISUAL = priorVisual;
27
33
  rmSync(temp, { recursive: true, force: true });
28
34
  }
29
35
  console.log('editor round-trip tests passed');
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  /** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
7
7
  export function editBufferInEditor(buffer) {
8
8
  const tmpFile = join(tmpdir(), `crtr-input-${randomUUID()}.txt`);
9
- const editor = process.env.EDITOR || 'vi';
9
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
10
10
  try {
11
11
  writeFileSync(tmpFile, buffer);
12
12
  // $EDITOR may be a shell fragment such as "code --wait".
@@ -0,0 +1,58 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { parseArgv } from '../../core/command.js';
7
+ import { nodeNew } from '../node/create.js';
8
+ function listen(server, path) {
9
+ return new Promise((resolve, reject) => {
10
+ server.once('error', reject);
11
+ server.listen(path, resolve);
12
+ });
13
+ }
14
+ function close(server) {
15
+ return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
16
+ }
17
+ test('node new forwards --resident in the create request', async () => {
18
+ const previousHome = process.env['CRTR_HOME'];
19
+ const home = mkdtempSync('/tmp/crtr-node-new-resident-');
20
+ const socketPath = join(home, 'crtrd.sock');
21
+ let requestBody;
22
+ const server = createServer((req, res) => {
23
+ const chunks = [];
24
+ req.on('data', (chunk) => chunks.push(chunk));
25
+ req.on('end', () => {
26
+ requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8'));
27
+ const body = JSON.stringify({
28
+ node_id: 'resident-test',
29
+ name: 'Review worker',
30
+ status: 'active',
31
+ cwd: home,
32
+ });
33
+ res.writeHead(201, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
34
+ res.end(body);
35
+ });
36
+ });
37
+ process.env['CRTR_HOME'] = home;
38
+ try {
39
+ await listen(server, socketPath);
40
+ const input = await parseArgv(nodeNew.help.params ?? [], [
41
+ 'Review the worker.',
42
+ '--root',
43
+ '--resident',
44
+ '--name',
45
+ 'Review worker',
46
+ ]);
47
+ await nodeNew.run(input);
48
+ assert.equal(requestBody?.['root_lifecycle'], 'resident');
49
+ }
50
+ finally {
51
+ await close(server);
52
+ if (previousHome === undefined)
53
+ delete process.env['CRTR_HOME'];
54
+ else
55
+ process.env['CRTR_HOME'] = previousHome;
56
+ rmSync(home, { recursive: true, force: true });
57
+ }
58
+ });
@@ -7,7 +7,7 @@ import { envProfileId } from '../../shared/env.js';
7
7
  import { defineLeaf } from '../../core/command.js';
8
8
  import { warn } from '../../core/output.js';
9
9
  import { ExitCode } from '../../types.js';
10
- import { lintMemoryStore, lintMemoryTarget, resolvableCanonicalNames, } from '../../core/memory/lint.js';
10
+ import { lintMemoryStore, lintMemoryTarget, lintOrphans, resolvableCanonicalNames, } from '../../core/memory/lint.js';
11
11
  import { loadMemoryTargetView } from '../../core/memory-resolver.js';
12
12
  import { resolveReadSelector, selectorParam } from './shared.js';
13
13
  function findingRow(finding) {
@@ -21,16 +21,16 @@ function findingRow(finding) {
21
21
  ...(finding.paths === undefined ? {} : { paths: [...finding.paths] }),
22
22
  };
23
23
  }
24
- /** The canonical names a `--dir` store's own links and routes must resolve in:
25
- * the target view an agent standing in that project sees. Never the store in
26
- * isolation — a project doc legitimately links a user or builtin doc. */
27
- function resolvableNamesForStore(store) {
28
- const view = loadMemoryTargetView({
24
+ /** The target view an agent standing in a `--dir` store's project sees. Both a
25
+ * project doc's links/routes and its reachability are resolved against this,
26
+ * never the store in isolation — a project doc legitimately links a user or
27
+ * builtin doc, and is legitimately reached from one. */
28
+ function targetViewForStore(store) {
29
+ return loadMemoryTargetView({
29
30
  cwd: store.ownerDir ?? store.storeRoot,
30
31
  profileId: envProfileId() || null,
31
32
  nodeId: null,
32
33
  }, { quiet: true, includeDescendants: true });
33
- return resolvableCanonicalNames(view.docs);
34
34
  }
35
35
  export const lintLeaf = defineLeaf({
36
36
  name: 'lint',
@@ -48,7 +48,7 @@ export const lintLeaf = defineLeaf({
48
48
  { name: 'stores', type: 'number', required: true, constraint: 'Physical memory stores linted.' },
49
49
  { name: 'profile_projects', type: 'number', required: false, constraint: 'Projects the selected profile manages that were checked for a workspace front door — zero under a scope filter excluding project stores. Absent under --dir.' },
50
50
  { name: 'findings', type: 'object[]', required: true, constraint: 'One row per ERROR: {rule, path, message, name?, scope?, store_root?, paths?}. `rule` names the violated rule (yaml, schema, extension, length, store-namespace, namespace-placement, root-name, root-index-unaddressable, local-name, canonical-collision, dangling-link, memory-read-route, front-door, owner-path-collision). `paths` carries every file sharing a collided identity, read winner first. Empty when green.' },
51
- { name: 'warnings', type: 'object[]', required: true, constraint: 'Same row shape, non-fatal authoring smells: short-preview, broad-memory-read, nested-store-boot, an advisory missing front door, a wildcard memory-read glob matching nothing.' },
51
+ { name: 'warnings', type: 'object[]', required: true, constraint: 'Same row shape, non-fatal authoring smells: short-preview, broad-memory-read, nested-store-boot, an advisory missing front door, a wildcard memory-read glob matching nothing, and orphan (a doc no reachability path routes an agent to — unfiltered target/--dir view only, never under --scope).' },
52
52
  { name: 'notices', type: 'object[]', required: true, constraint: 'Same row shape, inspection data rather than faults: cross-store-candidates (one canonical name held by several stores, winner first) and reference-check-deferred.' },
53
53
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command.' },
54
54
  ],
@@ -60,14 +60,23 @@ export const lintLeaf = defineLeaf({
60
60
  scope: input['scope'],
61
61
  dir: input['dir'],
62
62
  });
63
- const result = selector.store !== null
64
- ? lintMemoryStore(selector.store, {
65
- resolvable: resolvableNamesForStore(selector.store),
63
+ let result;
64
+ if (selector.store !== null) {
65
+ const view = targetViewForStore(selector.store);
66
+ const base = lintMemoryStore(selector.store, {
67
+ resolvable: resolvableCanonicalNames(view.docs),
66
68
  // Asked about directly, not as a profile-managed project: a missing
67
69
  // front door is a gap to report, not a failed gate.
68
70
  frontDoor: 'advisory',
69
- })
70
- : lintMemoryTarget({ ...(selector.scope === undefined ? {} : { scope: selector.scope }) });
71
+ });
72
+ // Reachability is a whole-view property, so orphans are computed over the
73
+ // project's target view but flagged only for this store's own docs.
74
+ const orphans = lintOrphans(view.docs, { flagStoreRoot: selector.store.storeRoot });
75
+ result = { ...base, findings: [...base.findings, ...orphans], warnings: [...base.warnings, ...orphans] };
76
+ }
77
+ else {
78
+ result = lintMemoryTarget({ ...(selector.scope === undefined ? {} : { scope: selector.scope }) });
79
+ }
71
80
  const errors = result.errors.map(findingRow);
72
81
  const warnings = result.warnings.map(findingRow);
73
82
  const notices = result.notices.map(findingRow);
@@ -778,7 +778,7 @@ export const FRONTMATTER_OVERLAY_PARAMS = {
778
778
  '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.' },
779
779
  '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.' },
780
780
  '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.' },
781
- 'lint-ignore': { kind: 'flag', name: 'lint-ignore', type: 'enum', choices: [...SUPPRESSIBLE_RULES], required: false, repeatable: true, constraint: 'Suppress one authoring lint rule for this document. Repeat for a list; valid rules: length, broad-memory-read.' },
781
+ 'lint-ignore': { kind: 'flag', name: 'lint-ignore', type: 'enum', choices: [...SUPPRESSIBLE_RULES], required: false, repeatable: true, constraint: 'Suppress one authoring lint rule for this document. Repeat for a list; valid rules: length, broad-memory-read, orphan.' },
782
782
  '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.' },
783
783
  };
784
784
  /** One overlay flag, with leaf-specific prose appended to the shared
@@ -18,6 +18,6 @@ export declare function assertKind(kind: string): void;
18
18
  * sub-kind (universally available) earns a place in this list. A direct launch
19
19
  * by full sub-kind string is always valid regardless (visibility-only
20
20
  * filtering) — discover the roster for a specific kind via
21
- * `crtr sys prompt-review --list --kind <kind>`. */
21
+ * `crtr sys context prompt-review --list --kind <kind>`. */
22
22
  export declare function kindsStateBlock(): string;
23
23
  export declare const nodeNew: import("../../core/command.js").LeafDef;
@@ -51,7 +51,7 @@ export function assertKind(kind) {
51
51
  * sub-kind (universally available) earns a place in this list. A direct launch
52
52
  * by full sub-kind string is always valid regardless (visibility-only
53
53
  * filtering) — discover the roster for a specific kind via
54
- * `crtr sys prompt-review --list --kind <kind>`. */
54
+ * `crtr sys context prompt-review --list --kind <kind>`. */
55
55
  export function kindsStateBlock() {
56
56
  const registry = readMergedLaunchConfig().kinds;
57
57
  const kinds = Object.keys(registry)
@@ -81,6 +81,7 @@ function nodeNewParams() {
81
81
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
82
82
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
83
83
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
84
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Applies to managed children and roots; roots default to resident.' },
84
85
  { kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
85
86
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
86
87
  {
@@ -179,6 +180,7 @@ async function runNodeCreation(input) {
179
180
  const name = input['name'];
180
181
  const parent = input['parent'];
181
182
  const root = input['root'] === true;
183
+ const resident = input['resident'] === true;
182
184
  const worktreeBaseRaw = input['worktree'];
183
185
  if (worktreeBaseRaw !== undefined && worktreeBaseRaw.trim() === '') {
184
186
  throw new InputError({ error: 'worktree_base_required', message: '--worktree requires a base branch', field: 'worktree', next: 'Pass an existing local branch that landing can fast-forward.' });
@@ -221,6 +223,7 @@ async function runNodeCreation(input) {
221
223
  ...(name !== undefined ? { name } : {}),
222
224
  prompt,
223
225
  ...(root ? { root: true } : { parent: parentId ?? null }),
226
+ ...(resident ? { root_lifecycle: 'resident' } : {}),
224
227
  ...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
225
228
  ...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
226
229
  ...(modelSpec !== undefined ? { model: modelSpec } : {}),
@@ -246,8 +246,8 @@ export const nodePromote = defineLeaf({
246
246
  name: 'node promote',
247
247
  summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
248
248
  params: [
249
- { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
250
- { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, stays wakeable while attached or awaiting something live, and completes after the unattended grace otherwise. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
249
+ { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys context prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
250
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, and unattended grace parks it after a summary turn instead, leaving it revivable fresh against its goal and roadmap. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
251
251
  { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
252
252
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
253
253
  ],
@@ -1,7 +1,4 @@
1
- // `crtr node worktree` — the node-owned managed-worktree lifecycle. Birth is
2
- // `node new --worktree <base>`; this branch holds the single close
3
- // verb, symmetric with birth, so a git worktree's whole lifecycle sits under
4
- // `node`.
1
+ // `crtr node worktree` manages node-owned managed worktrees.
5
2
  import { envNodeId } from '../shared/env.js';
6
3
  import { defineBranch, defineLeaf } from '../core/command.js';
7
4
  import { InputError } from '../core/io.js';
@@ -39,6 +36,39 @@ function mapWorktreeApiError(err) {
39
36
  }
40
37
  throw err;
41
38
  }
39
+ const worktreeAbandon = defineLeaf({
40
+ name: 'abandon',
41
+ description: 'remove a node worktree and preserve any branch work',
42
+ whenToUse: 'a canceled, dead, or otherwise finished node holds work you will not land now: remove its checkout without reviving it, retain its branch work, and release its worktree ownership so finalization or deletion can continue',
43
+ help: {
44
+ name: 'node worktree abandon',
45
+ summary: 'remove a node-managed checkout, retain its work, and commit dirty changes first',
46
+ params: [
47
+ { kind: 'positional', name: 'node', required: true, constraint: 'Node id whose open managed worktree to abandon.' },
48
+ ],
49
+ output: [
50
+ { name: 'node_id', type: 'string', required: true, constraint: 'The node whose worktree was abandoned.' },
51
+ { name: 'branch', type: 'string', required: true, constraint: 'The branch that retains abandoned work, or the deleted empty branch.' },
52
+ { name: 'tip_sha', type: 'string', required: true, constraint: 'The preserved work tip, or the deleted empty branch tip.' },
53
+ { name: 'worktree_path', type: 'string', required: true, constraint: 'The removed managed checkout path.' },
54
+ { name: 'branch_deleted', type: 'boolean', required: true, constraint: 'Whether the empty branch was deleted; false means the branch retains work or cleanup needs manual completion.' },
55
+ ],
56
+ outputKind: 'object',
57
+ effects: ['Removes the checkout. A clean zero-commit worktree deletes its empty branch. Otherwise commits all worktree state to the branch, retains that branch, records abandoned provenance, and repoints the node cwd to the repository root without changing lifecycle status.'],
58
+ },
59
+ run: async (input) => {
60
+ const nodeId = input['node'];
61
+ try {
62
+ return { ...(await cliClient().abandonWorktree(nodeId, { by: envNodeId() ?? 'human' })) };
63
+ }
64
+ catch (err) {
65
+ mapWorktreeApiError(err);
66
+ }
67
+ },
68
+ render: (r) => r['branch_deleted'] === true
69
+ ? `Managed empty worktree abandoned — checkout and empty branch ${String(r['branch'])} removed.`
70
+ : `Managed worktree abandoned — checkout removed; branch ${String(r['branch'])} kept at ${String(r['tip_sha'])}.`,
71
+ });
42
72
  const worktreeClose = defineLeaf({
43
73
  name: 'close',
44
74
  description: 'land and close the current node-managed worktree',
@@ -93,11 +123,12 @@ const worktreeClose = defineLeaf({
93
123
  });
94
124
  export const nodeWorktreeBranch = defineBranch({
95
125
  name: 'worktree',
96
- description: 'manage a node-owned git worktree — close the one born by `node new --worktree <base>`',
97
- whenToUse: 'you are inside a node that was spawned with `node new --worktree <base>` and are ready to land it: rebase it onto its recorded local base branch, fast-forward that base onto it, and close the worktree with cleanup deferred until the node exits. Symmetric with `node new --worktree <base>`, which births it',
126
+ description: 'manage a node-owned git worktree — land it or explicitly abandon it',
127
+ whenToUse: 'you need to dispose of a managed checkout: close lands the current node’s work onto its base; abandon removes any node’s checkout while retaining its work on the branch',
98
128
  help: {
99
129
  name: 'node worktree',
100
- summary: 'manage a node-owned git worktree — birth is `node new --worktree <base>`; this branch closes it',
130
+ summary: 'manage a node-owned git worktree — land it, or explicitly abandon its checkout while retaining work',
131
+ model: 'Close merges work into its recorded base. Abandon removes the checkout without changing lifecycle state; it retains work on the branch and deletes only a clean zero-commit branch.',
101
132
  },
102
- children: [worktreeClose],
133
+ children: [worktreeClose, worktreeAbandon],
103
134
  });
@@ -62,6 +62,7 @@ export const reviveLeaf = defineLeaf({
62
62
  { name: 'candidates', type: 'string', required: false, constraint: 'Preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
63
63
  { name: 'revived', type: 'string', required: false, constraint: 'Sweep: the node ids whose broker engine was relaunched (newline-joined).' },
64
64
  { name: 'failed', type: 'string', required: false, constraint: 'Sweep: node ids whose relaunch threw, with the error (newline-joined). Absent when none failed.' },
65
+ { name: 'frozen', type: 'string', required: false, constraint: 'Sweep: node ids the live-broker cap turned away (newline-joined). Not failures — the daemon launches each as a slot frees. Absent when none were frozen.' },
65
66
  ],
66
67
  outputKind: 'object',
67
68
  effects: [
@@ -164,9 +165,17 @@ async function runReviveAll(input) {
164
165
  };
165
166
  if (res.failed.length > 0) {
166
167
  out['failed'] = res.failed.map((f) => `${f.node_id}: ${f.error}`).join('\n');
168
+ }
169
+ if (res.frozen.length > 0) {
170
+ out['frozen'] = res.frozen.join('\n');
171
+ }
172
+ const total = res.revived.length + res.failed.length + res.frozen.length;
173
+ if (total > res.revived.length) {
167
174
  out['message'] =
168
- `Revived ${res.revived.length} of ${res.revived.length + res.failed.length} disconnected node(s); ` +
169
- `${res.failed.length} failed to relaunch (see failed).`;
175
+ `Revived ${res.revived.length} of ${total} disconnected node(s) (RESUME)` +
176
+ (res.failed.length > 0 ? `; ${res.failed.length} failed to relaunch (see failed)` : '') +
177
+ (res.frozen.length > 0 ? `; ${res.frozen.length} frozen at the broker cap and launched as slots free (see frozen)` : '') +
178
+ '.';
170
179
  }
171
180
  return out;
172
181
  }
@@ -0,0 +1,48 @@
1
+ import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
2
+ import type { Rung, SurfaceEntry } from '../../../../core/substrate/schema.js';
3
+ export interface ActionOutcome {
4
+ ok: boolean;
5
+ message: string;
6
+ /** Canonical name the document answers to after the action — the new name
7
+ * after a move. Absent when the action changed no document. */
8
+ name?: string;
9
+ }
10
+ /** The notice a mutating action shows instead of writing, or null when the
11
+ * record is writable. The dossier marks a refusing record read-only so the
12
+ * refusal is never a surprise. */
13
+ export declare function readOnlyReason(record: DeliveryRecord, profileId: string | null): string | null;
14
+ /** Replace one document's whole surfaces list — refused when the list on disk
15
+ * holds more than the parsed list can carry back. */
16
+ export declare function writeSurfaces(record: DeliveryRecord, profileId: string | null, surfaces: SurfaceEntry[], success: string): Promise<ActionOutcome>;
17
+ /** The body exactly as it sits after the frontmatter fence, which is what the
18
+ * revision path saves back. The PARSED body eats the blank line the fence opens
19
+ * with, so seeding an editor from it would rewrite every doc it touched. */
20
+ export declare function readBody(record: DeliveryRecord): string;
21
+ /** Replace one document's whole body. */
22
+ export declare function writeBody(record: DeliveryRecord, profileId: string | null, body: string): Promise<ActionOutcome>;
23
+ /** Set or clear the document-level gate. An empty predicate clears it. */
24
+ export declare function writeGate(record: DeliveryRecord, profileId: string | null, gate: string): Promise<ActionOutcome>;
25
+ export declare function toggleFlag(record: DeliveryRecord, profileId: string | null, field: 'unlisted' | 'slash'): Promise<ActionOutcome>;
26
+ export declare function writeRoutingLine(record: DeliveryRecord, profileId: string | null, line: string): Promise<ActionOutcome>;
27
+ export declare function writeShortForm(record: DeliveryRecord, profileId: string | null, text: string): Promise<ActionOutcome>;
28
+ /** Rename or relocate a document inside its own store. */
29
+ export declare function moveDoc(record: DeliveryRecord, profileId: string | null, to: string): Promise<ActionOutcome>;
30
+ /** The profile project entry whose ceiling reaches a document's store, or null
31
+ * when the store carries the neutral ceiling because no entry covers it.
32
+ *
33
+ * Mirrors `resolveProjectScopeRoots`: pointers are walked in manifest order
34
+ * and the FIRST entry whose ancestor walk reaches the store's SCOPE root — the
35
+ * `.crouter/` dir one level above the `memory/` store — owns the ceiling, so
36
+ * cycling any other entry would move a number the store never reads. */
37
+ export declare function capOwnerFor(record: DeliveryRecord, profileId: string | null): {
38
+ path: string;
39
+ memory: Rung;
40
+ } | null;
41
+ export interface CapChange {
42
+ path: string;
43
+ memory: Rung;
44
+ }
45
+ /** Move a project relationship's automatic-delivery ceiling. */
46
+ export declare function writeCap(profileId: string, path: string, memory: Rung): ActionOutcome & {
47
+ change?: CapChange;
48
+ };
@@ -0,0 +1,246 @@
1
+ // Every write `crtr sys context admin` can make. Document changes go through
2
+ // the same `memory edit` / `memory move` leaves the CLI runs, always with
3
+ // rationale `editor`; a project relationship's memory ceiling goes through the
4
+ // profile manifest the profiles settings panel writes. Nothing here touches a
5
+ // memory file directly.
6
+ import { dirname } from 'node:path';
7
+ import { editLeaf } from '../../../memory/edit.js';
8
+ import { literalBodySegment } from '../../../memory/shared.js';
9
+ import { moveLeaf } from '../../../memory/move.js';
10
+ import { addProfileProject, loadProfileManifest } from '../../../../core/profiles/manifest.js';
11
+ import { findProjectScopeRoots } from '../../../../core/scope.js';
12
+ import { readText, realpathOrSelf } from '../../../../core/fs-utils.js';
13
+ import { parseSubstrateFrontmatter } from '../../../../core/substrate/schema.js';
14
+ import { surfaceEntryToFlag } from './model.js';
15
+ const RATIONALE = 'editor';
16
+ /** Which store a mutation must land in, or why the page cannot name one.
17
+ *
18
+ * The memory leaves resolve a bare name against the AMBIENT target — this
19
+ * process's cwd, profile and node — while the page plans against a dialled
20
+ * snapshot that may name a different one. Every mutation therefore carries an
21
+ * explicit selector for the store the record was actually read from. A record
22
+ * no selector reaches is READ-ONLY here rather than sent without one: the leaf
23
+ * would resolve the bare name ambiently and rewrite whatever wins it — a
24
+ * same-named override at a writable scope — and report that as success. */
25
+ function storeAddress(record, profileId) {
26
+ if (record.doc.plugin !== undefined) {
27
+ return {
28
+ refusal: `${record.name} belongs to the plugin “${record.doc.plugin}” and is read-only here — override it with \`crtr memory write ${record.name}\`.`,
29
+ };
30
+ }
31
+ if (record.scope === 'builtin') {
32
+ return {
33
+ refusal: `${record.name} is a builtin document and is read-only here — override it with \`crtr memory write ${record.name}\`.`,
34
+ };
35
+ }
36
+ if (record.scope === 'project') {
37
+ const ownerDir = record.source.store.ownerDir;
38
+ return ownerDir === undefined
39
+ ? { refusal: `${record.name} resolves from a project store with no owning directory, which this page cannot address — edit it with \`crtr memory edit ${record.name} --dir <dir>\`.` }
40
+ : { selector: { dir: ownerDir } };
41
+ }
42
+ if (record.scope === 'profile') {
43
+ return { selector: profileId === null ? { scope: 'profile' } : { scope: 'profile', profile: profileId } };
44
+ }
45
+ if (record.scope === 'user' || record.scope === 'node')
46
+ return { selector: { scope: record.scope } };
47
+ return { refusal: `${record.name} resolves from the ${record.scope} scope, which this page cannot address.` };
48
+ }
49
+ /** The notice a mutating action shows instead of writing, or null when the
50
+ * record is writable. The dossier marks a refusing record read-only so the
51
+ * refusal is never a surprise. */
52
+ export function readOnlyReason(record, profileId) {
53
+ const address = storeAddress(record, profileId);
54
+ return 'refusal' in address ? address.refusal : null;
55
+ }
56
+ function failure(error) {
57
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ async function runEdit(record, profileId, request, success) {
60
+ const address = storeAddress(record, profileId);
61
+ if ('refusal' in address)
62
+ return { ok: false, message: address.refusal };
63
+ const surfaces = request.surfaces;
64
+ const unset = [...(request.unset ?? [])];
65
+ if (surfaces !== undefined && surfaces.length === 0 && !unset.includes('surfaces'))
66
+ unset.push('surfaces');
67
+ try {
68
+ await editLeaf.run({
69
+ name: record.name,
70
+ rationale: RATIONALE,
71
+ ...address.selector,
72
+ ...(surfaces !== undefined && surfaces.length > 0 ? { surface: surfaces.map(surfaceEntryToFlag) } : {}),
73
+ ...(request.body === undefined ? {} : { body: request.body }),
74
+ ...(request.gate === undefined ? {} : { gate: request.gate }),
75
+ ...(request.unlisted === undefined ? {} : { unlisted: true }),
76
+ ...(request.slash === undefined ? {} : { slash: true }),
77
+ ...(request.whenAndWhyToRead === undefined ? {} : { whenAndWhyToRead: request.whenAndWhyToRead }),
78
+ ...(request.shortForm === undefined ? {} : { shortForm: request.shortForm }),
79
+ ...(unset.length > 0 ? { unset } : {}),
80
+ });
81
+ return { ok: true, message: success, name: record.name };
82
+ }
83
+ catch (error) {
84
+ return failure(error);
85
+ }
86
+ }
87
+ /** Replace one document's whole surfaces list — refused when the list on disk
88
+ * holds more than the parsed list can carry back. */
89
+ export function writeSurfaces(record, profileId, surfaces, success) {
90
+ const refusal = readOnlyReason(record, profileId) ?? unwritableSurfaces(record);
91
+ if (refusal !== null)
92
+ return Promise.resolve({ ok: false, message: refusal });
93
+ return runEdit(record, profileId, { surfaces }, success);
94
+ }
95
+ /** Why the authored `surfaces` list cannot survive a write, or null when it can.
96
+ *
97
+ * A surface write REPLACES the whole list with the parsed entries, and the
98
+ * runtime parser is tolerant: it drops an entry it cannot read and strips a key
99
+ * it does not know. Writing that list back would delete authored content the
100
+ * page never showed, so an unrepresentable list refuses the edit instead —
101
+ * `memory edit --surface` admits only entries the strict coercion accepts, so
102
+ * carrying the original through verbatim is not available either. */
103
+ function unwritableSurfaces(record) {
104
+ const raw = record.source.frontmatter?.['surfaces'];
105
+ if (raw === undefined || raw === null)
106
+ return null;
107
+ const fix = `a write replaces the whole list, so fix ${record.storePath} first`;
108
+ if (!Array.isArray(raw)) {
109
+ return `The \`surfaces\` field of ${record.name} is not a list of entries — ${fix}.`;
110
+ }
111
+ for (const [index, entry] of raw.entries()) {
112
+ const loss = entryLoss(record.kind, entry);
113
+ if (loss !== null)
114
+ return `Entry ${index + 1} of ${record.name} ${loss} — ${fix}.`;
115
+ }
116
+ return null;
117
+ }
118
+ /** The frontmatter key behind each field a parsed entry carries back. */
119
+ const ENTRY_FIELDS = {
120
+ on: 'on',
121
+ at: 'at',
122
+ match: 'match',
123
+ 'match-frontmatter': 'matchFrontmatter',
124
+ gate: 'gate',
125
+ };
126
+ function entryLoss(kind, raw) {
127
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
128
+ return 'is not an entry object';
129
+ const rec = raw;
130
+ const parsed = parseSubstrateFrontmatter({ kind, surfaces: [rec] })?.surfaces[0];
131
+ if (parsed === undefined)
132
+ return `(on ${JSON.stringify(rec['on'] ?? null)}) is malformed and the parser drops it`;
133
+ for (const key of Object.keys(rec)) {
134
+ if (rec[key] === undefined)
135
+ continue;
136
+ const field = ENTRY_FIELDS[key];
137
+ if (field === undefined)
138
+ return `carries an unknown key \`${key}\``;
139
+ if (parsed[field] === undefined)
140
+ return `carries a \`${key}\` the parser drops`;
141
+ }
142
+ return null;
143
+ }
144
+ /** The body exactly as it sits after the frontmatter fence, which is what the
145
+ * revision path saves back. The PARSED body eats the blank line the fence opens
146
+ * with, so seeding an editor from it would rewrite every doc it touched. */
147
+ export function readBody(record) {
148
+ const segment = literalBodySegment(readText(record.storePath));
149
+ return segment.startsWith('\n') ? segment.slice(1) : segment;
150
+ }
151
+ /** Replace one document's whole body. */
152
+ export function writeBody(record, profileId, body) {
153
+ if (body.trim() === '') {
154
+ return Promise.resolve({
155
+ ok: false,
156
+ message: `An empty body is not an edit \u2014 remove ${record.name} with \`crtr memory delete\` instead.`,
157
+ });
158
+ }
159
+ return runEdit(record, profileId, { body }, `Rewrote the body of ${record.name}.`);
160
+ }
161
+ /** Set or clear the document-level gate. An empty predicate clears it. */
162
+ export function writeGate(record, profileId, gate) {
163
+ const trimmed = gate.trim();
164
+ return trimmed === ''
165
+ ? runEdit(record, profileId, { unset: ['gate'] }, `Cleared the gate on ${record.name}.`)
166
+ : runEdit(record, profileId, { gate: trimmed }, `Gated ${record.name} on ${trimmed}.`);
167
+ }
168
+ export function toggleFlag(record, profileId, field) {
169
+ const on = field === 'unlisted' ? record.doc.unlisted : record.doc.slash;
170
+ if (on)
171
+ return runEdit(record, profileId, { unset: [field] }, `${record.name} is no longer ${field}.`);
172
+ const set = field === 'unlisted' ? { unlisted: true } : { slash: true };
173
+ return runEdit(record, profileId, set, `${record.name} is now ${field}.`);
174
+ }
175
+ export function writeRoutingLine(record, profileId, line) {
176
+ const trimmed = line.trim();
177
+ if (trimmed === '') {
178
+ return Promise.resolve({ ok: false, message: 'A routing line cannot be empty — it is the document\u2019s contract.' });
179
+ }
180
+ return runEdit(record, profileId, { whenAndWhyToRead: trimmed }, `Rewrote the routing line on ${record.name}.`);
181
+ }
182
+ export function writeShortForm(record, profileId, text) {
183
+ const trimmed = text.trim();
184
+ return trimmed === ''
185
+ ? runEdit(record, profileId, { unset: ['short-form'] }, `Cleared the short form on ${record.name}.`)
186
+ : runEdit(record, profileId, { shortForm: trimmed }, `Rewrote the short form on ${record.name}.`);
187
+ }
188
+ /** Rename or relocate a document inside its own store. */
189
+ export async function moveDoc(record, profileId, to) {
190
+ const trimmed = to.trim();
191
+ if (trimmed === '')
192
+ return { ok: false, message: 'Type the new canonical name.' };
193
+ const address = storeAddress(record, profileId);
194
+ if ('refusal' in address)
195
+ return { ok: false, message: address.refusal };
196
+ try {
197
+ const result = (await moveLeaf.run({
198
+ name: record.name,
199
+ to: trimmed,
200
+ ...address.selector,
201
+ }));
202
+ const name = typeof result?.['name'] === 'string' ? result['name'] : trimmed;
203
+ return { ok: true, message: `${record.name} now answers to ${name}.`, name };
204
+ }
205
+ catch (error) {
206
+ return failure(error);
207
+ }
208
+ }
209
+ /** The profile project entry whose ceiling reaches a document's store, or null
210
+ * when the store carries the neutral ceiling because no entry covers it.
211
+ *
212
+ * Mirrors `resolveProjectScopeRoots`: pointers are walked in manifest order
213
+ * and the FIRST entry whose ancestor walk reaches the store's SCOPE root — the
214
+ * `.crouter/` dir one level above the `memory/` store — owns the ceiling, so
215
+ * cycling any other entry would move a number the store never reads. */
216
+ export function capOwnerFor(record, profileId) {
217
+ if (profileId === null || record.scope !== 'project')
218
+ return null;
219
+ // Realpathed on both sides: a manifest path is stored already resolved while
220
+ // a store root is derived from the dialled cwd, so a symlinked ancestor would
221
+ // otherwise read as no entry governing the store.
222
+ const scopeRoot = realpathOrSelf(dirname(record.source.store.storeRoot));
223
+ let manifest;
224
+ try {
225
+ manifest = loadProfileManifest(profileId).manifest;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ for (const project of manifest.projects) {
231
+ if (findProjectScopeRoots(project.path, null).some((root) => realpathOrSelf(root) === scopeRoot)) {
232
+ return { path: project.path, memory: project.memory };
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+ /** Move a project relationship's automatic-delivery ceiling. */
238
+ export function writeCap(profileId, path, memory) {
239
+ try {
240
+ addProfileProject(profileId, path, { memory });
241
+ return { ok: true, message: `${path} discloses up to "${memory}".`, change: { path, memory } };
242
+ }
243
+ catch (error) {
244
+ return failure(error);
245
+ }
246
+ }