@north-light/crouter 0.3.231 → 0.3.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-memory/internal/memory-loading.md +4 -3
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  21. package/dist/clients/attach/render/diagram.js +13 -5
  22. package/dist/clients/attach/render/page-block.d.ts +0 -1
  23. package/dist/clients/attach/render/page-block.js +4 -57
  24. package/dist/clients/attach/viewer.js +570 -563
  25. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  26. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  27. package/dist/clients/inbox/controller.d.ts +10 -0
  28. package/dist/clients/inbox/controller.js +56 -12
  29. package/dist/clients/inbox/tui/input.js +38 -10
  30. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  31. package/dist/clients/inbox/tui/page-body.js +66 -0
  32. package/dist/clients/inbox/tui/panel.js +6 -4
  33. package/dist/clients/inbox/tui/render.js +89 -17
  34. package/dist/clients/inbox/tui/types.d.ts +4 -4
  35. package/dist/commands/__tests__/human.test.js +18 -3
  36. package/dist/commands/__tests__/node-message.test.js +3 -3
  37. package/dist/commands/api-client.js +1 -7
  38. package/dist/commands/canvas-config.js +6 -14
  39. package/dist/commands/canvas-use.js +4 -6
  40. package/dist/commands/cron.js +16 -22
  41. package/dist/commands/human/prompts.d.ts +1 -1
  42. package/dist/commands/human/prompts.js +118 -112
  43. package/dist/commands/human/request.js +13 -14
  44. package/dist/commands/human/review.js +3 -4
  45. package/dist/commands/human/shared.d.ts +6 -0
  46. package/dist/commands/human/shared.js +43 -5
  47. package/dist/commands/human.js +1 -1
  48. package/dist/commands/memory/delete.js +4 -6
  49. package/dist/commands/memory/edit.js +0 -4
  50. package/dist/commands/memory/move.js +3 -5
  51. package/dist/commands/memory/shared.d.ts +1 -1
  52. package/dist/commands/memory/shared.js +11 -7
  53. package/dist/commands/memory/write.js +60 -29
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/node/bash.js +6 -9
  56. package/dist/commands/node/create.js +89 -22
  57. package/dist/commands/node/inspect.js +3 -3
  58. package/dist/commands/node/lifecycle.js +30 -27
  59. package/dist/commands/node/message.js +24 -45
  60. package/dist/commands/node/subscription.js +6 -15
  61. package/dist/commands/node/wait.js +2 -3
  62. package/dist/commands/node-lifecycle-revive.js +1 -12
  63. package/dist/commands/pkg/browse/actions.js +2 -3
  64. package/dist/commands/pkg/market-manage.js +2 -5
  65. package/dist/commands/pkg/plugin-manage.js +7 -8
  66. package/dist/commands/profile/default.js +5 -5
  67. package/dist/commands/profile/delete.js +1 -1
  68. package/dist/commands/profile/env.js +9 -15
  69. package/dist/commands/profile/kind.js +3 -7
  70. package/dist/commands/profile/meta.js +3 -5
  71. package/dist/commands/profile/new.js +0 -6
  72. package/dist/commands/profile/pause.js +4 -8
  73. package/dist/commands/profile/project.js +5 -9
  74. package/dist/commands/profile/rename.js +3 -7
  75. package/dist/commands/profile/show.js +3 -3
  76. package/dist/commands/profile.js +4 -3
  77. package/dist/commands/surface-tmux-spread.js +1 -3
  78. package/dist/commands/sys/config.js +3 -4
  79. package/dist/commands/sys/support/prepare.js +8 -4
  80. package/dist/commands/sys/support/submit.js +2 -3
  81. package/dist/commands/sys/sync-deps.js +1 -9
  82. package/dist/commands/sys/sync-project-guidance.js +1 -7
  83. package/dist/commands/sys/sync-skills.js +1 -11
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  85. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  86. package/dist/core/__tests__/history-inbox.test.js +11 -1
  87. package/dist/core/__tests__/human-deliver.test.js +2 -1
  88. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  89. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  90. package/dist/core/__tests__/lifecycle.test.js +30 -2
  91. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  92. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  93. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  94. package/dist/core/canvas/attention.d.ts +2 -0
  95. package/dist/core/canvas/attention.js +25 -18
  96. package/dist/core/canvas/extensions.d.ts +1 -1
  97. package/dist/core/canvas/extensions.js +7 -1
  98. package/dist/core/canvas/history.js +20 -2
  99. package/dist/core/canvas/types.d.ts +1 -1
  100. package/dist/core/command.js +33 -8
  101. package/dist/core/help.d.ts +28 -2
  102. package/dist/core/help.js +46 -10
  103. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  104. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  105. package/dist/core/human/component-docs.js +4 -4
  106. package/dist/core/human/page-html-markdown.d.ts +8 -0
  107. package/dist/core/human/page-html-markdown.js +260 -0
  108. package/dist/core/memory/lint.d.ts +15 -0
  109. package/dist/core/memory/lint.js +150 -90
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  111. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  112. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  113. package/dist/core/profiles/fuzzy-match.js +92 -0
  114. package/dist/core/profiles/manifest.d.ts +14 -7
  115. package/dist/core/profiles/manifest.js +62 -12
  116. package/dist/core/profiles/select.d.ts +3 -1
  117. package/dist/core/profiles/select.js +5 -3
  118. package/dist/core/profiles/state-block.js +4 -3
  119. package/dist/core/runtime/boot-root.d.ts +3 -2
  120. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  121. package/dist/core/runtime/canvas-extensions.js +8 -1
  122. package/dist/core/runtime/lifecycle.d.ts +11 -2
  123. package/dist/core/runtime/lifecycle.js +15 -2
  124. package/dist/core/runtime/model-selection.d.ts +4 -0
  125. package/dist/core/runtime/model-selection.js +5 -0
  126. package/dist/core/runtime/nodes.js +5 -0
  127. package/dist/core/runtime/reopen.d.ts +6 -0
  128. package/dist/core/runtime/reopen.js +12 -1
  129. package/dist/core/runtime/revive.d.ts +6 -0
  130. package/dist/core/runtime/revive.js +22 -2
  131. package/dist/core/runtime/spawn.d.ts +5 -2
  132. package/dist/core/runtime/spawn.js +18 -32
  133. package/dist/core/runtime/structured-output.d.ts +6 -0
  134. package/dist/core/runtime/structured-output.js +6 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  136. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  137. package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
  138. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
  139. package/dist/core/substrate/frontmatter-validation.js +1 -1
  140. package/dist/core/substrate/injected-store.d.ts +6 -0
  141. package/dist/core/substrate/injected-store.js +24 -0
  142. package/dist/core/substrate/on-read.d.ts +17 -1
  143. package/dist/core/substrate/on-read.js +38 -3
  144. package/dist/core/substrate/schema.d.ts +3 -3
  145. package/dist/core/substrate/schema.js +4 -4
  146. package/dist/core/substrate/surface-match.d.ts +19 -0
  147. package/dist/core/substrate/surface-match.js +224 -12
  148. package/dist/core/termrender/version.d.ts +1 -1
  149. package/dist/core/termrender/version.js +1 -1
  150. package/dist/core/user-settings.js +1 -1
  151. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  152. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  153. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  154. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  155. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  156. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  157. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  158. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  159. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  160. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  161. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  162. package/dist/daemon/api/handlers/canvas.js +10 -0
  163. package/dist/daemon/api/handlers/messages.js +25 -16
  164. package/dist/daemon/api/handlers/nodes.js +5 -0
  165. package/dist/daemon/api/handlers/profiles.js +22 -1
  166. package/dist/daemon/cron-run.js +19 -1
  167. package/dist/daemon/manage.d.ts +16 -1
  168. package/dist/daemon/manage.js +20 -1
  169. package/dist/daemon/park-pending.d.ts +13 -0
  170. package/dist/daemon/park-pending.js +42 -0
  171. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  172. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  173. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  174. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  175. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  176. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  177. package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
  178. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
  179. package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
  180. package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
  181. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  182. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  183. package/dist/shared/generated-context.d.ts +7 -0
  184. package/dist/shared/generated-context.js +11 -0
  185. package/package.json +4 -4
  186. package/runtime.lock.json +2 -2
  187. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -1,5 +1,5 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { loadProfileManifest, renameProfile } from '../../core/profiles/manifest.js';
