@north-light/crouter 0.3.197 → 0.3.198

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 (144) hide show
  1. package/dist/api/client.d.ts +2 -2
  2. package/dist/api/dto/inbox.d.ts +46 -0
  3. package/dist/builtin-memory/00-runtime-base.md +3 -3
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  5. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  7. package/dist/clients/attach/render/chat-view.d.ts +3 -8
  8. package/dist/clients/attach/render/chat-view.js +3 -24
  9. package/dist/clients/attach/render/markdown-source.js +0 -6
  10. package/dist/clients/attach/render/tool-calls.js +16 -2
  11. package/dist/clients/attach/viewer.js +613 -602
  12. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
  13. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
  14. package/dist/clients/inbox/controller.js +4 -4
  15. package/dist/clients/inbox/page-adapter.d.ts +2 -1
  16. package/dist/clients/inbox/tui/render.js +46 -25
  17. package/dist/clients/inbox/tui/slots.d.ts +1 -3
  18. package/dist/clients/inbox/tui/slots.js +8 -37
  19. package/dist/clients/inbox/tui/types.d.ts +4 -3
  20. package/dist/commands/__tests__/human.test.js +160 -23
  21. package/dist/commands/canvas-browse.js +1 -1
  22. package/dist/commands/human/doc.js +21 -74
  23. package/dist/commands/human/html.d.ts +2 -0
  24. package/dist/commands/human/html.js +81 -0
  25. package/dist/commands/human/prompts.d.ts +5 -4
  26. package/dist/commands/human/prompts.js +81 -153
  27. package/dist/commands/human/shared.d.ts +5 -6
  28. package/dist/commands/human/shared.js +13 -61
  29. package/dist/commands/human.js +7 -2
  30. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  31. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  32. package/dist/core/__tests__/human-deliver.test.js +28 -37
  33. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  34. package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
  35. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
  36. package/dist/core/canvas/__tests__/attention.test.js +2 -2
  37. package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
  38. package/dist/core/canvas/browse/app.d.ts +1 -1
  39. package/dist/core/canvas/browse/app.js +21 -3
  40. package/dist/core/canvas/browse/render.d.ts +4 -0
  41. package/dist/core/canvas/browse/render.js +5 -1
  42. package/dist/core/canvas/canvas.d.ts +26 -0
  43. package/dist/core/canvas/canvas.js +55 -0
  44. package/dist/core/command.js +6 -11
  45. package/dist/core/config.js +3 -2
  46. package/dist/core/help.d.ts +3 -0
  47. package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
  48. package/dist/core/human/__tests__/html-markdown.test.js +52 -0
  49. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  50. package/dist/core/human/__tests__/page-catalog.test.js +16 -0
  51. package/dist/core/human/__tests__/page-render.test.js +40 -23
  52. package/dist/core/human/__tests__/page-tickets.test.js +48 -27
  53. package/dist/core/human/__tests__/page.test.js +48 -65
  54. package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
  55. package/dist/core/human/answer-text.d.ts +3 -0
  56. package/dist/core/human/answer-text.js +98 -0
  57. package/dist/core/human/answer.d.ts +51 -0
  58. package/dist/core/human/answer.js +125 -0
  59. package/dist/core/human/component-docs.d.ts +14 -0
  60. package/dist/core/human/component-docs.js +161 -0
  61. package/dist/core/human/html-markdown.d.ts +3 -0
  62. package/dist/core/human/html-markdown.js +288 -0
  63. package/dist/core/human/page-catalog.d.ts +19 -3
  64. package/dist/core/human/page-catalog.js +73 -16
  65. package/dist/core/human/page-render.d.ts +3 -2
  66. package/dist/core/human/page-render.js +19 -45
  67. package/dist/core/human/page-schema.d.ts +108 -8
  68. package/dist/core/human/page-schema.js +88 -31
  69. package/dist/core/human/page-synth.d.ts +2 -4
  70. package/dist/core/human/page-synth.js +23 -7
  71. package/dist/core/human/page.d.ts +14 -7
  72. package/dist/core/human/page.js +178 -90
  73. package/dist/core/human/review-schema.d.ts +1 -0
  74. package/dist/core/human/review-schema.js +1 -1
  75. package/dist/core/human/scan.d.ts +3 -2
  76. package/dist/core/human/scan.js +7 -8
  77. package/dist/core/human/summary.d.ts +4 -7
  78. package/dist/core/human/summary.js +4 -60
  79. package/dist/core/human/tickets.d.ts +10 -9
  80. package/dist/core/human/tickets.js +5 -3
  81. package/dist/core/human/types.d.ts +4 -0
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +1 -0
  84. package/dist/core/review/realize.js +9 -1
  85. package/dist/core/runtime/boot-root.js +6 -1
  86. package/dist/core/runtime/broker.js +8 -6
  87. package/dist/core/runtime/busy.d.ts +4 -3
  88. package/dist/core/runtime/busy.js +4 -3
  89. package/dist/core/runtime/spawn.js +4 -4
  90. package/dist/core/user-settings.d.ts +17 -2
  91. package/dist/core/user-settings.js +32 -11
  92. package/dist/daemon/api/handlers/human.js +1 -1
  93. package/dist/daemon/api/handlers/inbox.js +22 -7
  94. package/dist/daemon/cron-run.js +16 -2
  95. package/dist/daemon/crtrd.js +1 -1
  96. package/dist/daemon/fleet.d.ts +22 -10
  97. package/dist/daemon/fleet.js +43 -24
  98. package/dist/daemon/human/finish.d.ts +2 -1
  99. package/dist/daemon/human/finish.js +45 -28
  100. package/dist/daemon/human/sweep.js +4 -2
  101. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  102. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  103. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  104. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  105. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  106. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  107. package/dist/daemon/review/sweep.js +12 -1
  108. package/dist/pages/bundle.css +1 -1
  109. package/dist/pages/bundle.js +1008 -577
  110. package/dist/pages/comments.d.ts +29 -0
  111. package/dist/pages/comments.js +51 -0
  112. package/dist/pages/controls.d.ts +98 -0
  113. package/dist/pages/controls.js +261 -0
  114. package/dist/pages/elements/cards.d.ts +15 -4
  115. package/dist/pages/elements/cards.js +299 -186
  116. package/dist/pages/elements/chart.d.ts +22 -0
  117. package/dist/pages/elements/chart.js +107 -91
  118. package/dist/pages/elements/options.d.ts +9 -0
  119. package/dist/pages/elements/options.js +268 -155
  120. package/dist/pages/elements/pages.d.ts +6 -0
  121. package/dist/pages/elements/pages.js +60 -38
  122. package/dist/pages/elements/slot.d.ts +5 -0
  123. package/dist/pages/elements/slot.js +17 -10
  124. package/dist/pages/elements/table.d.ts +17 -0
  125. package/dist/pages/elements/table.js +336 -228
  126. package/dist/pages/elements/text.d.ts +9 -3
  127. package/dist/pages/elements/text.js +350 -143
  128. package/dist/pages/host.d.ts +0 -24
  129. package/dist/pages/host.js +0 -17
  130. package/dist/pages/readonly.d.ts +24 -0
  131. package/dist/pages/readonly.js +31 -0
  132. package/dist/pages/responses.d.ts +37 -0
  133. package/dist/pages/responses.js +56 -0
  134. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  135. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  136. package/dist/types.d.ts +19 -5
  137. package/dist/types.js +2 -1
  138. package/package.json +1 -1
  139. package/runtime.lock.json +2 -2
  140. package/scripts/install-runtime.mjs +19 -24
  141. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  142. package/dist/clients/attach/render/html-markdown.js +0 -206
  143. package/dist/core/human/markdown-html.d.ts +0 -8
  144. package/dist/core/human/markdown-html.js +0 -581
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { derivePageManifest, parsePageSource } from '../page.js';
7
- import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from '../page-schema.js';
7
+ import { isResponseBearingSlot, validatePageManifest, validatePageResponses, validatePartialPageResponses } from '../page-schema.js';
8
8
  function withSource(name, source, run) {
9
9
  const dir = mkdtempSync(join(tmpdir(), 'crtr-page-test-'));
10
10
  try {
@@ -18,7 +18,7 @@ function withSource(name, source, run) {
18
18
  }
19
19
  const htmlHead = '<!doctype html><html><head><meta name="crtr-page-title" content="Test"></head><body>';
20
20
  const htmlTail = '</body></html>';
21
- const optionsScript = '<script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"}]}</script>';
21
+ const optionsScript = '<script type="application/json">{"label":"Choose an option","mode":"single","options":[{"id":"yes","label":"Yes"}]}</script>';
22
22
  function deriveHtml(body, head = htmlHead) {
23
23
  let manifest;
24
24
  withSource('input.html', `${head}${body}${htmlTail}`, (path) => { manifest = derivePageManifest(path, 'html'); });
@@ -27,41 +27,6 @@ function deriveHtml(body, head = htmlHead) {
27
27
  function expectHtmlError(body, pattern, head = htmlHead) {
28
28
  withSource('input.html', `${head}${body}${htmlTail}`, (path) => assert.throws(() => derivePageManifest(path, 'html'), pattern));
29
29
  }
30
- test('md dialect derives a normalized manifest and preserves top-level slot scanning', () => {
31
- const source = `---
32
- title: Rollout
33
- subtitle: Choose carefully
34
- placement: inline
35
- ---
36
-
37
- Before.
38
-
39
- \`\`\`\`markdown
40
- \`\`\`slot:text
41
- {"id":"nested","initialText":"not a slot"}
42
- \`\`\`
43
- \`\`\`\`
44
-
45
- \`\`\`slot:table
46
- {"columns":[{"id":"day","label":"Day"}],"rows":[{"id":"r1","cells":{"day":"Mon"}}]}
47
- \`\`\`
48
-
49
- \`\`\`slot:options
50
- {"id":"rollout","mode":"single","options":[{"id":"yes","label":"Yes"}]}
51
- \`\`\`
52
- `;
53
- withSource('input.md', source, (path) => {
54
- const manifest = derivePageManifest(path, 'md');
55
- // A display-only table's schema defaults must survive the manifest→source round trip.
56
- assert.deepEqual(parsePageSource(source, manifest).at(-1), { kind: 'slot', slotIndex: 1 });
57
- assert.equal(manifest.title, 'Rollout');
58
- assert.equal(manifest.subtitle, 'Choose carefully');
59
- assert.equal(manifest.placement, 'inline');
60
- assert.equal(manifest.document, 'page.md');
61
- assert.equal(manifest.steps, 1);
62
- assert.deepEqual(manifest.slots.map(({ id, kind, step }) => ({ id, kind, step })), [{ id: undefined, kind: 'table', step: 0 }, { id: 'rollout', kind: 'options', step: 0 }]);
63
- });
64
- });
65
30
  test('html dialect derives document-order slots and wrapper steps', () => {
66
31
  const body = `<crtr-pages><crtr-page><section><crtr-options id="choice">${optionsScript}</crtr-options></section></crtr-page><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages>`;
67
32
  const manifest = deriveHtml(body, '<!doctype html><html placement="inline"><head><meta name="crtr-page-title" content="HTML"><meta name="crtr-page-subtitle" content="Two steps"><meta name="crtr-page-placement" content="inline"></head><body>');
@@ -75,14 +40,6 @@ test('html wrapper rejects crtr-page outside crtr-pages', () => expectHtmlError(
75
40
  test('html wrapper rejects empty crtr-pages', () => expectHtmlError('<crtr-pages></crtr-pages>', /empty <crtr-pages>/));
76
41
  test('html wrapper rejects a response-bearing element outside a step with the fix', () => expectHtmlError(`<crtr-options id="choice">${optionsScript}</crtr-options>`, /response-bearing element <crtr-options>[\s\S]*<crtr-pages><crtr-page>…<\/crtr-page><\/crtr-pages>/));
77
42
  test('html wrapper rejects nested crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page><crtr-pages><crtr-page></crtr-page></crtr-pages></crtr-page></crtr-pages>', /nested <crtr-pages>/));
78
- test('md front matter names unknown, empty-title, and placement errors', () => {
79
- for (const [source, pattern] of [
80
- ['---\ntitle: T\nother: no\n---\n', /unknown page front matter key "other"/],
81
- ['---\ntitle: \n---\n', /title is required and must be nonempty/],
82
- ['---\ntitle: T\nplacement: sidebar\n---\n', /placement must be exactly inline or panel/],
83
- ])
84
- withSource('input.md', source, (path) => assert.throws(() => derivePageManifest(path, 'md'), pattern));
85
- });
86
43
  test('html metadata enforces cardinality, known names, and placement content', () => {
87
44
  const duplicateTitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-title" content="B"></head><body>';
88
45
  const duplicateSubtitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-subtitle" content="A"><meta name="crtr-page-subtitle" content="B"></head><body>';
@@ -100,40 +57,66 @@ test('the response-bearing predicate handles table selection and catalog edges',
100
57
  assert.equal(isResponseBearingSlot(slot('chart', { kind: 'bar', x: 'x', y: [{ field: 'y' }] })), false);
101
58
  assert.equal(isResponseBearingSlot(slot('product-kind', {}, true)), true);
102
59
  });
60
+ test('display product slots derive as non-response-bearing without interactive structure', () => {
61
+ const displayComponent = [{ kind: 'nl-thread', display: true }];
62
+ const displayBody = '<crtr-slot kind="nl-thread"><script type="application/json">{"messages":[]}</script></crtr-slot>';
63
+ let displayManifest;
64
+ withSource('input.html', `${htmlHead}${displayBody}${htmlTail}`, (path) => { displayManifest = derivePageManifest(path, 'html', displayComponent); });
65
+ assert.deepEqual(displayManifest.slots, [{ kind: 'nl-thread', step: 0, config: { messages: [] }, unvalidated: true, display: true }]);
66
+ assert.deepEqual(validatePageManifest(displayManifest, displayComponent).slots, displayManifest.slots);
67
+ assert.equal(isResponseBearingSlot(displayManifest.slots[0]), false);
68
+ const responseBody = '<crtr-slot kind="product-input"><script type="application/json">{}</script></crtr-slot>';
69
+ withSource('input.html', `${htmlHead}${responseBody}${htmlTail}`, (path) => {
70
+ assert.throws(() => derivePageManifest(path, 'html', [{ kind: 'product-input' }]), /product slot kind "product-input" at slot #0 requires an id="…" attribute on its <crtr-slot> element/);
71
+ });
72
+ });
73
+ test('multi-response pages require a label on every response-bearing slot', () => {
74
+ expectHtmlError(`<crtr-pages><crtr-page><crtr-options id="choice"><script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"}]}</script></crtr-options><crtr-text id="note"><script type="application/json">{"label":"Add a note","initialText":""}</script></crtr-text></crtr-page></crtr-pages>`, /response-bearing slot "choice" requires a nonempty config\.label/);
75
+ expectHtmlError(`<crtr-pages><crtr-page><crtr-options id="choice"><script type="application/json">{"label":"Choose","mode":"single","options":[{"id":"yes","label":"Yes"}]}</script></crtr-options><crtr-table id="table"><script type="application/json">{"label":"Pick a row","columns":[{"id":"name","label":"Name"}],"rows":[{"id":"one","cells":{"name":"One"}}],"rowSelect":"single","caption":"old"}</script></crtr-table></crtr-page></crtr-pages>`, /invalid config for slot "table"/);
76
+ });
103
77
  test('final and partial response validation share slot-addressed rules', () => {
104
- const manifest = deriveHtml(`<crtr-pages><crtr-page><crtr-options id="choice">${optionsScript}</crtr-options><crtr-text id="note"><script type="application/json">{"initialText":""}</script></crtr-text></crtr-page></crtr-pages>`);
78
+ const manifest = deriveHtml(`<crtr-pages><crtr-page><crtr-options id="choice">${optionsScript}</crtr-options><crtr-text id="note"><script type="application/json">{"label":"Add a note","initialText":""}</script></crtr-text></crtr-page></crtr-pages>`);
105
79
  assert.deepEqual(validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] } });
106
80
  assert.throws(() => validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), /missing response-bearing slot id "note"/);
107
81
  assert.deepEqual(validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } });
108
82
  assert.throws(() => validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['maybe'], comments: [] } }), /unknown option id "maybe"/);
109
83
  assert.throws(() => validatePartialPageResponses(manifest, { extra: {} }), /unknown or non-response-bearing slot id "extra"/);
110
84
  });
