@north-light/crouter 0.3.230 → 0.3.232

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 (173) 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-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -133,7 +133,7 @@ test('an already idle resident with no live obligation reconciles into completed
133
133
  subscribe(root, nodeId, true);
134
134
  await h.tick();
135
135
  assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
136
- assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
136
+ assert.equal(h.node(nodeId)?.intent, 'parked', 'the reconciled row carries the durable park marker (isParked)');
137
137
  const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
138
138
  assert.ok(wake, 'the subscribing root learns that the parked resident completed without a final report');
139
139
  });
@@ -141,8 +141,19 @@ test('an unattended resident with no live obligation completes on the daemon clo
141
141
  // The park clock is read per supervision pass (unattendedParkMsForDaemon), so
142
142
  // it is armed for THIS test only — a file-global 1500ms park clock would also
143
143
  // apply to the idle-release manager above and race its own release path.
144
+ //
145
+ // The clock no longer terminalizes directly: it arms a pending park and asks
146
+ // the live engine for one summary turn, and broker SETTLEMENT enacts the park
147
+ // on the good path. That settlement runs in the harness's separate api-server
148
+ // process, where the (deliberately process-local) pending park does not
149
+ // exist — so this seam pins the DEGRADED path: the summary grace expires on
150
+ // the supervision clock and the daemon parks without a report, fanning the
151
+ // doctrine wake. The settlement good path is covered by the in-process
152
+ // broker-settle tests.
144
153
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
154
+ const priorGraceMs = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
145
155
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
156
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = '1000';
146
157
  try {
147
158
  const nodeId = h.fabricateBrokerNode({
148
159
  id: 'settled-no-work',
@@ -167,15 +178,20 @@ test('an unattended resident with no live obligation completes on the daemon clo
167
178
  await h.tick(seenAt);
168
179
  await h.tick(seenAt + 1499);
169
180
  assert.equal(h.fleet.has(nodeId), true, 'the daemon keeps the broker through the full grace');
181
+ // Clock expiry: arms the pending park and requests the summary turn.
170
182
  await h.tick(seenAt + 1500);
183
+ // Summary grace expiry: the degraded enactment parks without a report.
184
+ // (If the summary turn's settlement already released the broker in the
185
+ // api-server process, the dormant-inbox reconciler applies the same park.)
186
+ await h.tick(seenAt + 1500 + 1000);
171
187
  await h.waitFor(() => {
172
188
  const node = h.node(nodeId);
173
- return node?.status === 'done' && node.intent === 'done' && !isPidAlive(boot.pid);
174
- }, { timeoutMs: 10_000, intervalMs: 20, label: 'unattended completion and broker exit' });
189
+ return node?.status === 'done' && node.intent === 'parked' && !isPidAlive(boot.pid);
190
+ }, { timeoutMs: 10_000, intervalMs: 20, label: 'unattended park and broker exit' });
175
191
  // The pid vanishing is not the fleet learning about it: wait for the child
176
192
  // 'exit' delivery + its policy job, which is what actually frees the slot.
177
193
  await h.awaitFleetExit(nodeId);
178
- assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
194
+ assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the parked broker');
179
195
  assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
180
196
  const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
181
197
  assert.ok(wake, 'the subscribing root is woken when the unattended clock completes the resident');
@@ -188,11 +204,17 @@ test('an unattended resident with no live obligation completes on the daemon clo
188
204
  delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
189
205
  else
190
206
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
207
+ if (priorGraceMs === undefined)
208
+ delete process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
209
+ else
210
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = priorGraceMs;
191
211
  }
192
212
  });
193
213
  test('a non-attending observer does not prevent unattended resident parking', { timeout: 30_000 }, async () => {
194
214
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
215
+ const priorGraceMs = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
195
216
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
217
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = '1000';
196
218
  const nodeId = h.fabricateBrokerNode({
197
219
  id: 'settled-with-system-observer',
198
220
  parent: root,
@@ -209,10 +231,11 @@ test('a non-attending observer does not prevent unattended resident parking', {
209
231
  const seenAt = 12_000;
210
232
  await h.tick(seenAt);
211
233
  await h.tick(seenAt + 1500);
234
+ await h.tick(seenAt + 1500 + 1000);
212
235
  await h.waitFor(() => h.node(nodeId)?.status === 'done' && !isPidAlive(boot.pid), {
213
236
  timeoutMs: 10_000,
214
237
  intervalMs: 20,
215
- label: 'system-observed unattended completion and broker exit',
238
+ label: 'system-observed unattended park and broker exit',
216
239
  });
217
240
  await h.awaitFleetExit(nodeId);
218
241
  }
@@ -221,6 +244,10 @@ test('a non-attending observer does not prevent unattended resident parking', {
221
244
  delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
222
245
  else
223
246
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
247
+ if (priorGraceMs === undefined)
248
+ delete process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
249
+ else
250
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = priorGraceMs;
224
251
  }
225
252
  });
226
253
  test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
@@ -3,6 +3,8 @@ export interface TicketEntry {
3
3
  name: string;
4
4
  cwd: string;
5
5
  count: number;
6
+ /** Open ticket titles for this node, newest first (scan order). */
7
+ subjects: string[];
6
8
  }
7
9
  /** Drop the cached walk. Callers that swap the canvas home out from under a live
8
10
  * process (tests, and any in-process home switch) must call this: the cache is
@@ -33,57 +33,64 @@ let ticketCountCache = null;
33
33
  export function invalidateTicketCounts() {
34
34
  ticketCountCache = null;
35
35
  }
36
- function ticketCounts() {
36
+ function ticketSubjects() {
37
37
  const now = Date.now();
38
38
  if (ticketCountCache !== null && now - ticketCountCache.at < TICKET_COUNT_TTL_MS) {
39
- return ticketCountCache.counts;
39
+ return ticketCountCache.subjects;
40
40
  }
41
- const counts = new Map();
41
+ const subjects = new Map();
42
42
  try {
43
43
  for (const ticket of filterTerminalReviewTickets(scanInbox())) {
44
44
  const nodeId = askingNodeId(ticket.dir);
45
- if (nodeId !== null)
46
- counts.set(nodeId, (counts.get(nodeId) ?? 0) + 1);
45
+ if (nodeId === null)
46
+ continue;
47
+ const list = subjects.get(nodeId);
48
+ if (list === undefined)
49
+ subjects.set(nodeId, [ticket.title]);
50
+ else
51
+ list.push(ticket.title);
47
52
  }
48
53
  }
49
54
  catch {
50
55
  // An unavailable ticket store or absent root has no display consequence.
51
56
  }
52
- ticketCountCache = { at: Date.now(), counts };
53
- return counts;
57
+ ticketCountCache = { at: Date.now(), subjects };
58
+ return subjects;
54
59
  }
55
60
  /** Count pending tickets asked by one node. */
56
61
  export function countTickets(nodeId) {
57
- return ticketCounts().get(nodeId) ?? 0;
62
+ return ticketSubjects().get(nodeId)?.length ?? 0;
58
63
  }
59
64
  /** Pending tickets for every reachable asking node, including the root. */
60
65
  export function pendingTicketsForView(rootId) {
61
- const counts = ticketCounts();
66
+ const found = ticketSubjects();
62
67
  const entries = [];
63
68
  for (const id of [rootId, ...view(rootId)]) {
64
69
  const node = getNode(id);
65
- const count = counts.get(id) ?? 0;
66
- if (node !== null && count > 0)
67
- entries.push({ node_id: id, name: node.name, cwd: node.cwd, count });
70
+ const subjects = found.get(id) ?? [];
71
+ if (node !== null && subjects.length > 0) {
72
+ entries.push({ node_id: id, name: node.name, cwd: node.cwd, count: subjects.length, subjects });
73
+ }
68
74
  }
69
75
  return entries;
70
76
  }
71
77
  /** Per-node pending ticket counts for an explicit node set. */
72
78
  export function ticketCountsForNodes(ids) {
73
- const found = ticketCounts();
79
+ const found = ticketSubjects();
74
80
  const counts = {};
75
81
  for (const id of ids)
76
- counts[id] = found.get(id) ?? 0;
82
+ counts[id] = found.get(id)?.length ?? 0;
77
83
  return counts;
78
84
  }
79
85
  /** Pending tickets across the entire canvas, attributed to asking nodes. */
80
86
  export function ticketsAcrossCanvas() {
81
- const counts = ticketCounts();
87
+ const found = ticketSubjects();
82
88
  const entries = [];
83
89
  for (const node of listNodes()) {
84
- const count = counts.get(node.node_id) ?? 0;
85
- if (count > 0)
86
- entries.push({ node_id: node.node_id, name: node.name, cwd: node.cwd, count });
90
+ const subjects = found.get(node.node_id) ?? [];
91
+ if (subjects.length > 0) {
92
+ entries.push({ node_id: node.node_id, name: node.name, cwd: node.cwd, count: subjects.length, subjects });
93
+ }
87
94
  }
88
95
  return entries;
89
96
  }
@@ -1,4 +1,4 @@
1
- export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve", "canvas-preview-result"];
1
+ export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve", "canvas-preview-result", "canvas-prompt-scrub"];
2
2
  /** Structural shipped-hook identity: a canvas-hook stem directly under a
3
3
  * `pi-extensions` directory. A user's own extension elsewhere may share the
4
4
  * stem and is not shipped. */
@@ -7,9 +7,15 @@ export const CANVAS_EXTENSION_NAMES = [
7
7
  'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
8
8
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
9
9
  'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
10
- 'canvas-bash-valve', 'canvas-preview-result',
10
+ 'canvas-bash-valve', 'canvas-preview-result', 'canvas-prompt-scrub',
11
11
  ];
12
12
  const HISTORICAL_CANVAS_EXTENSION_PREFIXES = [
13
+ [
14
+ 'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
15
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
16
+ 'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
17
+ 'canvas-bash-valve', 'canvas-preview-result',
18
+ ],
13
19
  [
14
20
  'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
15
21
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
@@ -451,16 +451,34 @@ export function nodeArtifacts(nodeId, types) {
451
451
  const want = new Set(types !== undefined && types.length > 0 ? types : ['report', 'doc', 'roadmap']);
452
452
  const desc = node.description ?? '';
453
453
  const out = [];
454
+ // One malformed file must not break the whole listing — the frontmatter
455
+ // parser throws by design, so this collection layer isolates per file, same
456
+ // as buildCorpus. A context dir often holds foreign markdown (a checked-out
457
+ // repo, node_modules) whose `---` header is not crouter frontmatter.
454
458
  if (want.has('report')) {
455
459
  for (const f of listReportFiles(nodeId)) {
456
- const a = reportArtifact(row, desc, f);
460
+ let a;
461
+ try {
462
+ a = reportArtifact(row, desc, f);
463
+ }
464
+ catch (e) {
465
+ warnInvalidFrontmatter(join(reportsDir(nodeId), f), e);
466
+ continue;
467
+ }
457
468
  if (a !== null)
458
469
  out.push(a);
459
470
  }
460
471
  }
461
472
  if (want.has('doc') || want.has('roadmap')) {
462
473
  for (const abs of listDocFiles(nodeId)) {
463
- const a = docArtifact(row, desc, abs);
474
+ let a;
475
+ try {
476
+ a = docArtifact(row, desc, abs);
477
+ }
478
+ catch (e) {
479
+ warnInvalidFrontmatter(abs, e);
480
+ continue;
481
+ }
464
482
  if (a.source === 'roadmap' && !want.has('roadmap'))
465
483
  continue;
466
484
  if (a.source === 'doc' && !want.has('doc'))
@@ -17,7 +17,7 @@ export type Lifecycle = 'terminal' | 'resident';
17
17
  /** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
18
18
  export type Mode = 'base' | 'orchestrator';
19
19
  /** Why a node last stopped — drives the daemon's reap-vs-revive decision. */
20
- export type ExitIntent = 'done' | 'refresh' | 'idle-release' | null;
20
+ export type ExitIntent = 'done' | 'refresh' | 'idle-release' | 'parked' | null;
21
21
  /** The two structural edges. `subscribes_to` is the load-bearing spine (flow,
22
22
  * org chart, views, completion routing). `spawned_by` is audit only. */
23
23
  export type EdgeType = 'subscribes_to' | 'spawned_by';
@@ -189,11 +189,16 @@ function renderNode(node) {
189
189
  return renderLeafArgv(node.help);
190
190
  }
191
191
  /** Render effective Effects for a hook-eligible core leaf, then append any
192
- * commands.json helpAddenda beneath the contract. Both lookups read stored
193
- * manifest bytes only. Addenda validation receives the install gate's exact
194
- * reserved core names and full core path set, so a manifest rejected at
195
- * ingress contributes nothing here either. */
196
- async function renderNodeWithAddenda(node, path) {
192
+ * commands.json helpAddenda beneath the ordinary contract. Focused parameter
193
+ * help returns only the leaf-owned parameter view, because the caller already
194
+ * read the ordinary contract and addenda before following its road sign. Both
195
+ * plugin lookups read stored manifest bytes only. Addenda validation receives
196
+ * the install gate's exact reserved core names and full core path set, so a
197
+ * manifest rejected at ingress contributes nothing here either. */
198
+ async function renderNodeWithAddenda(node, path, focusedFlag) {
199
+ if (focusedFlag !== undefined && node.kind === 'leaf') {
200
+ return renderLeafArgv(node.help, focusedFlag);
201
+ }
197
202
  const body = node.kind === 'leaf' && node.effectiveHelp !== undefined
198
203
  ? renderLeafArgv(await node.effectiveHelp())
199
204
  : renderNode(node);
@@ -215,6 +220,21 @@ async function renderNodeWithAddenda(node, path) {
215
220
  function helpRequested(remaining) {
216
221
  return remaining.some((t) => t === '-h' || t === '--help');
217
222
  }
223
+ /** Resolve the canonical focused-help form `crtr <leaf> --<flag> -h`.
224
+ * Only flags that explicitly own a focusedHelp contract select that view;
225
+ * every other historical help invocation renders ordinary leaf help. */
226
+ function focusedHelpFlag(node, remaining) {
227
+ if (node.kind !== 'leaf' || remaining.length !== 2)
228
+ return undefined;
229
+ const [flagToken, helpToken] = remaining;
230
+ if ((helpToken !== '-h' && helpToken !== '--help')
231
+ || !flagToken.startsWith('--')
232
+ || flagToken.includes('='))
233
+ return undefined;
234
+ const name = flagToken.slice(2);
235
+ const flag = (node.help.params ?? []).find((param) => param.kind === 'flag' && param.name === name);
236
+ return flag?.focusedHelp !== undefined ? name : undefined;
237
+ }
218
238
  /** Recover the RAW argv slice after a matched passthrough branch's path,
219
239
  * unaffected by the `--json`-stripping done to build `tokens`. `path` is a
220
240
  * prefix of non-`--json` tokens in order, so walking `rawTokens` while
@@ -387,7 +407,10 @@ export async function parseArgv(params, tokens, options) {
387
407
  }
388
408
  const rawVal = inlineValue !== undefined ? inlineValue : tokens[++i];
389
409
  if (rawVal === undefined || rawVal.startsWith('--')) {
390
- throw parseArgvError('missing_parameter', `--${flagName} requires a value`, `--${flagName}`, flagName);
410
+ const focusedNext = flagDef.focusedHelp !== undefined && (options?.leafPath?.length ?? 0) > 0
411
+ ? `Run \`crtr ${options.leafPath.join(' ')} --${flagName} -h\` and read the focused value contract before retrying.`
412
+ : undefined;
413
+ throw parseArgvError('missing_parameter', `--${flagName} requires a value`, `--${flagName}`, flagName, focusedNext);
391
414
  }
392
415
  let parsedValue;
393
416
  if (flagDef.type === 'int') {
@@ -620,9 +643,11 @@ export async function runCli(root, argv) {
620
643
  && remaining[0] !== '-h' && remaining[0] !== '--help') {
621
644
  throw unknownPathError(node, path, remaining[0]);
622
645
  }
623
- // Help anywhere in remaining tokens → print node help and exit
646
+ // Help anywhere in remaining tokens → print node help and exit. The exact
647
+ // leaf form `--<flag> -h` selects that flag's focused continuation when one
648
+ // is declared; no invocation state or acknowledgement token is persisted.
624
649
  if (helpRequested(remaining)) {
625
- process.stdout.write(await renderNodeWithAddenda(node, path) + '\n');
650
+ process.stdout.write(await renderNodeWithAddenda(node, path, focusedHelpFlag(node, remaining)) + '\n');
626
651
  process.exitCode = ExitCode.SUCCESS;
627
652
  return;
628
653
  }
@@ -28,6 +28,20 @@ export interface PositionalParam {
28
28
  }
29
29
  /** How a local file named by a `path` param is encoded into the request. */
30
30
  export type FileEncoding = 'text' | 'base64';
31
+ /** Additional guidance for an uncommon flag whose safe selection, value
32
+ * grammar, dynamic choices, or effects do not fit in its compact leaf-schema
33
+ * row. The compact row routes the agent to `crtr <leaf> --<flag> -h`; that
34
+ * focused continuation renders only this parameter contract. */
35
+ export interface FocusedFlagHelp {
36
+ /** Concrete cases that justify overriding the default, plus when to omit. */
37
+ whenToUse: string;
38
+ /** Complete accepted-value grammar and default/inheritance behavior. */
39
+ value: string;
40
+ /** Flag-specific persistent or lifecycle effects and coupled interactions. */
41
+ effects?: string[];
42
+ /** Bounded dynamic choices owned by this flag, rendered only in focused help. */
43
+ dynamicState?: () => string | null;
44
+ }
31
45
  /** Long-form flag (`--name`). */
32
46
  export interface FlagParam {
33
47
  kind: 'flag';
@@ -37,7 +51,13 @@ export interface FlagParam {
37
51
  /** Required only when type is 'enum'. */
38
52
  choices?: string[];
39
53
  required: boolean;
54
+ /** Compact discovery contract. When focusedHelp is present, state what the
55
+ * flag changes and the condition that could justify it; the renderer adds
56
+ * the exact focused-help road sign automatically. */
40
57
  constraint: string;
58
+ /** Opt into focused `--flag -h` guidance. Use only when omission is normal
59
+ * and the complete safe contract cannot fit in one compact row. */
60
+ focusedHelp?: FocusedFlagHelp;
41
61
  default?: string | number | boolean;
42
62
  /** When true, the flag may appear multiple times; values accumulate into an
43
63
  * array (parseArgv collects them; body-placed REST params ship as a JSON array). */
@@ -196,7 +216,9 @@ export interface LeafHelp {
196
216
  name: string;
197
217
  summary: string;
198
218
  /** Optional long-form workflow prose rendered immediately after the summary
199
- * line. Only plan new / spec new carry this; it precedes the schema. */
219
+ * line, before the schema. Carried only by leaves whose correct use needs
220
+ * prose beyond the schema (e.g. node new's prompt-writing guidance,
221
+ * node yield's pre-yield checklist). */
200
222
  guide?: string;
201
223
  params?: InputParam[];
202
224
  /** Note appended when there is no input (replaces the Input block). */
@@ -231,4 +253,8 @@ export type EffectiveLeafHelp = Readonly<Omit<LeafHelp, 'effects'> & {
231
253
  export declare function stateBlock(tag: string, attrs: Record<string, string | number>, body: string): string;
232
254
  export declare function renderRoot(h: RootHelp): string;
233
255
  export declare function renderBranch(h: BranchHelp): string;
234
- export declare function renderLeafArgv(h: LeafHelp | EffectiveLeafHelp): string;
256
+ /** Render one leaf's ordinary contract, or the focused continuation for one
257
+ * uncommon flag selected through `--flag -h`. The caller reaches focused help
258
+ * from the ordinary row, so that view contains only leaf identity and the new
259
+ * parameter detail instead of repeating information already in context. */
260
+ export declare function renderLeafArgv(h: LeafHelp | EffectiveLeafHelp, focusedFlag?: string): string;
package/dist/core/help.js CHANGED
@@ -47,7 +47,7 @@ function pad(s, width) {
47
47
  // ---------------------------------------------------------------------------
48
48
  const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is agent-ready markdown/XML you\n' +
49
49
  'act on directly — read it as a continuation of your prompt, don\'t parse it as data.\n' +
50
- 'Exit 0 on success, non-zero on failure. Schemas appear at leaf -h.';
50
+ 'Exit 0 on success, non-zero on failure. Schemas appear at leaf -h; a leaf may route an uncommon flag to focused --flag -h guidance.';
51
51
  // Behavioral instruction (not a schema) — engrained in the appended system
52
52
  // prompt so the model treats unfamiliar capabilities as a cue to discover the
53
53
  // contract, never to guess, AND reads a command's contract before invoking it.
@@ -57,6 +57,7 @@ const CAPABILITY_DISCOVERY = 'Before running a crtr command that changes state
57
57
  "deletes anything — whose exact contract (args, flags, effects) you haven't " +
58
58
  'verified this session, run `-h` on it and read the schema first — a reliable ' +
59
59
  'read beats a guess that wastes a turn or triggers an unintended effect. ' +
60
+ 'When a leaf routes a flag to focused help, read that focused help before using the flag; the compact row is discovery, not its invocation contract. ' +
60
61
  "Same when the user names a capability you don't fully recognize: " +
61
62
  '`-h` it before acting.';
62
63
  /** Lines for a command's subcommand affordance at root: any promoted
@@ -204,7 +205,12 @@ function envDefaultNote(envVar) {
204
205
  ? ''
205
206
  : ` Defaults to $${envVar} when that is set and non-empty in the environment, and counts as supplied.`;
206
207
  }
207
- function paramDesc(p) {
208
+ function focusedFlagHelpNote(flag, leafName) {
209
+ if (flag.focusedHelp === undefined)
210
+ return '';
211
+ return ` Before using this flag, run \`crtr ${leafName} --${flag.name} -h\` and read its focused guidance.`;
212
+ }
213
+ function paramDesc(p, leafName) {
208
214
  const req = p.required ? 'required' : 'optional';
209
215
  if (p.kind === 'positional') {
210
216
  const repeatable = p.repeatable === true
@@ -222,8 +228,9 @@ function paramDesc(p) {
222
228
  }
223
229
  // flag
224
230
  const f = p;
231
+ const focusedNote = focusedFlagHelpNote(f, leafName);
225
232
  if (f.type === 'bool')
226
- return `${req} boolean. Presence means true. ${f.constraint}`.trim();
233
+ return `${req} boolean. Presence means true. ${f.constraint}${focusedNote}`.trim();
227
234
  const dflt = f.default !== undefined ? ` Default: ${String(f.default)}.` : '';
228
235
  const choices = f.type === 'enum' && f.choices !== undefined
229
236
  ? ` One of: ${f.choices.join(', ')}.`
@@ -231,24 +238,53 @@ function paramDesc(p) {
231
238
  const repeatable = f.repeatable === true
232
239
  ? ' Repeatable — pass multiple times to accumulate.'
233
240
  : '';
234
- return `${f.type}, ${req}.${choices}${dflt}${fileEncodingNote(f.encoding)}${envDefaultNote(f.defaultFromEnv)} ${f.constraint}${repeatable}`.trim();
241
+ return `${f.type}, ${req}.${choices}${dflt}${fileEncodingNote(f.encoding)}${envDefaultNote(f.defaultFromEnv)} ${f.constraint}${repeatable}${focusedNote}`.trim();
235
242
  }
236
- export function renderLeafArgv(h) {
237
- const lines = [];
238
- lines.push(`${h.name}: ${h.summary}.`);
243
+ /** Render one leaf's ordinary contract, or the focused continuation for one
244
+ * uncommon flag selected through `--flag -h`. The caller reaches focused help
245
+ * from the ordinary row, so that view contains only leaf identity and the new
246
+ * parameter detail instead of repeating information already in context. */
247
+ export function renderLeafArgv(h, focusedFlag) {
248
+ const lines = [`${h.name}: ${h.summary}.`];
249
+ // Hidden flags are parsed but never advertised in -h (see FlagParam.hidden).
250
+ const params = (h.params ?? []).filter((p) => !(p.kind === 'flag' && p.hidden === true));
251
+ const focused = focusedFlag === undefined
252
+ ? undefined
253
+ : params.find((p) => p.kind === 'flag' && p.name === focusedFlag && p.focusedHelp !== undefined);
254
+ if (focused?.focusedHelp !== undefined) {
255
+ const detail = focused.focusedHelp;
256
+ lines.push('');
257
+ lines.push(`<parameter name="${focused.name}">`);
258
+ lines.push('When to use');
259
+ lines.push(` ${detail.whenToUse}`);
260
+ lines.push('');
261
+ lines.push('Value');
262
+ lines.push(` ${detail.value}`);
263
+ if ((detail.effects ?? []).length > 0) {
264
+ lines.push('');
265
+ lines.push('Effects');
266
+ for (const effect of detail.effects ?? [])
267
+ lines.push(` ${effect}`);
268
+ }
269
+ const focusedState = evalDynamic(detail.dynamicState);
270
+ if (focusedState !== null) {
271
+ lines.push('');
272
+ lines.push(focusedState);
273
+ }
274
+ lines.push('</parameter>');
275
+ return lines.join('\n');
276
+ }
239
277
  if (h.guide !== undefined) {
240
278
  lines.push('');
241
279
  lines.push(h.guide);
242
280
  }
243
281
  lines.push('');
244
- // Hidden flags are parsed but never advertised in -h (see FlagParam.hidden).
245
- const params = (h.params ?? []).filter((p) => !(p.kind === 'flag' && p.hidden === true));
246
282
  if (params.length > 0) {
247
283
  lines.push('Input');
248
284
  const labels = params.map(paramLabel);
249
285
  const colW = maxLen(labels);
250
286
  for (let i = 0; i < params.length; i++) {
251
- lines.push(` ${pad(labels[i], colW)} ${paramDesc(params[i])}`);
287
+ lines.push(` ${pad(labels[i], colW)} ${paramDesc(params[i], h.name)}`);
252
288
  }
253
289
  }
254
290
  else {
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { projectHtmlDocumentMarkdown } from '../page-html-markdown.js';
4
+ test('an authored HTML page projects to readable markdown', () => {
5
+ const markdown = projectHtmlDocumentMarkdown(`<!doctype html>
6
+ <html><head><title>Deploy sign-off</title><style>body{color:red}</style></head>
7
+ <body>
8
+ <h1>Deploy sign-off</h1>
9
+ <p>Staging has been green for <strong>6 hours</strong>; the risk is the <em>migration</em>.</p>
10
+ <ul><li>Smoke suite passed</li><li>Rollback rehearsed &mdash; 90s</li><li>See <a href="https://ci.example.com/42">run 42</a></li></ul>
11
+ <table><tr><th>Step</th><th>Duration</th></tr><tr><td>Migrate</td><td>4m 10s</td></tr></table>
12
+ <pre><code>kubectl rollout status deploy/orders</code></pre>
13
+ <blockquote>Waiting on your call.</blockquote>
14
+ <script>console.log('nope')</script>
15
+ </body></html>`);
16
+ assert.equal(markdown, [
17
+ '# Deploy sign-off',
18
+ '',
19
+ 'Staging has been green for **6 hours**; the risk is the *migration*.',
20
+ '',
21
+ '- Smoke suite passed',
22
+ '- Rollback rehearsed — 90s',
23
+ '- See [run 42](https://ci.example.com/42)',
24
+ '',
25
+ '| Step | Duration |',
26
+ '| --- | --- |',
27
+ '| Migrate | 4m 10s |',
28
+ '',
29
+ '```',
30
+ 'kubectl rollout status deploy/orders',
31
+ '```',
32
+ '',
33
+ '> Waiting on your call.',
34
+ ].join('\n'));
35
+ });
36
+ test('the projection survives malformed markup rather than losing the document', () => {
37
+ // A stray </style> must not reopen the head, and a stray <script> must not
38
+ // swallow the rest of the page: both are common in hand-written HTML.
39
+ assert.match(projectHtmlDocumentMarkdown('</style><p>Body text</p>'), /Body text/);
40
+ assert.equal(projectHtmlDocumentMarkdown('<p>Before</p><script>x=1;</script><p>After</p>'), 'Before\n\nAfter');
41
+ assert.equal(projectHtmlDocumentMarkdown('<p>Alone<'), 'Alone\\<');
42
+ assert.equal(projectHtmlDocumentMarkdown(''), '');
43
+ // Ordered lists number themselves, and a link with no label leaves no husk.
44
+ assert.equal(projectHtmlDocumentMarkdown('<ol><li>First</li><li>Second</li></ol>'), '1. First\n2. Second');
45
+ assert.equal(projectHtmlDocumentMarkdown('<p>Read <a href="https://x.test"></a>this</p>'), 'Read this');
46
+ // Markdown structure in authored prose is escaped, not obeyed.
47
+ assert.equal(projectHtmlDocumentMarkdown('<p># not a heading</p>'), '\\# not a heading');
48
+ });
@@ -10,7 +10,7 @@ const SLOT_RULES = `Component contract
10
10
  - When a page has two or more response-bearing components, every one needs a nonempty \`label\`.
11
11
  - Config objects and every documented nested object reject unknown fields.
12
12
  - The page is rendered once at submit to build the ticket manifest, so every prop reaches it whatever its shape — mapped data, a helper component, a value from \`useState\`. Event handlers are dropped, because a manifest is JSON. The user's answer lands in \`response.json\` keyed by this \`id\`.`;
13
- const COMMENT_SHAPE = `Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\` and qualify the answer itself. \`Anchor\` is \`{kind:"option",optionId:string}\` or \`{kind:"row",rowId:string}\`; each component accepts only the anchors named next. Feedback on rendered page content — including the question text — is given by highlighting it on the page, never authored into a response.`;
13
+ const COMMENT_SHAPE = `Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\` and qualify the answer itself. \`Anchor\` is \`{kind:"option",optionId:string}\` or \`{kind:"row",rowId:string}\`; each component accepts only the anchors named next. The reader writes one against a specific option or row in whatever host they read in — in the terminal inbox, \`c\` on the selected one — so every option and row needs a label that reads on its own. Feedback on the page's own prose is a separate channel, offered only by hosts that render the page, and never part of a response.`;
14
14
  const DISPLAY_RULES = `Display-only: it is not recorded in the page manifest and contributes nothing to the response. Every display component also takes \`className\` for Tailwind utilities, and standard shadcn props otherwise.`;
15
15
  export const BUILTIN_COMPONENT_DOCS = [
16
16
  {
@@ -103,12 +103,12 @@ Example
103
103
  {
104
104
  names: ['UserText'],
105
105
  group: 'slot',
106
- selectionLine: '`UserText` — writing surface (wire kind `text`). Use when you need prose back from the user — a value they write, or a draft they hand back revised; use `singleLine` for a subject, name, or URL. Prose the user only reads is a plain element, never this.',
106
+ selectionLine: '`UserText` — writing surface (wire kind `text`). Use whenever the answer is written rather than chosen: a value, a paragraph, or a draft you seeded for them to revise; `singleLine` for a subject, name, or URL. This is the page\'s surface for freeform input — a picker\'s `allowFreetext` is an escape hatch beside its options, not a way to collect prose. Prose the user only reads is a plain element.',
107
107
  contract: `\`<UserText>\` — wire kind \`text\`
108
108
 
109
109
  Response-bearing. The response is \`{text:string, edited:boolean}\`: the text the user hands back, and whether they changed what you drafted.
110
110
 
111
- It collects prose, it does not present it. Text the user only reads is a plain element with Tailwind classes — \`<p className="whitespace-pre-wrap">\` — which renders the same and asks nothing of them. Reach for it only when the answer you need is the string left in the field. Feedback on the rendered text is given by highlighting it on the page, not authored into the response.
111
+ Reach for it whenever what you need back is written rather than picked — a value, a paragraph, or a draft you seeded with \`initialText\` for them to revise. It is the page's one writing surface: \`allowFreetext\` on a picker adds a single "something else" row beside the options, so prose you actually want belongs here instead. It collects text, it does not present it — text the user only reads is a plain element with Tailwind classes, \`<p className="whitespace-pre-wrap">\`, which renders the same and asks nothing of them.
112
112
 
113
113
  ${SLOT_RULES}
114
114
 
@@ -164,7 +164,7 @@ Example
164
164
  selectionLine: '`UserCards` — visual item picker (wire kind `cards`). Use when the user chooses among rich, card-shaped alternatives.',
165
165
  contract: `\`<UserCards>\` — wire kind \`cards\`
166
166
 
167
- Response-bearing. The response is \`{selectedCardIds:string[]}\`. Feedback on a card's content is given by highlighting it on the page, not authored into the response.
167
+ Response-bearing. The response is \`{selectedCardIds:string[]}\` — the selection alone; cards carry no comments.
168
168
 
169
169
  ${SLOT_RULES}
170
170
 
@@ -0,0 +1,8 @@
1
+ /** An HTML page's document projected to markdown, so a terminal reader sees the
2
+ * page's actual content instead of a placeholder line. HTML pages are authored
3
+ * for a browser surface and carry no slots, so this is a reading projection
4
+ * only — layout, styling, and scripting are dropped, and what survives is the
5
+ * prose, headings, lists, links, code, and table cells the author wrote. */
6
+ /** Project one authored HTML document to markdown. Never throws: a document
7
+ * this cannot make sense of yields whatever text it did find, down to ''. */
8
+ export declare function projectHtmlDocumentMarkdown(document: string): string;