2
+ import { resolveProfileOperand, renameProfile } from '../../core/profiles/manifest.js';
3
3
  export const renameLeaf = defineLeaf({
4
4
  name: 'rename',
5
5
  description: "change a profile's display name",
@@ -12,7 +12,7 @@ export const renameLeaf = defineLeaf({
12
12
  kind: 'positional',
13
13
  name: 'profile',
14
14
  required: true,
15
- constraint: 'Exact profile id, or a unique manifest name (the CURRENT name, before this rename).',
15
+ constraint: 'Profile id or manifest name (the CURRENT name, before this rename); a close-enough name resolves to the nearest profile, reported on stderr.',
16
16
  },
17
17
  {
18
18
  kind: 'flag',
@@ -24,19 +24,15 @@ export const renameLeaf = defineLeaf({
24
24
  ],
25
25
  output: [
26
26
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id — unchanged by this leaf.' },
27
- { name: 'name', type: 'string', required: true, constraint: 'The new manifest name.' },
28
- { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
29
27
  ],
30
28
  outputKind: 'object',
31
29
  effects: ["Changes the profile manifest's `name` field. Invalidates the process scope cache."],
32
30
  },
33
31
  run: async (input) => {
34
- const { profileId } = loadProfileManifest(input['profile']);
32
+ const { profileId } = resolveProfileOperand(input['profile']);
35
33
  const { manifest } = renameProfile(profileId, input['name']);
36
34
  return {
37
35
  profile_id: profileId,
38
- name: manifest.name,
39
- follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
40
36
  };
41
37
  },
42
38
  });
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { loadProfileManifest, profileRoot, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
4
+ import { resolveProfileOperand, profileRoot, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
5
5
  import { CONFIG_FILE } from '../../types.js';
6
6
  export const showLeaf = defineLeaf({
7
7
  name: 'show',
@@ -15,7 +15,7 @@ export const showLeaf = defineLeaf({
15
15
  kind: 'positional',
16
16
  name: 'profile',
17
17
  required: true,
18
- constraint: 'Exact profile id, or a unique manifest name. An ambiguous name fails listing every matching id.',
18
+ constraint: 'Profile id or manifest name; a close-enough name resolves to the nearest profile, reported on stderr. A name shared by several profiles fails listing every matching id.',
19
19
  },
20
20
  ],
21
21
  output: [
@@ -37,7 +37,7 @@ export const showLeaf = defineLeaf({
37
37
  effects: ['None. Read-only.'],
38
38
  },
39
39
  run: async (input) => {
40
- const { profileId, manifest } = loadProfileManifest(input['profile']);
40
+ const { profileId, manifest } = resolveProfileOperand(input['profile']);
41
41
  const configPath = join(profileRoot(profileId), CONFIG_FILE);
42
42
  return {
43
43
  profile_id: profileId,
@@ -2,8 +2,9 @@
2
2
  // identities defined at the user root, each owning a manifest naming project
3
3
  // directories plus its own memory store.
4
4
  //
5
- // Every manifest verb but `new`/`list` takes <profile> as an exact id OR a
6
- // unique manifest name.
5
+ // Every manifest verb but `new`/`list` takes <profile> as an exact id, a
6
+ // manifest name, or the closest match to one. `delete` is the one exception:
7
+ // an irreversible verb never runs against a guessed target.
7
8
  import { defineBranch } from '../core/command.js';
8
9
  import { newLeaf } from './profile/new.js';
9
10
  import { listLeaf } from './profile/list.js';
@@ -27,7 +28,7 @@ export function registerProfile() {
27
28
  help: {
28
29
  name: 'profile',
29
30
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
30
- model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. `meta` edits the manifest\u2019s readable metadata map \u2014 identity facts each broker under the profile sees as `CRTR_PROFILE_META_<KEY>` env (secrets stay in `env`). Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
31
+ model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. `meta` edits the manifest\u2019s readable metadata map \u2014 identity facts each broker under the profile sees as `CRTR_PROFILE_META_<KEY>` env (secrets stay in `env`). Every manifest verb but `new`/`list` takes <profile> as an exact id, a manifest name, or a close-enough name that resolves to the nearest profile (reported on stderr); a name shared by several profiles, or one equally close to several, fails listing every matching id. `delete` alone never guesses — an irreversible verb requires an exact id or name.',
31
32
  },
32
33
  children: [newLeaf, listLeaf, showLeaf, renameLeaf, pauseLeaf, resumeLeaf, kindLeaf, metaLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
33
34
  });
@@ -92,7 +92,6 @@ export const tmuxSpreadLeaf = defineLeaf({
92
92
  output: [
93
93
  { name: 'window', type: 'string', required: false, constraint: 'The new window id holding the tiled spread.' },
94
94
  { name: 'session', type: 'string', required: false, constraint: 'The session the spread window lives in (the caller\'s session — break-pane keeps it).' },
95
- { name: 'panes', type: 'number', required: true, constraint: 'How many panes the spread window ended with (self + opened siblings).' },
96
95
  { name: 'nodes', type: 'string', required: true, constraint: 'Space-joined ids tiled into the window (self first).' },
97
96
  { name: 'overflow', type: 'number', required: true, constraint: 'Broker-alive siblings beyond the cap that were NOT shown.' },
98
97
  ],
@@ -207,7 +206,6 @@ export const tmuxSpreadLeaf = defineLeaf({
207
206
  return {
208
207
  window,
209
208
  session: session ?? undefined,
210
- panes: spreadIds.length,
211
209
  nodes: spreadIds.join(' '),
212
210
  overflow,
213
211
  };
@@ -215,6 +213,6 @@ export const tmuxSpreadLeaf = defineLeaf({
215
213
  render: (out) => {
216
214
  const o = out;
217
215
  const tail = o.overflow > 0 ? ` (+${o.overflow} more not shown)` : '';
218
- return `spread ${o.panes} pane(s) into window ${o.window ?? '?'}${tail}`;
216
+ return `spread viewers into window ${o.window ?? '?'}${tail}`;
219
217
  },
220
218
  });
@@ -186,8 +186,7 @@ const configSet = defineLeaf({
186
186
  { 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.' },
187
187
  ],
188
188
  output: [
189
- { name: 'key', type: 'string', required: true, constraint: 'Echo of input key.' },
190
- { name: 'value', type: 'unknown', required: true, constraint: 'Value as written.' },
189
+ { name: 'value', type: 'unknown', required: true, constraint: 'Resolved value as written after parsing and setting-specific coercion.' },
191
190
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the value was written to.' },
192
191
  ],
193
192
  outputKind: 'object',
@@ -210,9 +209,9 @@ const configSet = defineLeaf({
210
209
  const cfg = ladder !== null
211
210
  ? (persistModelLadders(requireScopeRoot(scope), ladder), readConfig(scope))
212
211
  : updateConfig(scope, (c) => setNestedValue(c, key, rawValue, parsed));
213
- // Read back the written value for echo
212
+ // Read back the resolved value that was written
214
213
  const written = getNestedValue(cfg, key);
215
- return { key, value: written, scope };
214
+ return { value: written, scope };
216
215
  },
217
216
  });
218
217
  const configPath = defineLeaf({
@@ -212,10 +212,8 @@ export const supportPrepareLeaf = defineLeaf({
212
212
  },
213
213
  ],
214
214
  output: [
215
- { name: 'status', type: 'string', required: true, constraint: 'Always "prepared" on success; every failure throws.' },
216
- { name: 'reference', type: 'string', required: true, constraint: 'YYYYMMDDTHHmmssSSSZ-xxxxxxxx — pass this to `crtr sys support submit`.' },
215
+ { name: 'reference', type: 'string', required: true, constraint: 'Generated YYYYMMDDTHHmmssSSSZ-xxxxxxxx — pass this to `crtr sys support submit`.' },
217
216
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the new ~/.crouter/support/<reference>/ directory.' },
218
- { name: 'node_id', type: 'string', required: true, constraint: 'The node the bundle was collected for.' },
219
217
  { name: 'source_count', type: 'number', required: true, constraint: 'Number of distinct sources declared in the manifest.' },
220
218
  { name: 'bytes', type: 'number', required: true, constraint: 'Combined size of manifest.json + bundle.jsonl, always \u2264 2,097,152.' },
221
219
  ],
@@ -230,6 +228,12 @@ export const supportPrepareLeaf = defineLeaf({
230
228
  const nodeId = input['node_id'];
231
229
  if (nodeId === undefined || nodeId === '')
232
230
  throw usage('node_id is required. Run `crtr sys support prepare -h`.');
233
- return prepareSupportBundle(nodeId);
231
+ const result = prepareSupportBundle(nodeId);
232
+ return {
233
+ reference: result.reference,
234
+ path: result.path,
235
+ source_count: result.source_count,
236
+ bytes: result.bytes,
237
+ };
234
238
  },
235
239
  });
@@ -504,8 +504,6 @@ export const supportSubmitLeaf = defineLeaf({
504
504
  },
505
505
  ],
506
506
  output: [
507
- { name: 'status', type: 'string', required: true, constraint: 'Always "submitted" on success; every failure throws.' },
508
- { name: 'reference', type: 'string', required: true, constraint: 'The submitted reference.' },
509
507
  { name: 'repository', type: 'string', required: true, constraint: 'Canonical owner/name of the private destination.' },
510
508
  { name: 'url', type: 'string', required: true, constraint: 'Canonical github.com URL of support/<reference>/.' },
511
509
  ],
@@ -520,6 +518,7 @@ export const supportSubmitLeaf = defineLeaf({
520
518
  const reference = input.reference;
521
519
  if (reference === undefined || reference === '')
522
520
  throw usage('reference is required. Run `crtr sys support submit -h`.');
523
- return submitPreparedBundle(reference, process.env, defaultGhRunner);
521
+ const result = await submitPreparedBundle(reference, process.env, defaultGhRunner);
522
+ return { repository: result.repository, url: result.url };
524
523
  },
525
524
  });
@@ -481,10 +481,6 @@ export const sysSyncDepsLeaf = defineLeaf({
481
481
  { kind: 'flag', name: 'dry-run', type: 'bool', required: false, constraint: 'Report what would be vendored without writing anything.' },
482
482
  ],
483
483
  output: [
484
- { name: 'imported', type: 'number', required: true, constraint: 'Count of memory docs written (bundle INDEX.md, sidecar docs, and the catalog INDEX.md).' },
485
- { name: 'wouldImport', type: 'number', required: true, constraint: 'Count of memory docs that would be written under --dry-run.' },
486
- { name: 'skipped', type: 'number', required: true, constraint: 'Count of same-named bundle collisions skipped (first-sorted package wins).' },
487
- { name: 'excluded', type: 'number', required: true, constraint: 'Count of dependency skill bundles excluded by exact package.json crtr.sync.deps.exclude selectors.' },
488
484
  { name: 'results', type: 'object[]', required: true, constraint: 'Each: {source,target,name,scope,status,reason?}; status is one of imported, would-import, skipped, excluded. `name` is the full canonical name the vendored doc answers at (`<namespace>/deps/<bundle>`), except on an excluded row, which names the `<package>@<version>:<skill>` selector. An excluded row is a dependency bundle rejected before its files enter the vendoring planner.' },
489
485
  ],
490
486
  outputKind: 'object',
@@ -498,11 +494,7 @@ export const sysSyncDepsLeaf = defineLeaf({
498
494
  const sourceArg = input['source'];
499
495
  const dryRun = input['dryRun'] ?? false;
500
496
  const results = runDepsSync({ sourceArg, dryRun });
501
- const imported = results.filter((r) => r.status === 'imported').length;
502
- const wouldImport = results.filter((r) => r.status === 'would-import').length;
503
- const skipped = results.filter((r) => r.status === 'skipped').length;
504
- const excluded = results.filter((r) => r.status === 'excluded').length;
505
- return { imported, wouldImport, skipped, excluded, results };
497
+ return { results };
506
498
  },
507
499
  render: (result) => renderDepsSummary(result.results),
508
500
  });
@@ -379,9 +379,6 @@ export const sysSyncProjectGuidanceLeaf = defineLeaf({
379
379
  { kind: 'flag', name: 'overwrite', type: 'bool', required: false, constraint: 'Replace an existing target memory doc. Default: skip existing docs to avoid clobbering user-authored memory.' },
380
380
  ],
381
381
  output: [
382
- { name: 'imported', type: 'number', required: true, constraint: 'Count of memory docs written.' },
383
- { name: 'wouldImport', type: 'number', required: true, constraint: 'Count of memory docs that would be written under --dry-run.' },
384
- { name: 'skipped', type: 'number', required: true, constraint: 'Count of source files skipped (existing target without --overwrite).' },
385
382
  { name: 'results', type: 'object[]', required: true, constraint: 'Each: {source,target,name,scope,status,reason?}; status is one of imported, skipped, would-import.' },
386
383
  ],
387
384
  outputKind: 'object',
@@ -477,10 +474,7 @@ export const sysSyncProjectGuidanceLeaf = defineLeaf({
477
474
  for (const { prepared } of ordered) {
478
475
  results.push(convertProjectDoc(prepared, { dryRun, overwrite }));
479
476
  }
480
- const imported = results.filter((r) => r.status === 'imported').length;
481
- const wouldImport = results.filter((r) => r.status === 'would-import').length;
482
- const skipped = results.filter((r) => r.status === 'skipped').length;
483
- return { imported, wouldImport, skipped, results };
477
+ return { results };
484
478
  },
485
479
  render: (result) => renderSummary(result.results),
486
480
  });
@@ -250,11 +250,6 @@ export const sysSyncSkillsLeaf = defineLeaf({
250
250
  { kind: 'flag', name: 'include-ignored', type: 'bool', required: false, constraint: 'Include ignored candidates in the report without making sync read-only. Pair with --dry-run to audit the full candidate set without importing non-ignored skills.' },
251
251
  ],
252
252
  output: [
253
- { name: 'imported', type: 'number', required: true, constraint: 'Count of memory docs written.' },
254
- { name: 'wouldImport', type: 'number', required: true, constraint: 'Count of memory docs that would be written under --dry-run.' },
255
- { name: 'skipped', type: 'number', required: true, constraint: 'Count of source files skipped (existing target without --overwrite, or an unimportable SKILL.md).' },
256
- { name: 'ignored', type: 'number', required: true, constraint: 'Count of selected SKILL.md files that are permanently ignored.' },
257
- { name: 'wouldIgnore', type: 'number', required: true, constraint: 'Count of selected SKILL.md files that would be ignored under --dry-run.' },
258
253
  { name: 'results', type: 'object[]', required: true, constraint: 'Each: {source,target,name,scope,status,reason?}; status is one of imported, skipped, would-import, ignored, would-ignore. `name` is the full canonical name the imported doc answers at — a user import’s bundle name, a project import’s `<namespace>/<bundle name>`.' },
259
254
  ],
260
255
  outputKind: 'object',
@@ -310,12 +305,7 @@ export const sysSyncSkillsLeaf = defineLeaf({
310
305
  }
311
306
  if (changedIgnoreState)
312
307
  writeIgnoreState(ignoreState);
313
- const imported = results.filter((r) => r.status === 'imported').length;
314
- const wouldImport = results.filter((r) => r.status === 'would-import').length;
315
- const skipped = results.filter((r) => r.status === 'skipped').length;
316
- const ignored = results.filter((r) => r.status === 'ignored').length;
317
- const wouldIgnore = results.filter((r) => r.status === 'would-ignore').length;
318
- return { imported, wouldImport, skipped, ignored, wouldIgnore, results, ignoreFile: ignoreFilePath(), ignoreMode };
308
+ return { results, ignoreFile: ignoreFilePath(), ignoreMode };
319
309
  },
320
310
  render: (result) => renderSummary(result.results, result.ignoreMode === true),
321
311
  });
@@ -0,0 +1,147 @@
1
+ // Run with: npm test -- --test-name-pattern='cron node sink'
2
+ //
3
+ // A resident root parked by the unattended clock is status=done without a
4
+ // final-report latch. Its anchored cron is the explicit next cycle that must
5
+ // append durably and resume the existing session; finalized and canceled rows
6
+ // remain ordinary undeliverable node sinks.
7
+ import { after, afterEach, before, beforeEach, test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { createNode, getNode, listNodes } from '../canvas/canvas.js';
13
+ import { armCron, getCron } from '../canvas/crons.js';
14
+ import { closeDb } from '../canvas/db.js';
15
+ import { readInboxSince } from '../feed/inbox.js';
16
+ import { headlessBrokerHost } from '../runtime/host.js';
17
+ import { resetFleetForTests } from '../runtime/fleet.js';
18
+ import { applyParkEvent, parkEvent } from '../../daemon/reconcilers/live-obligation.js';
19
+ import { executeCron } from '../../daemon/cron-run.js';
20
+ import { bindTestFleet } from './helpers/fleet.js';
21
+ let home;
22
+ let priorHome;
23
+ let fleet;
24
+ let launches;
25
+ let originalLaunch;
26
+ function node(id, over = {}) {
27
+ return {
28
+ node_id: id,
29
+ name: id,
30
+ created: new Date().toISOString(),
31
+ cwd: home,
32
+ kind: 'general',
33
+ mode: 'base',
34
+ lifecycle: 'resident',
35
+ status: 'active',
36
+ ...over,
37
+ };
38
+ }
39
+ function arm({ cron_id, ...overrides }) {
40
+ armCron({
41
+ cron_id,
42
+ name: cron_id,
43
+ created_by: null,
44
+ command: "printf 'cron output'",
45
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
46
+ recur: JSON.stringify({ every: '1h' }),
47
+ tz: null,
48
+ expires_at: null,
49
+ anchor_node: null,
50
+ cancel_on_wake: false,
51
+ cwd: home,
52
+ env_json: null,
53
+ profile: null,
54
+ scope: 'profile',
55
+ run_timeout_s: 30,
56
+ overlap: 'skip',
57
+ on_output: 'always',
58
+ sink: '{}',
59
+ tier: 'normal',
60
+ ...overrides,
61
+ });
62
+ }
63
+ /** Produce the exact unattended resident-root shape: `park` writes done with
64
+ * the park marker and leaves the final-report latch empty. A real 15-minute
65
+ * clock reaches this same transition, through a summary turn on the good path
66
+ * and directly on the degraded one. */
67
+ function parkResidentRoot(id) {
68
+ const sessionFile = join(home, `${id}.jsonl`);
69
+ writeFileSync(sessionFile, '{}\n');
70
+ createNode(node(id, { pi_session_id: `${id}-session`, pi_session_file: sessionFile }));
71
+ const event = parkEvent(id, 'resident');
72
+ assert.equal(event, 'park');
73
+ applyParkEvent(id, event);
74
+ const parked = getNode(id);
75
+ assert.equal(parked.status, 'done');
76
+ assert.equal(parked.intent, 'parked');
77
+ assert.equal(parked.final_report ?? null, null);
78
+ }
79
+ before(() => {
80
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-node-sink-parked-root-'));
81
+ priorHome = process.env['CRTR_HOME'];
82
+ process.env['CRTR_HOME'] = home;
83
+ });
84
+ beforeEach(() => {
85
+ closeDb();
86
+ rmSync(home, { recursive: true, force: true });
87
+ mkdirSync(home, { recursive: true });
88
+ resetFleetForTests();
89
+ fleet = bindTestFleet();
90
+ launches = [];
91
+ originalLaunch = headlessBrokerHost.launch;
92
+ headlessBrokerHost.launch = ((nodeId, _inv, opts) => {
93
+ launches.push({ nodeId, resuming: opts.resuming });
94
+ const handle = { pid: 0x7ffffffe, exited: new Promise(() => { }) };
95
+ fleet.fleet.register(nodeId, handle);
96
+ return handle;
97
+ });
98
+ });
99
+ afterEach(() => {
100
+ headlessBrokerHost.launch = originalLaunch;
101
+ closeDb();
102
+ resetFleetForTests();
103
+ });
104
+ after(() => {
105
+ rmSync(home, { recursive: true, force: true });
106
+ if (priorHome === undefined)
107
+ delete process.env['CRTR_HOME'];
108
+ else
109
+ process.env['CRTR_HOME'] = priorHome;
110
+ });
111
+ test('cron node sink delivers to a parked resident root and resumes its existing session without escalation', async () => {
112
+ parkResidentRoot('parked-root');
113
+ arm({ cron_id: 'parked-root-cron', sink: JSON.stringify({ node: 'parked-root' }), anchor_node: 'parked-root' });
114
+ const record = await executeCron(getCron('parked-root-cron'), { outOfBand: false });
115
+ assert.equal(record?.delivered, 'delivered: node parked-root');
116
+ assert.equal(getCron('parked-root-cron').state, 'active', 'delivery does not pause the cron');
117
+ assert.deepEqual(readInboxSince('parked-root', undefined).map((entry) => entry.data?.['body']), ['cron output']);
118
+ assert.deepEqual(launches, [{ nodeId: 'parked-root', resuming: true }], 'the parked session is resumed exactly once');
119
+ assert.equal(getNode('parked-root').status, 'active', 'revive restores the parked root to its active cycle');
120
+ assert.deepEqual(listNodes().map((entry) => entry.node_id), ['parked-root'], 'no escalation node was created');
121
+ });
122
+ test('finalized and canceled node-sink targets still pause and escalate without delivery', async () => {
123
+ const targets = [
124
+ ['finalized-root', { status: 'done', final_report: 'final.md', finalized_at: new Date().toISOString() }],
125
+ ['canceled-root', { status: 'canceled' }],
126
+ ];
127
+ for (const [id, state] of targets) {
128
+ createNode(node(id, state));
129
+ arm({ cron_id: `${id}-cron`, sink: JSON.stringify({ node: id }) });
130
+ const before = listNodes().length;
131
+ const record = await executeCron(getCron(`${id}-cron`), { outOfBand: false });
132
+ assert.match(record?.delivered ?? '', /^failure: cron paused, escalation node /, `${id}: failure is escalated`);
133
+ assert.equal(getCron(`${id}-cron`).state, 'paused', `${id}: cron is paused`);
134
+ assert.equal(readInboxSince(id, undefined).length, 0, `${id}: no message was appended`);
135
+ assert.equal(listNodes().length, before + 1, `${id}: exactly one escalation node was created`);
136
+ }
137
+ });
138
+ test('a deferred node-sink entry reaches the parked root durably but never revives it', async () => {
139
+ parkResidentRoot('deferred-root');
140
+ arm({ cron_id: 'deferred-root-cron', sink: JSON.stringify({ node: 'deferred-root' }), tier: 'deferred' });
141
+ const record = await executeCron(getCron('deferred-root-cron'), { outOfBand: false });
142
+ assert.equal(record?.delivered, 'delivered: node deferred-root');
143
+ assert.equal(getCron('deferred-root-cron').state, 'active');
144
+ assert.deepEqual(readInboxSince('deferred-root', undefined).map((entry) => entry.tier), ['deferred']);
145
+ assert.deepEqual(launches, [], 'deferred delivery never launches a dormant target');
146
+ assert.equal(getNode('deferred-root').status, 'done', 'the parked target remains parked');
147
+ });
@@ -13,7 +13,7 @@ import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
14
  import { contextDir } from '../canvas/paths.js';
15
15
  import { appendInbox } from '../feed/inbox.js';
16
- import { buildCorpus, resolveRef } from '../canvas/history.js';
16
+ import { buildCorpus, nodeArtifacts, resolveRef } from '../canvas/history.js';
17
17
  let home;
18
18
  function node(id, cwd) {
19
19
  return {
@@ -49,6 +49,16 @@ test('history corpus skips malformed frontmatter in another cwd', () => {
49
49
  assert.equal(corpus.some((a) => a.ref === `${broken.node_id}:context/copied-source.md`), false, 'invalid artifact is skipped');
50
50
  assert.ok(corpus.some((a) => a.loadBody().includes('0a0afd613')), 'valid artifacts remain searchable');
51
51
  });
52
+ test('one node\'s artifact listing skips malformed frontmatter instead of failing', () => {
53
+ const n = createNode(node('single-node-broken', '/tmp/hist-single'));
54
+ // A context dir often holds foreign markdown (a checked-out repo) whose
55
+ // `---` header is not crouter frontmatter.
56
+ writeFileSync(join(contextDir(n.node_id), 'copied-source.md'), '---\npattern: "@(IsString)\\("\n---\nsource text\n');
57
+ writeFileSync(join(contextDir(n.node_id), 'result.md'), '---\ntitle: result\n---\n0a0afd613\n');
58
+ const arts = nodeArtifacts(n.node_id);
59
+ assert.equal(arts.some((a) => a.relpath === 'context/copied-source.md'), false, 'invalid artifact is skipped');
60
+ assert.ok(arts.some((a) => a.relpath === 'context/result.md'), 'valid artifacts still listed');
61
+ });
52
62
  test('default history excludes inbox; --type inbox includes it, with full sender-attributed bodies', () => {
53
63
  const cwd = '/tmp/hist-work';
54
64
  createNode(node('sender-1', cwd));
@@ -9,7 +9,8 @@ import { join } from 'node:path';
9
9
  import { ApiError } from '../../api/index.js';
10
10
  import { createApiServer } from '../../daemon/api/server.js';
11
11
  import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket, settleFeedbackCompanion } from '../../daemon/human/finish.js';
12
- import { humanSend } from '../../commands/human/prompts.js';
12
+ import { humanSend as buildHumanSend } from '../../commands/human/prompts.js';
13
+ const humanSend = buildHumanSend();
13
14
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
14
15
  import { coalesce, readInboxSince } from '../feed/inbox.js';
15
16
  import { createPageFeedbackComment } from '../human/feedback.js';
@@ -739,7 +739,6 @@ describe('memory corpus gate across the plugin lifecycle', () => {
739
739
  // The package gains an old-format document while it sits disabled.
740
740
  writeDoc(join(source, 'memory', 'guide', 'INDEX.md'), 'kind: knowledge\nname: guide/INDEX', 'Guide.');
741
741
  const enabled = await pluginEnable.run({ name: 'lifecycle' });
742
- assert.equal(enabled['enabled'], true);
743
742
  const memory = enabled['memory'];
744
743
  assert.deepEqual(memory.migrated, ['guide/INDEX.md']);
745
744
  assert.equal(memory.linkedSource, realpathSync(join(source, 'memory')));
@@ -160,7 +160,6 @@ test('node message send --tier deferred to an ordinary dormant (idle-released) t
160
160
  });
161
161
  try {
162
162
  const res = (await messageSendLeaf().run({ to: id, tier: 'deferred', body: 'a held note' }));
163
- assert.equal(res['delivered'], true, 'the message is still delivered (appended), just not woken');
164
163
  assert.equal(res['revived'], false, 'deferred never revives a dormant target');
165
164
  assert.equal(res['guidance'], "Delivered — held for the target's next natural cycle; not revived.", 'guidance is truthful about the hold, not a claimed revive');
166
165
  }
@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { createNode, getNode, setStatus, setIntent } from '../canvas/canvas.js';
14
14
  import { closeDb } from '../canvas/db.js';
15
- import { transition } from '../runtime/lifecycle.js';
15
+ import { isParked, transition } from '../runtime/lifecycle.js';
16
16
  let home;
17
17
  function node(id) {
18
18
  return {
@@ -67,6 +67,34 @@ test('finalize: illegal from done|dead|canceled → throws, status untouched', (
67
67
  }
68
68
  });
69
69
  // ---------------------------------------------------------------------------
70
+ // park → done + intent='parked', legal only from active|idle. The intent IS the
71
+ // durable park marker: it must land atomically with the `done` it qualifies,
72
+ // read only through isParked, and be cleared by revival.
73
+ // ---------------------------------------------------------------------------
74
+ test('park: active|idle → done + intent=parked, and isParked reads exactly that pair', () => {
75
+ for (const from of LIVE) {
76
+ mk(`n_${from}`, from);
77
+ const m = transition(`n_${from}`, 'park');
78
+ assert.equal(m.status, 'done', `park from ${from}`);
79
+ assert.equal(m.intent, 'parked', `park from ${from} sets intent=parked`);
80
+ assert.equal(isParked(m), true, `park from ${from} is readable as parked`);
81
+ assert.equal(m.final_report ?? null, null, `park from ${from} leaves the finalization latch unset`);
82
+ }
83
+ });
84
+ test('park: illegal from done|dead|canceled → throws', () => {
85
+ for (const from of TERMINAL) {
86
+ mk(`n_${from}`, from);
87
+ assert.throws(() => transition(`n_${from}`, 'park'), /illegal lifecycle transition/);
88
+ }
89
+ });
90
+ test('park: revival clears the marker, and no other done is parked', () => {
91
+ mk('reopened', 'active');
92
+ transition('reopened', 'park');
93
+ assert.equal(isParked(transition('reopened', 'revive')), false, 'revive clears intent=parked');
94
+ mk('finished', 'active');
95
+ assert.equal(isParked(transition('finished', 'finish')), false, 'a finished done is not a park');
96
+ });
97
+ // ---------------------------------------------------------------------------
70
98
  // cancel → canceled + intent cleared, legal from ANY status (forced teardown).
71
99
  // A5 (human-confirmed 2026-06-06): the `reap` event was COLLAPSED into `cancel`
72
100
  // — they were identical (status differed only; both intent=null, from=ANY, no
@@ -159,7 +187,7 @@ test('revive: any status → active + intent cleared', () => {
159
187
  // unknown node → throws for every event
160
188
  // ---------------------------------------------------------------------------
161
189
  test('transition on an unknown node throws', () => {
162
- for (const ev of ['finish', 'cancel', 'crash', 'yield', 'release', 'refresh-launch', 'revive']) {
190
+ for (const ev of ['finish', 'park', 'cancel', 'crash', 'yield', 'release', 'refresh-launch', 'revive']) {
163
191
  assert.throws(() => transition('ghost', ev), /unknown node/);
164
192
  }
165
193
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-parked-fresh.test.ts
2
+ //
3
+ // Re-entering a parked node starts a FRESH cycle grounded in goal + roadmap
4
+ // rather than replaying the transcript its parking turn already concluded. The
5
+ // rule is a property of the node, evaluated once inside reviveNode, so every
6
+ // doorway gets it from one strict-resume veto. Its one exception is a park with
7
+ // nothing on disk to stand on (reachable only on the degraded path, which
8
+ // writes no summary): resuming an old transcript beats waking amnesiac.
9
+ import { after, afterEach, before, beforeEach, test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { createNode, getNode } from '../canvas/canvas.js';
15
+ import { contextDir } from '../canvas/paths.js';
16
+ import { closeDb } from '../canvas/db.js';
17
+ import { transition } from '../runtime/lifecycle.js';
18
+ import { reviveNode } from '../runtime/revive.js';
19
+ import { headlessBrokerHost } from '../runtime/host.js';
20
+ import { resetFleetForTests } from '../runtime/fleet.js';
21
+ import { bindTestFleet } from './helpers/fleet.js';
22
+ let home;
23
+ let priorHome;
24
+ let fleet;
25
+ let launches;
26
+ let originalLaunch;
27
+ /** A parked resident with a real `.jsonl` on disk, so the choice between
28
+ * resuming that transcript and cycling it is genuinely open. */
29
+ function parkResident(id, ground = {}) {
30
+ const sessionFile = join(home, `${id}.jsonl`);
31
+ writeFileSync(sessionFile, '{}\n');
32
+ const meta = {
33
+ node_id: id,
34
+ name: id,
35
+ created: new Date().toISOString(),
36
+ cwd: home,
37
+ kind: 'general',
38
+ mode: 'base',
39
+ lifecycle: 'resident',
40
+ status: 'active',
41
+ pi_session_id: `${id}-session`,
42
+ pi_session_file: sessionFile,
43
+ };
44
+ createNode(meta);
45
+ mkdirSync(contextDir(id), { recursive: true });
46
+ if (ground.roadmap !== undefined)
47
+ writeFileSync(join(contextDir(id), 'roadmap.md'), ground.roadmap);
48
+ if (ground.goal !== undefined)
49
+ writeFileSync(join(contextDir(id), 'initial-prompt.md'), ground.goal);
50
+ transition(id, 'park');
51
+ }
52
+ before(() => {
53
+ home = mkdtempSync(join(tmpdir(), 'crtr-revive-parked-'));
54
+ priorHome = process.env['CRTR_HOME'];
55
+ process.env['CRTR_HOME'] = home;
56
+ });
57
+ beforeEach(() => {
58
+ closeDb();
59
+ rmSync(home, { recursive: true, force: true });
60
+ mkdirSync(home, { recursive: true });
61
+ resetFleetForTests();
62
+ fleet = bindTestFleet();
63
+ launches = [];
64
+ originalLaunch = headlessBrokerHost.launch;
65
+ headlessBrokerHost.launch = ((nodeId, _inv, opts) => {
66
+ launches.push({ nodeId, resuming: opts.resuming });
67
+ const handle = { pid: 0x7ffffffe, exited: new Promise(() => { }) };
68
+ fleet.fleet.register(nodeId, handle);
69
+ return handle;
70
+ });
71
+ });
72
+ afterEach(() => {
73
+ headlessBrokerHost.launch = originalLaunch;
74
+ closeDb();
75
+ resetFleetForTests();
76
+ });
77
+ after(() => {
78
+ rmSync(home, { recursive: true, force: true });
79
+ if (priorHome === undefined)
80
+ delete process.env['CRTR_HOME'];
81
+ else
82
+ process.env['CRTR_HOME'] = priorHome;
83
+ });
84
+ test('a parked node with a roadmap re-enters fresh even when the caller asked to resume, and the marker clears', () => {
85
+ parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
86
+ const result = reviveNode('parked', { resume: true });
87
+ assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
88
+ assert.deepEqual(launches, [{ nodeId: 'parked', resuming: false }]);
89
+ const reopened = getNode('parked');
90
+ assert.equal(reopened.status, 'active');
91
+ assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
92
+ assert.equal(reopened.cycle_pending, true, 'the fresh cycle is provisional until session_start confirms it');
93
+ });
94
+ test('a parked node grounded only by a goal still re-enters fresh', () => {
95
+ parkResident('goal-only', { goal: 'Watch the deploy lane.' });
96
+ assert.equal(reviveNode('goal-only', { resume: true }).resumed, false);
97
+ });
98
+ test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
99
+ parkResident('bare');
100
+ const result = reviveNode('bare', { resume: true });
101
+ assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
102
+ assert.deepEqual(launches, [{ nodeId: 'bare', resuming: true }]);
103
+ });
104
+ test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', () => {
105
+ parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
106
+ transition('finished', 'revive');
107
+ transition('finished', 'finish');
108
+ assert.equal(reviveNode('finished', { resume: true }).resumed, true);
109
+ });