111
- test('PageSourceProjection associates leading, interstitial, and trailing prose', () => {
112
- const source = `---
85
+ test('PageSourceProjection rejects changed inline component data', () => {
86
+ const body = '<crtr-table><script type="application/json">{"columns":[{"id":"name","label":"Name"}],"rows":[{"id":"one","cells":{"name":"One"}}]}</script></crtr-table>';
87
+ const manifest = deriveHtml(body);
88
+ const changed = {
89
+ ...manifest,
90
+ slots: [{ ...manifest.slots[0], config: { columns: [{ id: 'name', label: 'Name' }], rows: [{ id: 'two', cells: { name: 'Two' } }] } }],
91
+ };
92
+ assert.throws(() => parsePageSource(`${htmlHead}${body}${htmlTail}`, changed), /does not match manifest slot/);
93
+ });
94
+ test('PageSourceProjection preserves legacy markdown replay and interleaves HTML prose', () => {
95
+ const legacySource = `---
113
96
  title: Projection
114
97
  ---
115
98
  Leading prose.
116
99
 
117
100
  \`\`\`slot:text
118
- {"id":"first","initialText":""}
119
- \`\`\`
120
-
121
- Interstitial prose.
122
-
123
- \`\`\`slot:text
124
- {"id":"second","initialText":""}
101
+ {"id":"first","label":"First answer","initialText":""}
125
102
  \`\`\`
126
103
 
127
104
  Trailing prose.
128
105
  `;
129
- withSource('input.md', source, (path) => {
130
- const manifest = derivePageManifest(path, 'md');
131
- assert.deepEqual(parsePageSource(source, manifest), [
132
- { kind: 'prose', markdown: 'Leading prose.' },
133
- { kind: 'slot', slotIndex: 0 },
134
- { kind: 'prose', markdown: 'Interstitial prose.' },
135
- { kind: 'slot', slotIndex: 1 },
136
- { kind: 'prose', markdown: 'Trailing prose.' },
137
- ]);
138
- });
106
+ const legacyManifest = {
107
+ schema: 'crtr.page/v1', dialect: 'md', title: 'Projection', placement: 'panel', document: 'page.md', steps: 1,
108
+ slots: [{ id: 'first', kind: 'text', step: 0, config: { label: 'First answer', initialText: '' } }],
109
+ };
110
+ assert.deepEqual(parsePageSource(legacySource, legacyManifest), [
111
+ { kind: 'prose', markdown: 'Leading prose.' },
112
+ { kind: 'slot', slotIndex: 0 },
113
+ { kind: 'prose', markdown: 'Trailing prose.' },
114
+ ]);
115
+ const htmlSource = '<!doctype html><html><head><meta name="crtr-page-title" content="Test"></head><body><p>Leading prose.</p><crtr-pages><crtr-page><crtr-text id="first"><script type="application/json">{"label":"First answer","initialText":""}</script></crtr-text><p>Trailing prose.</p></crtr-page></crtr-pages></body></html>';
116
+ const htmlManifest = deriveHtml('<p>Leading prose.</p><crtr-pages><crtr-page><crtr-text id="first"><script type="application/json">{"label":"First answer","initialText":""}</script></crtr-text><p>Trailing prose.</p></crtr-page></crtr-pages>');
117
+ assert.deepEqual(parsePageSource(htmlSource, htmlManifest), [
118
+ { kind: 'prose', markdown: '<p>Leading prose.</p>' },
119
+ { kind: 'slot', slotIndex: 0 },
120
+ { kind: 'prose', markdown: '<p>Trailing prose.</p>' },
121
+ ]);
139
122
  });
@@ -12,19 +12,9 @@ const temp = mkdtempSync(join(tmpdir(), 'crtr-inbox-core-'));
12
12
  process.env['CRTR_HOME'] = temp;
13
13
  const source = join(temp, 'source.md');
14
14
  writeFileSync(source, '# Source\n');
15
- const pageSource = join(temp, 'page.md');
16
- writeFileSync(pageSource, [
17
- '---',
18
- 'title: A page',
19
- 'subtitle: Go? requires your attention.',
20
- '---',
21
- '',
22
- '```slot:text',
23
- '{ "id": "go", "initialText": "", "editable": true }',
24
- '```',
25
- '',
26
- ].join('\n'));
27
- const page = (id, blockedSince) => submitPage({ dir: ticketDir(id), sourceFile: pageSource, dialect: 'md', source: { blockedSince, nodeId: 'node-1' } });
15
+ const pageSource = join(temp, 'page.html');
16
+ writeFileSync(pageSource, '<!doctype html><html><head><meta name="crtr-page-title" content="A page"><meta name="crtr-page-subtitle" content="Go? requires your attention."></head><body><crtr-pages><crtr-page><crtr-text id="go"><script type="application/json">{"initialText":"","editable":true}</script></crtr-text></crtr-page></crtr-pages></body></html>');
17
+ const page = (id, blockedSince) => submitPage({ dir: ticketDir(id), sourceFile: pageSource, dialect: 'html', source: { blockedSince, nodeId: 'node-1' } });
28
18
  const answer = { go: { text: 'yes', edited: true, comments: [] } };
29
19
  async function waitFor(paths) {
30
20
  for (let attempts = 0; !paths.every(existsSync); attempts++) {
@@ -42,7 +32,7 @@ assert.equal(existsSync(ticketDir('bad-review')), false, 'invalid submission doe
42
32
  const prepared = ticketDir('prepared');
43
33
  mkdirSync(prepared);
44
34
  writeFileSync(join(prepared, 'asset.png'), 'prepared asset');
45
- const preparedTicket = submitPage({ dir: prepared, sourceFile: pageSource, dialect: 'md', source: { blockedSince: '2025-01-02T00:00:00.000Z', nodeId: 'node-1' } });
35
+ const preparedTicket = submitPage({ dir: prepared, sourceFile: pageSource, dialect: 'html', source: { blockedSince: '2025-01-02T00:00:00.000Z', nodeId: 'node-1' } });
46
36
  assert.equal(preparedTicket.dir, realpathSync(prepared), 'submission accepts a crouter-precreated direct child and colocated assets');
47
37
  // Same timestamp is stable by id; progress is resumable work, never a hidden lease.
48
38
  atomicWriteJson(progressPath(newPage.dir), { kind: 'page', responses: {}, savedAt: new Date().toISOString() });
@@ -104,7 +94,7 @@ for (const [index, fixture] of rejectFixtures.entries()) {
104
94
  const protocolDir = ticketDir('stale-protocol');
105
95
  mkdirSync(protocolDir);
106
96
  writeFileSync(responsePath(protocolDir), '{}');
107
- assert.throws(() => submitPage({ dir: protocolDir, sourceFile: pageSource, dialect: 'md' }), /protocol state/, 'precreated assets may not include humanloop lifecycle state');
97
+ assert.throws(() => submitPage({ dir: protocolDir, sourceFile: pageSource, dialect: 'html' }), /protocol state/, 'precreated assets may not include humanloop lifecycle state');
108
98
  const cancelDir = page('cancel-race', '2025-01-03T00:00:00.000Z').dir;
109
99
  const canceled = cancelTicket(cancelDir, { reason: 'stop' });
110
100
  assert.equal(canceled.status, 'canceled');
@@ -0,0 +1,3 @@
1
+ import type { PageAnswer } from './answer.js';
2
+ /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent. */
3
+ export declare function renderAnswerText(answer: PageAnswer, responsePath: string): string;
@@ -0,0 +1,98 @@
1
+ function inline(value) {
2
+ return `“${value}”`;
3
+ }
4
+ function verbatim(prefix, value) {
5
+ if (value === '')
6
+ return [`${prefix}(left empty)`];
7
+ if (!value.includes('\n'))
8
+ return [`${prefix}${inline(value)}`];
9
+ return [...(prefix === '' ? [] : [prefix.trimEnd()]), ...value.split('\n').map((line) => `> ${line}`)];
10
+ }
11
+ function code(value) {
12
+ const fence = '`'.repeat(Math.max(0, ...(value.match(/`+/g)?.map((run) => run.length) ?? [0])) + 1);
13
+ return `${fence}${value}${fence}`;
14
+ }
15
+ function item(item) {
16
+ return `${item.label} (${code(item.id)})`;
17
+ }
18
+ function groupName(group) {
19
+ if (group.group === 'option')
20
+ return 'Chose';
21
+ if (group.group === 'card')
22
+ return 'Cards';
23
+ return group.group === 'row' ? 'Rows' : 'Columns';
24
+ }
25
+ function groupLines(group) {
26
+ return [`${groupName(group)}: ${group.picked.length === 0 ? 'none' : group.picked.map(item).join(', ')}`];
27
+ }
28
+ function commentLines(comment) {
29
+ const prefix = comment.anchor.kind === 'whole' ? 'Comment: ' : `Comment on ${comment.anchor.kind === 'range' ? inline(comment.anchorLabel) : `“${comment.anchorLabel}”`}: `;
30
+ return verbatim(prefix, comment.text);
31
+ }
32
+ function shape(slot) {
33
+ if (slot.kind === 'options')
34
+ return `options, ${slot.groups[0]?.mode === 'single' ? 'pick one' : 'pick any'}`;
35
+ if (slot.kind === 'text') {
36
+ if (slot.text?.singleLine === true)
37
+ return `one-line text, ${slot.text.edited ? 'edited' : 'unchanged'}`;
38
+ return `text, ${slot.text?.edited === true ? 'edited' : 'accepted as written'}`;
39
+ }
40
+ if (slot.kind === 'table') {
41
+ const group = slot.groups.find((candidate) => candidate.group === 'row') ?? slot.groups.find((candidate) => candidate.group === 'column');
42
+ return `table, ${group === undefined ? 'no selection' : group.total > 0 ? `${group.picked.length} of ${group.total} ${group.group}s` : `${group.picked.length} ${group.group}${group.picked.length === 1 ? '' : 's'}`}`;
43
+ }
44
+ if (slot.kind === 'cards') {
45
+ const cards = slot.groups[0];
46
+ return `cards, ${cards?.total && cards.total > 0 ? `${cards.picked.length} of ${cards.total} cards` : `${cards?.picked.length ?? 0} cards`}`;
47
+ }
48
+ return slot.kind;
49
+ }
50
+ function slotHeading(slot, totalSlots) {
51
+ if (slot.label !== undefined && slot.label !== '')
52
+ return `**${slot.label}** — ${shape(slot)}`;
53
+ if (totalSlots === 1)
54
+ return undefined;
55
+ return `${code(slot.id)} — ${shape(slot)}`;
56
+ }
57
+ function slotLines(slot, totalSlots) {
58
+ const lines = [];
59
+ const heading = slotHeading(slot, totalSlots);
60
+ if (heading !== undefined)
61
+ lines.push(heading);
62
+ if (slot.raw !== undefined) {
63
+ lines.push(`Custom component (${code(slot.kind)}) response: ${JSON.stringify(slot.raw)}`);
64
+ }
65
+ else if (slot.text !== undefined) {
66
+ // A one-line field always shows its value inline — quoted, or "(left empty)" — because the
67
+ // whole answer is that one string; the heading already carries whether it was edited.
68
+ if (slot.text.singleLine === true || slot.text.edited)
69
+ lines.push(...verbatim('', slot.text.value));
70
+ else
71
+ lines.push('Accepted as written, unchanged');
72
+ }
73
+ else {
74
+ for (const group of slot.groups)
75
+ lines.push(...groupLines(group));
76
+ if (slot.freetext !== undefined)
77
+ lines.push(...verbatim('Other: ', slot.freetext));
78
+ }
79
+ for (const comment of slot.comments)
80
+ lines.push(...commentLines(comment));
81
+ return lines;
82
+ }
83
+ /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent. */
84
+ export function renderAnswerText(answer, responsePath) {
85
+ if (answer.kind === 'acknowledgement') {
86
+ return [
87
+ `The human saw **${answer.title}** and dismissed it. The page asked for nothing, so the acknowledgement is the whole answer.`,
88
+ '',
89
+ `Acknowledged ${answer.completedAt}. Response record: ${responsePath}`,
90
+ ].join('\n');
91
+ }
92
+ const answered = answer.slots.filter((slot) => slot.state === 'answered');
93
+ const lines = [`The human answered **${answer.title}**.`];
94
+ for (const slot of answered)
95
+ lines.push('', ...slotLines(slot, answer.slots.length));
96
+ lines.push('', `Answered ${answer.completedAt}. Typed responses: ${responsePath}`);
97
+ return lines.join('\n');
98
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProductPageComponents } from '../../types.js';
2
+ import { type CommentAnchor, type PageResponses, type PageSlot } from './page-schema.js';
3
+ export type PageAnswer = {
4
+ title: string;
5
+ subtitle?: string;
6
+ completedAt: string;
7
+ kind: 'answer' | 'acknowledgement';
8
+ digest: string;
9
+ slots: PageAnswerSlot[];
10
+ };
11
+ export type PageAnswerSlot = {
12
+ id: string;
13
+ kind: string;
14
+ label?: string;
15
+ state: 'answered' | 'unanswered';
16
+ blank: boolean;
17
+ groups: PageAnswerGroup[];
18
+ freetext?: string;
19
+ /** `singleLine` marks a one-line input, whose value is worth echoing even when unchanged. */
20
+ text?: {
21
+ value: string;
22
+ edited: boolean;
23
+ singleLine?: boolean;
24
+ };
25
+ comments: PageAnswerComment[];
26
+ raw?: Record<string, unknown>;
27
+ };
28
+ export type PageAnswerGroup = {
29
+ group: 'option' | 'card' | 'row' | 'column';
30
+ mode: 'single' | 'multi';
31
+ total: number;
32
+ picked: {
33
+ id: string;
34
+ label: string;
35
+ }[];
36
+ };
37
+ export type PageAnswerComment = {
38
+ anchor: CommentAnchor;
39
+ anchorLabel: string;
40
+ text: string;
41
+ };
42
+ export interface DescribePageAnswerOptions {
43
+ completedAt: string;
44
+ productKinds?: ProductPageComponents;
45
+ }
46
+ export declare function answerCommentAnchorLabel(slot: PageSlot, anchor: CommentAnchor): string;
47
+ /** Derive the one shared answer model from the immutable page manifest and a response map. */
48
+ /** `manifest` is unknown deliberately: this boundary validates persisted JSON before deriving. */
49
+ export declare function describePageAnswer(manifest: unknown, responses: PageResponses, opts: DescribePageAnswerOptions): PageAnswer;
50
+ /** The stable one-line digest stored in response.json and shown in history. */
51
+ export declare function buildAnswerDigest(slots: PageAnswerSlot[]): string;
@@ -0,0 +1,125 @@
1
+ import { isResponseBearingSlot, validatePageManifest, } from './page-schema.js';
2
+ function hasOwn(object, key) {
3
+ return Object.prototype.hasOwnProperty.call(object, key);
4
+ }
5
+ function responseRecord(response) {
6
+ return response !== null && typeof response === 'object' ? response : {};
7
+ }
8
+ function strings(value) {
9
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
10
+ }
11
+ function comments(value) {
12
+ return Array.isArray(value) ? value.filter((item) => item !== null && typeof item === 'object' && 'anchor' in item && 'text' in item) : [];
13
+ }
14
+ function picked(ids, items) {
15
+ const labels = new Map(items.map((item) => [item.id, item.label]));
16
+ return ids.map((id) => ({ id, label: labels.get(id) ?? id }));
17
+ }
18
+ function rowLabel(config, id) {
19
+ const row = config.rows?.find((candidate) => candidate.id === id);
20
+ const firstColumn = config.columns[0];
21
+ if (row === undefined || firstColumn === undefined || !hasOwn(row.cells, firstColumn.id))
22
+ return id;
23
+ return String(row.cells[firstColumn.id]);
24
+ }
25
+ function slotLabel(slot) {
26
+ const label = slot.config.label;
27
+ return typeof label === 'string' && label !== '' ? label : slot.id ?? slot.kind;
28
+ }
29
+ export function answerCommentAnchorLabel(slot, anchor) {
30
+ if (anchor.kind === 'whole')
31
+ return slotLabel(slot);
32
+ if (anchor.kind === 'range')
33
+ return anchor.quote;
34
+ if (anchor.kind === 'option')
35
+ return slot.config.options.find((option) => option.id === anchor.optionId)?.label ?? anchor.optionId;
36
+ if (anchor.kind === 'card')
37
+ return slot.config.cards?.find((card) => card.id === anchor.cardId)?.title ?? anchor.cardId;
38
+ if (anchor.kind === 'column')
39
+ return slot.config.columns.find((column) => column.id === anchor.columnId)?.label ?? anchor.columnId;
40
+ return rowLabel(slot.config, anchor.rowId);
41
+ }
42
+ function describeComments(slot, response) {
43
+ return comments(response.comments).map((comment) => ({ anchor: comment.anchor, anchorLabel: answerCommentAnchorLabel(slot, comment.anchor), text: comment.text }));
44
+ }
45
+ function describeSlot(slot, response) {
46
+ const state = response === undefined ? 'unanswered' : 'answered';
47
+ const value = responseRecord(response);
48
+ const configLabel = slot.config.label;
49
+ const base = {
50
+ id: slot.id,
51
+ kind: slot.kind,
52
+ ...(typeof configLabel === 'string' && configLabel !== '' ? { label: configLabel } : {}),
53
+ state,
54
+ };
55
+ if (slot.unvalidated === true) {
56
+ return { ...base, blank: response === undefined, groups: [], comments: [], ...(response === undefined ? {} : { raw: value }) };
57
+ }
58
+ if (slot.kind === 'options') {
59
+ const config = slot.config;
60
+ const freetext = typeof value.freetext === 'string' && value.freetext !== '' ? value.freetext : undefined;
61
+ const group = { group: 'option', mode: config.mode, total: config.options.length, picked: picked(strings(value.selectedOptionIds), config.options) };
62
+ return { ...base, blank: group.picked.length === 0 && freetext === undefined, groups: [group], ...(freetext === undefined ? {} : { freetext }), comments: describeComments(slot, value) };
63
+ }
64
+ if (slot.kind === 'text') {
65
+ const text = value;
66
+ const written = typeof text.text === 'string' ? text.text : '';
67
+ const singleLine = slot.config.singleLine === true;
68
+ return { ...base, blank: singleLine && written === '', groups: [], text: { value: written, edited: text.edited === true, ...(singleLine ? { singleLine: true } : {}) }, comments: describeComments(slot, value) };
69
+ }
70
+ if (slot.kind === 'table') {
71
+ const config = slot.config;
72
+ const groups = [];
73
+ if (config.rowSelect === 'single' || config.rowSelect === 'multi')
74
+ groups.push({ group: 'row', mode: config.rowSelect, total: config.rows?.length ?? 0, picked: strings(value.selectedRowIds).map((id) => ({ id, label: rowLabel(config, id) })) });
75
+ if (config.columnSelect === 'single' || config.columnSelect === 'multi')
76
+ groups.push({ group: 'column', mode: config.columnSelect, total: config.columns.length, picked: picked(strings(value.selectedColumnIds), config.columns) });
77
+ return { ...base, blank: groups.every((group) => group.picked.length === 0), groups, comments: describeComments(slot, value) };
78
+ }
79
+ if (slot.kind === 'cards') {
80
+ const config = slot.config;
81
+ const cards = (config.cards ?? []).map((card) => ({ id: card.id, label: card.title }));
82
+ const group = { group: 'card', mode: config.mode, total: cards.length, picked: picked(strings(value.selectedCardIds), cards) };
83
+ return { ...base, blank: group.picked.length === 0, groups: [group], comments: describeComments(slot, value) };
84
+ }
85
+ throw new Error(`slot "${slot.id}" kind "${slot.kind}" does not accept a response`);
86
+ }
87
+ /** Derive the one shared answer model from the immutable page manifest and a response map. */
88
+ /** `manifest` is unknown deliberately: this boundary validates persisted JSON before deriving. */
89
+ export function describePageAnswer(manifest, responses, opts) {
90
+ const page = validatePageManifest(manifest, opts.productKinds);
91
+ const slots = page.slots.filter(isResponseBearingSlot).map((slot) => describeSlot(slot, hasOwn(responses, slot.id) ? responses[slot.id] : undefined));
92
+ const answered = slots.filter((slot) => slot.state === 'answered');
93
+ return {
94
+ title: page.title,
95
+ ...(page.subtitle === undefined ? {} : { subtitle: page.subtitle }),
96
+ completedAt: opts.completedAt,
97
+ kind: slots.length === 0 ? 'acknowledgement' : 'answer',
98
+ digest: buildAnswerDigest(answered),
99
+ slots,
100
+ };
101
+ }
102
+ function groupDigest(group) {
103
+ if (group.picked.length === 0)
104
+ return 'no selection';
105
+ if (group.group === 'option' || group.group === 'card')
106
+ return group.picked.map((item) => item.label).join(', ');
107
+ return group.total > 0 ? `${group.picked.length} of ${group.total} ${group.group}s` : `${group.picked.length} ${group.group}${group.picked.length === 1 ? '' : 's'}`;
108
+ }
109
+ /** The stable one-line digest stored in response.json and shown in history. */
110
+ export function buildAnswerDigest(slots) {
111
+ return slots.map((slot) => {
112
+ if (slot.raw !== undefined)
113
+ return `${slot.kind} response`;
114
+ // A one-line answer IS its value; "edited" would say nothing about what was typed.
115
+ if (slot.text !== undefined) {
116
+ if (slot.text.singleLine === true)
117
+ return slot.text.value === '' ? 'empty' : slot.text.value;
118
+ return slot.text.edited ? 'edited' : 'unchanged';
119
+ }
120
+ const groups = slot.groups.map(groupDigest);
121
+ if (slot.freetext !== undefined)
122
+ groups.push(slot.freetext);
123
+ return groups.join(', ') || 'no selection';
124
+ }).join(' · ');
125
+ }
@@ -0,0 +1,14 @@
1
+ import type { PageComponentRegistration } from '../../types.js';
2
+ export interface ComponentDoc {
3
+ kind: string;
4
+ tag: string;
5
+ selectionLine: string;
6
+ contract: string;
7
+ }
8
+ export declare const BUILTIN_COMPONENT_DOCS: readonly ComponentDoc[];
9
+ /** Compact catalog rows shared by `human -h` and `human html list`. */
10
+ export declare const BUILTIN_COMPONENT_SELECTION_LINES: string[];
11
+ export declare function componentDoc(kind: string): ComponentDoc | undefined;
12
+ export declare function componentKinds(productKinds: readonly string[]): string[];
13
+ export declare function renderComponentCatalog(productComponents: readonly PageComponentRegistration[]): string;
14
+ export declare function renderComponentContract(kind: string, productComponents: readonly PageComponentRegistration[]): string | undefined;