@north-light/crouter 0.3.215 → 0.3.217

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.
@@ -14,8 +14,8 @@ optionsPanel.handleKey('', enter);
14
14
  optionsPanel.handleKey('y', key());
15
15
  assert.deepEqual(optionResponses, { choice: { selectedOptionIds: ['yes'], comments: [{ id: 'option:yes', anchor: { kind: 'option', optionId: 'yes' }, text: 'ship it' }] } });
16
16
  optionsPanel.unmount();
17
- // Multi-select empty Enter remains inert; selection confirmation lands on Summary before submit.
18
- const multi = manifest([{ id: 'toppings', kind: 'options', step: 0, config: { mode: 'multi', options: [{ id: 'mush', label: 'Mushroom' }, { id: 'onion', label: 'Onion' }] } }]);
17
+ // A required multi-select refuses an empty Enter; selection confirmation lands on Summary before submit.
18
+ const multi = manifest([{ id: 'toppings', kind: 'options', step: 0, config: { mode: 'multi', required: true, options: [{ id: 'mush', label: 'Mushroom' }, { id: 'onion', label: 'Onion' }] } }]);
19
19
  let multiResponses;
20
20
  const multiPanel = mountPanel({ manifest: multi, cols: 80, rows: 24, onComplete: (r) => { multiResponses = r; } });
21
21
  multiPanel.handleKey('', enter);
@@ -1,6 +1,6 @@
1
1
  import { isResponseBearingSlot } from '../../../core/human/page-schema.js';
2
2
  import { panItemReview, verticalCursor, wordLeftIndex, wordRightIndex } from './render.js';
3
- import { commentAnchors, commentIdFor, pageComplete, slotActions, slotAnswered, terminalAnswerable } from './slots.js';
3
+ import { commentAnchors, commentIdFor, pageComplete, responseAnswers, slotActions, slotAnswered, terminalAnswerable } from './slots.js';
4
4
  /** Only a single-mode options/cards pick jumps ahead; a table pick leaves the user on the table. */
5
5
  const autoAdvances = (slot, mode) => mode === 'single' && (slot.kind === 'options' || slot.kind === 'cards');
6
6
  export function handleKeypress(input, key, state, render, exit) {
@@ -143,18 +143,22 @@ function handleItemReview(input, key, state, render, exit) {
143
143
  render();
144
144
  return;
145
145
  }
146
- if (slot.kind === 'table' && terminalAnswerable(slot)) {
147
- const r = responseFor(slot, state.responses.get(slot.id));
148
- setResponse(state, slot, r);
149
- advanceToNextIncomplete(state);
150
- render();
151
- return;
152
- }
153
146
  if (slotAnswered(state, slot)) {
154
147
  advanceToNextIncomplete(state);
155
148
  render();
156
149
  return;
157
150
  }
151
+ // Enter settles a question whose current value already answers it: an optional picker
152
+ // left empty is a real answer, not a skipped one.
153
+ if (terminalAnswerable(slot)) {
154
+ const settled = responseFor(slot, state.responses.get(slot.id));
155
+ if (responseAnswers(state, slot, settled)) {
156
+ setResponse(state, slot, settled);
157
+ advanceToNextIncomplete(state);
158
+ render();
159
+ return;
160
+ }
161
+ }
158
162
  if (isResponseBearingSlot(slot)) {
159
163
  state.hint = terminalAnswerable(slot) ? 'Select at least one option (space to toggle), or q to go back' : 'This must be answered in Northlight';
160
164
  render();
@@ -1,4 +1,4 @@
1
- import type { CommentAnchor, PageManifest, PageSlot, TableConfig } from '../../../core/human/page-schema.js';
1
+ import type { CommentAnchor, PageManifest, PageSlot, SlotResponse, TableConfig } from '../../../core/human/page-schema.js';
2
2
  import type { TuiState } from './types.js';
3
3
  export type SelectGroup = 'option' | 'card' | 'row' | 'column';
4
4
  export type ActionRow = {
@@ -24,6 +24,8 @@ export declare function describeSlot(slot: PageSlot): string;
24
24
  export declare function terminalAnswerable(slot: PageSlot): boolean;
25
25
  export declare function slotActions(slot: PageSlot): ActionRow[];
26
26
  export declare function tableMarkdown(config: TableConfig): string;
27
+ /** Whether this value would be accepted as the slot's answer — the same reading crtrd applies on submit. */
28
+ export declare function responseAnswers(state: Pick<TuiState, 'manifest'>, slot: PageSlot, response: SlotResponse): boolean;
27
29
  export declare function slotAnswered(state: TuiState, slot: PageSlot): boolean;
28
30
  export declare function pageComplete(state: Pick<TuiState, 'manifest' | 'responses'>): boolean;
29
31
  export declare function commentIdFor(anchor: CommentAnchor): string;
@@ -88,17 +88,23 @@ export function tableMarkdown(config) {
88
88
  const divider = `| ${config.columns.map((column) => column.align === 'right' ? '---:' : '---').join(' | ')} |`;
89
89
  return [header, divider, ...(config.rows ?? []).map((row) => `| ${config.columns.map((column) => escapeCell(row.cells[column.id], column.mono === true)).join(' | ')} |`)].join('\n');
90
90
  }
91
- export function slotAnswered(state, slot) {
92
- if (!terminalAnswerable(slot) || slot.id === undefined || !state.responses.has(slot.id))
91
+ /** Whether this value would be accepted as the slot's answer — the same reading crtrd applies on submit. */
92
+ export function responseAnswers(state, slot, response) {
93
+ if (slot.id === undefined)
93
94
  return false;
94
95
  try {
95
- validatePartialPageResponses(state.manifest, { [slot.id]: state.responses.get(slot.id) });
96
+ validatePartialPageResponses(state.manifest, { [slot.id]: response });
96
97
  return true;
97
98
  }
98
99
  catch {
99
100
  return false;
100
101
  }
101
102
  }
103
+ export function slotAnswered(state, slot) {
104
+ if (!terminalAnswerable(slot) || slot.id === undefined || !state.responses.has(slot.id))
105
+ return false;
106
+ return responseAnswers(state, slot, state.responses.get(slot.id));
107
+ }
102
108
  export function pageComplete(state) {
103
109
  if (state.manifest.slots.some((slot) => isResponseBearingSlot(slot) && !terminalAnswerable(slot)))
104
110
  return false;
@@ -77,6 +77,6 @@ export declare function overlayParam(name: string, overrides?: Partial<FlagParam
77
77
  * happening. Same field, same prose, two flag names that cannot be confused. */
78
78
  export declare const DOC_RATIONALE_CONSTRAINT = "Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.";
79
79
  export declare const GUIDE_SURFACES = "Every doc appears in its directory\u2019s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing \u2014 entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc\u2019s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file\u2019s absolute path and basename, `./`-anchored globs vs its path relative to the store\u2019s owning repo dir, `match-frontmatter` predicates over the read file\u2019s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc\u2019s canonical name, `./` anchored to this doc\u2019s own name directory); `command` fires when a matching shell command runs (globs vs the whole command string, `*` crossing `/`). `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly \u2014 a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.";
80
- export declare const GUIDE_ROUTING_LINE = "The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: \"because only genuine first principles belong in taste memory.\" Bad: \"because keeping the test loop fast and free of speculative tests protects the development pace\" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: \"because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation.\" Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.";
80
+ export declare const GUIDE_ROUTING_LINE = "The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its when-clause: it names an observable circumstance in the reader\u2019s current work, not the doc\u2019s topic reworded as an activity. If the WHEN can be inferred from the title alone, it is not a real trigger. Bad on a todo list: \"When planning or prioritizing work across this profile.\" Good: \"When the user mentions something from their todos, or asks what is still outstanding across this profile.\" The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: \"because only genuine first principles belong in taste memory.\" Bad: \"because keeping the test loop fast and free of speculative tests protects the development pace\" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: \"because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation.\" Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.";
81
81
  export declare const GUIDE_PREDICATE_VOCABULARY = "Gate and match-frontmatter share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.";
82
82
  export declare const GUIDE_DOC_LINKS = "Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes. A bare directory name is a valid link too: following it returns that directory\u2019s listing, a browse entrance rather than a doc. Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document or directory, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias or label form.";
@@ -343,6 +343,6 @@ export function overlayParam(name, overrides = {}, extraConstraint) {
343
343
  * happening. Same field, same prose, two flag names that cannot be confused. */
344
344
  export const DOC_RATIONALE_CONSTRAINT = 'Frontmatter rationale \u2014 the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only via `memory read --frontmatter`. Omitting the flag preserves an existing rationale unchanged.';
345
345
  export const GUIDE_SURFACES = 'Every doc appears in its directory’s listing by default (suppress with --unlisted); everything beyond the listing is explicit `surfaces` routing — entries declaring WHEN the doc delivers and how much. Events: `boot` delivers in the boot catalog every agent sees; `workspace-open` delivers in first-message context when cwd/profile mounts the doc’s project store (project stores only); `read` fires when the agent reads a matching file (globs vs the file’s absolute path and basename, `./`-anchored globs vs its path relative to the store’s owning repo dir, `match-frontmatter` predicates over the read file’s own YAML frontmatter); `memory-read` fires when another memory doc is read (globs vs that doc’s canonical name, `./` anchored to this doc’s own name directory); `command` fires when a matching shell command runs (globs vs the whole command string, `*` crossing `/`). `at` sets how much delivers: `name` the bare tag, `preview` the routing line, `content` the whole body. Each entry is paid by every future agent it fires on, so default down: no surfaces at all is correct for reference depth reached through listings and [[links]], and when a doc fits in a single sentence, deliver `content` directly — a `preview` routing line would run longer than the doc itself. Sentence-length `content` docs are correct; never pad a memory to be more verbose than the rule or fact it carries.';
346
- export const GUIDE_ROUTING_LINE = 'The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: "because only genuine first principles belong in taste memory." Bad: "because keeping the test loop fast and free of speculative tests protects the development pace" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: "because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation." Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.';
346
+ export const GUIDE_ROUTING_LINE = 'The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its when-clause: it names an observable circumstance in the reader\u2019s current work, not the doc\u2019s topic reworded as an activity. If the WHEN can be inferred from the title alone, it is not a real trigger. Bad on a todo list: "When planning or prioritizing work across this profile." Good: "When the user mentions something from their todos, or asks what is still outstanding across this profile." The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: "because only genuine first principles belong in taste memory." Bad: "because keeping the test loop fast and free of speculative tests protects the development pace" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: "because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation." Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.';
347
347
  export const GUIDE_PREDICATE_VOCABULARY = 'Gate and match-frontmatter share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.';
348
348
  export const GUIDE_DOC_LINKS = 'Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes. A bare directory name is a valid link too: following it returns that directory\u2019s listing, a browse entrance rather than a doc. Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document or directory, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias or label form.';
@@ -1,2 +1 @@
1
- export declare function assertProfileDefaultKind(kind: string): void;
2
1
  export declare const kindLeaf: import("../../core/command.js").LeafDef;
@@ -1,19 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { InputError } from '../../core/io.js';
3
- import { readMergedLaunchConfig } from '../../core/config.js';
2
+ import { assertInstalledKind, readMergedLaunchConfig } from '../../core/config.js';
4
3
  import { loadProfileManifest, setProfileDefaultKind } from '../../core/profiles/manifest.js';
5
4
  import { stateBlock } from '../../core/help.js';
6
- export function assertProfileDefaultKind(kind) {
7
- const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
8
- if (!kinds.includes(kind)) {
9
- throw new InputError({
10
- error: 'unknown_kind',
11
- message: `unknown kind: ${kind}`,
12
- field: 'kind',
13
- next: `Valid kinds: ${kinds.join(', ')}.`,
14
- });
15
- }
16
- }
17
5
  function kindsStateBlock() {
18
6
  return stateBlock('kinds', { count: Object.keys(readMergedLaunchConfig().kinds).length }, Object.keys(readMergedLaunchConfig().kinds).sort().join('\n'));
19
7
  }
@@ -39,7 +27,7 @@ export const kindLeaf = defineLeaf({
39
27
  },
40
28
  run: async (input) => {
41
29
  const kind = input['kind'];
42
- assertProfileDefaultKind(kind);
30
+ assertInstalledKind(kind);
43
31
  const { profileId } = loadProfileManifest(input['profile']);
44
32
  const { manifest } = setProfileDefaultKind(profileId, kind);
45
33
  return {
@@ -5,7 +5,9 @@ import { loadProfileManifest, updateProfileMetadata } from '../../core/profiles/
5
5
  * with the manifest store (`assertProfileMetadata`); this only splits. */
6
6
  export function resolveMetaPairs(raw, field) {
7
7
  const pairs = Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' && raw !== '' ? [raw] : [];
8
- const out = {};
8
+ // Null prototype: on a plain object a `__proto__` key would silently
9
+ // vanish here instead of reaching the store's key-shape rejection.
10
+ const out = Object.create(null);
9
11
  for (const pair of pairs) {
10
12
  const eq = pair.indexOf('=');
11
13
  if (eq <= 0) {
@@ -1,7 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { usage } from '../../core/errors.js';
3
+ import { assertInstalledKind } from '../../core/config.js';
3
4
  import { createProfile, ensureRootProfile, profileRoot } from '../../core/profiles/manifest.js';
4
- import { assertProfileDefaultKind } from './kind.js';
5
5
  import { resolveMetaPairs } from './meta.js';
6
6
  export const newLeaf = defineLeaf({
7
7
  name: 'new',
@@ -23,7 +23,7 @@ export const newLeaf = defineLeaf({
23
23
  name: 'root',
24
24
  type: 'bool',
25
25
  required: false,
26
- constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`.',
26
+ constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`/`--default-kind`/`--meta`.',
27
27
  },
28
28
  {
29
29
  kind: 'flag',
@@ -88,7 +88,7 @@ export const newLeaf = defineLeaf({
88
88
  if (name === undefined)
89
89
  throw usage('provide `--name <name>` to create a profile, or `--root` to provision the stable root profile.');
90
90
  if (defaultKind !== undefined)
91
- assertProfileDefaultKind(defaultKind);
91
+ assertInstalledKind(defaultKind);
92
92
  const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : [], {
93
93
  defaultKind,
94
94
  ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
@@ -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 } from '../../core/profiles/manifest.js';
4
+ import { loadProfileManifest, 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',
@@ -46,7 +46,7 @@ export const showLeaf = defineLeaf({
46
46
  home: manifest.home,
47
47
  paused_at: manifest.paused_at,
48
48
  default_kind: manifest.default_kind ?? null,
49
- metadata: manifest.metadata ?? {},
49
+ metadata: sanitizeProfileMetadata(manifest.metadata),
50
50
  created_at: manifest.created_at,
51
51
  last_used_at: manifest.last_used_at,
52
52
  path: profileRoot(profileId),
@@ -13,7 +13,7 @@ import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
14
  import { nodeDir } from '../canvas/paths.js';
15
15
  import { atomicWriteJson, bindTicketReplyRoute, pageManifestPath } from '../human/convention.js';
16
- import { cancelTicket } from '../human/tickets.js';
16
+ import { cancelTicket, readTicketResult } from '../human/tickets.js';
17
17
  import { humanCancel } from '../../commands/human/queue.js';
18
18
  import { createApiServer } from '../../daemon/api/server.js';
19
19
  import { apiSocketPath } from '../canvas/paths.js';
@@ -46,7 +46,7 @@ function node(id) {
46
46
  }
47
47
  /** Publish a reply-bearing page ticket in the bridge node's directory, exactly as
48
48
  * `human send` does: reply route first, then the page manifest. */
49
- function publishTicket(id) {
49
+ function publishTicket(id, slots = []) {
50
50
  const dir = nodeDir(id);
51
51
  mkdirSync(dir, { recursive: true });
52
52
  bindTicketReplyRoute(dir, id);
@@ -58,7 +58,7 @@ function publishTicket(id) {
58
58
  delivery: { placement: 'inline', inbox: true, reply: true },
59
59
  document: 'page.tsx',
60
60
  steps: 1,
61
- slots: [],
61
+ slots,
62
62
  });
63
63
  }
64
64
  before(() => {
@@ -80,13 +80,31 @@ after(() => {
80
80
  rmSync(home, { recursive: true, force: true });
81
81
  delete process.env['CRTR_HOME'];
82
82
  });
83
- test('cancel on an already-canceled ticket is a no-op, never throws on the finalize', async () => {
84
- const id = 'canceledJob';
85
- createNode(node(id));
86
- publishTicket(id);
87
- cancelTicket(nodeDir(id), { actor: 'human' }); // settled by the first cancel
88
- const res = (await humanCancel.run({ ticket_id: id }));
89
- assert.equal(res['canceled'], false);
90
- assert.equal(res['reason'], 'already_resolved');
91
- assert.equal(res['ticket_id'], id);
83
+ test('cancel accepts newer page config and treats an already-canceled ticket as a no-op', async () => {
84
+ const newerId = 'newerPage';
85
+ createNode(node(newerId));
86
+ publishTicket(newerId, [{
87
+ id: 'decision',
88
+ kind: 'options',
89
+ step: 0,
90
+ config: {
91
+ label: 'Decision',
92
+ body: 'Choose one.',
93
+ options: [{ id: 'yes', label: 'Yes' }],
94
+ mode: 'single',
95
+ futureField: true,
96
+ },
97
+ }]);
98
+ const canceled = (await humanCancel.run({ ticket_id: newerId }));
99
+ assert.equal(canceled['canceled'], true);
100
+ assert.equal(canceled['ticket_id'], newerId);
101
+ assert.equal(readTicketResult(nodeDir(newerId))?.kind, 'canceled');
102
+ const settledId = 'canceledJob';
103
+ createNode(node(settledId));
104
+ publishTicket(settledId);
105
+ cancelTicket(nodeDir(settledId), { actor: 'human' });
106
+ const settled = (await humanCancel.run({ ticket_id: settledId }));
107
+ assert.equal(settled['canceled'], false);
108
+ assert.equal(settled['reason'], 'already_resolved');
109
+ assert.equal(settled['ticket_id'], settledId);
92
110
  });
@@ -141,6 +141,11 @@ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfile
141
141
  * existence/launch-menu enumeration is a caller concern); this only
142
142
  * resolves the config for a kind the caller already knows about. */
143
143
  export declare function resolveKindConfig(kind: string): KindConfig | undefined;
144
+ /** Reject a kind no scope registers. The one validation gate for every
145
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
146
+ * route) — a stored unknown kind would poison future omitted-kind creates
147
+ * through `default_kind` resolution. */
148
+ export declare function assertInstalledKind(kind: string): void;
144
149
  /** The sub-kinds available to spawn FROM a given top-level kind — every
145
150
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
146
151
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -2,6 +2,7 @@ import { dirname, join } from 'node:path';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
4
4
  import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
5
+ import { usage } from './errors.js';
5
6
  import { emitEvent } from './events/emit.js';
6
7
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
7
8
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
@@ -753,6 +754,20 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
753
754
  export function resolveKindConfig(kind) {
754
755
  return readMergedLaunchConfig().kinds[kind];
755
756
  }
757
+ /** Reject a kind no scope registers. The one validation gate for every
758
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
759
+ * route) — a stored unknown kind would poison future omitted-kind creates
760
+ * through `default_kind` resolution. */
761
+ export function assertInstalledKind(kind) {
762
+ const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
763
+ if (!kinds.includes(kind)) {
764
+ throw usage(`unknown kind: ${kind}`, {
765
+ received: kind,
766
+ field: 'kind',
767
+ next: `Valid kinds: ${kinds.join(', ')}.`,
768
+ });
769
+ }
770
+ }
756
771
  /** The sub-kinds available to spawn FROM a given top-level kind — every
757
772
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
758
773
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -78,3 +78,15 @@ test('final and partial response validation share slot-addressed rules', () => {
78
78
  assert.throws(() => validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['maybe'], comments: [] } }), /unknown option id "maybe"/);
79
79
  assert.throws(() => validatePartialPageResponses(manifest, { extra: {} }), /unknown or non-response-bearing slot id "extra"/);
80
80
  });
81
+ // Which questions have a minimum is the author's call, and both defaults matter to a
82
+ // renderer mirroring this rule: pick-many is answered by ticking nothing, pick-one is not.
83
+ test('an empty answer is refused only where the question is required', () => {
84
+ const manifest = derivePageManifestFromDocument(page(`<UserQuestion id="risks" label="Tick everything that applies" body="Skip it if none do." mode="multi" options={[{ id: "data", label: "Data" }]} />
85
+ <UserQuestion id="lane" label="Which lane?" body="Ships tonight." mode="single" required={false} options={[{ id: "now", label: "Now" }]} />
86
+ <UserText id="window" label="Maintenance window" initialText="" required />`));
87
+ const empty = { risks: { selectedOptionIds: [], comments: [] }, lane: { selectedOptionIds: [], comments: [] }, window: { text: 'Sunday 02:00', edited: true } };
88
+ assert.deepEqual(validatePageResponses(manifest, empty), empty);
89
+ assert.throws(() => validatePageResponses(manifest, { ...empty, window: { text: ' ', edited: true } }), /slot "window" is required and was left empty/);
90
+ const required = derivePageManifestFromDocument(page('<UserQuestion id="risks" label="Tick everything that applies" body="At least one." mode="multi" required options={[{ id: "data", label: "Data" }]} />'));
91
+ assert.throws(() => validatePageResponses(required, { risks: { selectedOptionIds: [], comments: [] } }), /multi-select slot "risks" requires a selection/);
92
+ });
@@ -79,12 +79,13 @@ Props
79
79
  - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string, recommended?:boolean}\`.
80
80
  - \`recommended\` on one option marks it the suggested answer. At most one option per question may set it. The inbox shows it on the row, and when this question is the page's only response-bearing component the user can accept it from the list with one keystroke, without opening the page. Recommend when you have a view worth acting on; leave it off when the choice is genuinely theirs.
81
81
  - \`mode\` — required: \`single\` or \`multi\`.
82
+ - \`required?:boolean\` — whether an empty answer is refused. Defaults to the mode: a \`single\` question must be answered, a \`multi\` one need not be, because leaving every box unticked says "none of these apply". Set \`required\` when the mode's default is wrong — \`required={false}\` on a \`single\` question the user may skip, \`required\` on a \`multi\` one where at least one tick is the point.
82
83
  - \`label\` — required nonempty string: the question itself, drawn as the bold first line.
83
84
  - \`body\` — required nonempty string: markdown rendered above the choices — the context under the question a label cannot carry. Display-only; contributes nothing to the response.
84
85
  - \`allowFreetext?:boolean\`, \`freetextLabel?:string\`, \`freetextPlaceholder?:string\` — adds a final "something else" choice, drawn as one more row with its own control and a field to write in rather than as a note on the question. It is exclusive on a \`single\` question (choosing it clears the selection, and choosing an option clears what was written) and just another box on a \`multi\` one. \`freetextLabel\` names that row, default "Something else".
85
86
 
86
87
  Response rules
87
- - \`selectedOptionIds\` contains unique known option ids. A \`single\` response has at most one selected id and requires exactly one selection or nonempty allowed \`freetext\`; a \`multi\` response requires a selection, comment, or nonempty allowed \`freetext\`.
88
+ - \`selectedOptionIds\` contains unique known option ids; a \`single\` response has at most one. When the question is required, a \`single\` response needs exactly one selection or nonempty allowed \`freetext\`, and a \`multi\` response needs a selection, comment, or nonempty allowed \`freetext\`.
88
89
  - \`freetext\` is accepted only when \`allowFreetext:true\`.
89
90
  - ${COMMENT_SHAPE} UserQuestion accepts only \`option\` anchors; \`optionId\` must name a configured option.
90
91
 
@@ -116,6 +117,7 @@ Props
116
117
  - \`label?:string\` — the component's label; required by the multi-response page rule.
117
118
  - \`placeholder?:string\` — the hint shown while the field is empty.
118
119
  - \`singleLine?:boolean\` — swaps the writing surface for one compact form field.
120
+ - \`required?:boolean\` — defaults false. When set, a field left empty (whitespace only) is refused, and the inbox marks it unanswered rather than sending it.
119
121
 
120
122
  The field is always writable; there is no read-only mode, and the config rejects an \`editable\` prop.
121
123
 
@@ -140,6 +142,7 @@ Props
140
142
  - \`rows?:\` array of \`{id:string (nonempty, unique), cells:Record<string,string|number|boolean|null>}\`. Rows are inline — map your data into this array in the module.
141
143
  - \`rowSelect?:"none"|"single"|"multi"\` — defaults to \`none\`.
142
144
  - \`columnSelect?:"none"|"single"|"multi"\` — defaults to \`none\`.
145
+ - \`required?:boolean\` — defaults false. When set on a selectable table, a response selecting no row and no column is refused.
143
146
  - \`label?:string\` — the component's label; required by the multi-response page rule when the table is selectable.
144
147
  - \`body?:string\` — optional markdown rendered above the table. Display-only; it contributes nothing to the response.
145
148
 
@@ -169,6 +172,7 @@ Props
169
172
  - \`cards?:\` array of \`{id:string (nonempty, unique), title:string (nonempty), subtitle?:string, body?:string, tag?:string}\`. Cards are inline — map your data into this array in the module.
170
173
  - \`mode\` — required: \`single\` or \`multi\`.
171
174
  - \`columns?:2|3\` — optional layout column count.
175
+ - \`required?:boolean\` — defaults false. When set, a response selecting no card is refused.
172
176
  - \`label?:string\` — the component's label; required by the multi-response page rule.
173
177
  - \`body?:string\` — optional markdown rendered above the cards. Display-only; it contributes nothing to the response.
174
178
 
@@ -41,6 +41,7 @@ export declare const optionsConfigSchema: z.ZodObject<{
41
41
  single: "single";
42
42
  multi: "multi";
43
43
  }>;
44
+ required: z.ZodOptional<z.ZodBoolean>;
44
45
  allowFreetext: z.ZodOptional<z.ZodBoolean>;
45
46
  freetextLabel: z.ZodOptional<z.ZodString>;
46
47
  freetextPlaceholder: z.ZodOptional<z.ZodString>;
@@ -67,6 +68,7 @@ export declare const textConfigSchema: z.ZodObject<{
67
68
  initialText: z.ZodString;
68
69
  placeholder: z.ZodOptional<z.ZodString>;
69
70
  singleLine: z.ZodOptional<z.ZodBoolean>;
71
+ required: z.ZodOptional<z.ZodBoolean>;
70
72
  }, z.core.$strict>;
71
73
  export type TextConfig = z.infer<typeof textConfigSchema>;
72
74
  export declare const textResponseSchema: z.ZodObject<{
@@ -119,6 +121,7 @@ export declare const tableConfigSchema: z.ZodObject<{
119
121
  single: "single";
120
122
  multi: "multi";
121
123
  }>>;
124
+ required: z.ZodOptional<z.ZodBoolean>;
122
125
  }, z.core.$strict>;
123
126
  export type TableConfig = z.infer<typeof tableConfigSchema>;
124
127
  export declare const tableResponseSchema: z.ZodObject<{
@@ -167,6 +170,7 @@ export declare const cardsConfigSchema: z.ZodObject<{
167
170
  multi: "multi";
168
171
  }>;
169
172
  columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>]>>;
173
+ required: z.ZodOptional<z.ZodBoolean>;
170
174
  }, z.core.$strict>;
171
175
  export type CardsConfig = z.infer<typeof cardsConfigSchema>;
172
176
  export declare const cardsResponseSchema: z.ZodObject<{
@@ -228,6 +232,14 @@ export interface PageManifest {
228
232
  slots: PageSlot[];
229
233
  source?: PageSource;
230
234
  }
235
+ export declare const pageDeliverySchema: z.ZodObject<{
236
+ placement: z.ZodEnum<{
237
+ inline: "inline";
238
+ panel: "panel";
239
+ }>;
240
+ inbox: z.ZodBoolean;
241
+ reply: z.ZodBoolean;
242
+ }, z.core.$strict>;
231
243
  export declare const BUILTIN_PAGE_CONFIG_SCHEMAS: Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>;
232
244
  export declare const BUILTIN_PAGE_RESPONSE_SCHEMAS: Partial<Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>>;
233
245
  export declare function issueText(error: z.ZodError): string;
@@ -266,6 +278,8 @@ export declare function validateAuthoredPageManifest(parsed: unknown, productCom
266
278
  export declare function createPageManifestSchema(): z.ZodType<PageManifest>;
267
279
  export declare const pageManifestSchema: z.ZodType<PageManifest, unknown, z.core.$ZodTypeInternals<PageManifest, unknown>>;
268
280
  export type PageResponses = Record<string, SlotResponse>;
281
+ /** A picker's minimum: authored `required` wins, else the mode's own reading of an empty answer. */
282
+ export declare function optionsRequired(config: OptionsConfig): boolean;
269
283
  /** Validate a complete final response map. */
270
284
  export declare function validatePageResponses(manifest: PageManifest, responses: unknown): PageResponses;
271
285
  /** Validate an autosaved response map, permitting omitted response-bearing components. */
@@ -28,6 +28,9 @@ export const optionsConfigSchema = z.object({
28
28
  body: z.string().optional(),
29
29
  options: z.array(optionSchema).min(1).superRefine((items, ctx) => uniqueIds(items, ctx, 'option')),
30
30
  mode: z.enum(['single', 'multi']),
31
+ // Defaults with the mode: a radio group means "pick one", while an empty checkbox
32
+ // list is itself an answer ("none of these apply").
33
+ required: z.boolean().optional(),
31
34
  allowFreetext: z.boolean().optional(),
32
35
  freetextLabel: z.string().optional(),
33
36
  freetextPlaceholder: z.string().optional(),
@@ -40,7 +43,7 @@ export const optionsResponseSchema = z.object({ selectedOptionIds: z.array(itemI
40
43
  // Text is a writing surface, always: its whole point is the string the user hands back, so
41
44
  // there is no read-only mode. `singleLine` only swaps the multi-line surface for one compact
42
45
  // form field — a subject line, a name, a URL.
43
- export const textConfigSchema = z.object({ label: z.string().optional(), initialText: z.string(), placeholder: z.string().optional(), singleLine: z.boolean().optional() }).strict();
46
+ export const textConfigSchema = z.object({ label: z.string().optional(), initialText: z.string(), placeholder: z.string().optional(), singleLine: z.boolean().optional(), required: z.boolean().optional() }).strict();
44
47
  export const textResponseSchema = z.object({ text: z.string(), edited: z.boolean() }).strict();
45
48
  export const tableColumnSchema = z.object({ id: itemIdSchema, label: z.string().min(1), align: z.enum(['left', 'right']).optional(), mono: z.boolean().optional() }).strict();
46
49
  export const tableCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
@@ -53,6 +56,7 @@ const tableConfigFields = {
53
56
  rows: tableRowsSchema.optional(),
54
57
  rowSelect: selectModeSchema.default('none'),
55
58
  columnSelect: selectModeSchema.default('none'),
59
+ required: z.boolean().optional(),
56
60
  };
57
61
  /** Table config: its data is always embedded. */
58
62
  export const tableConfigSchema = z.object(tableConfigFields).strict();
@@ -65,6 +69,7 @@ const cardsConfigFields = {
65
69
  cards: cardsSchema.optional(),
66
70
  mode: z.enum(['single', 'multi']),
67
71
  columns: z.union([z.literal(2), z.literal(3)]).optional(),
72
+ required: z.boolean().optional(),
68
73
  };
69
74
  /** Cards config: its data is always embedded. */
70
75
  export const cardsConfigSchema = z.object(cardsConfigFields).strict();
@@ -81,14 +86,14 @@ const chartConfigFields = {
81
86
  /** Chart config: its data is always embedded. */
82
87
  export const chartConfigSchema = z.object(chartConfigFields).strict();
83
88
  const sourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), emittedAt: z.string().datetime({ offset: true }).optional(), profileName: z.string().optional(), nodeId: z.string().optional() }).strict();
84
- const deliverySchema = z.object({ placement: z.enum(['inline', 'panel']), inbox: z.boolean(), reply: z.boolean() }).strict();
89
+ export const pageDeliverySchema = z.object({ placement: z.enum(['inline', 'panel']), inbox: z.boolean(), reply: z.boolean() }).strict();
85
90
  const rawSlotSchema = z.object({ id: idSchema.optional(), kind: z.string().min(1), step: z.number().int().nonnegative(), config: jsonObjectSchema, unvalidated: z.literal(true).optional(), display: z.literal(true).optional() }).strict();
86
91
  const rawPageManifestSchema = z.object({
87
92
  schema: z.literal('crtr.page/v2'),
88
93
  dialect: z.enum(['jsx', 'html']),
89
94
  title: z.string().trim().min(1),
90
95
  subtitle: z.string().optional(),
91
- delivery: deliverySchema,
96
+ delivery: pageDeliverySchema,
92
97
  document: z.enum(['page.tsx', 'page.html']),
93
98
  steps: z.number().int().positive(),
94
99
  slots: z.array(rawSlotSchema),
@@ -254,6 +259,10 @@ function validateComments(slot, comments) {
254
259
  throw new Error(`slot "${slot.id}" does not allow comment anchor kind "${anchor.kind}"`);
255
260
  }
256
261
  }
262
+ /** A picker's minimum: authored `required` wins, else the mode's own reading of an empty answer. */
263
+ export function optionsRequired(config) {
264
+ return config.required ?? config.mode === 'single';
265
+ }
257
266
  function parseResponse(slot, raw) {
258
267
  if (slot.unvalidated === true) {
259
268
  const result = jsonObjectSchema.safeParse(raw);
@@ -276,10 +285,12 @@ function parseResponse(slot, raw) {
276
285
  if (config.mode === 'single' && response.selectedOptionIds.length > 1)
277
286
  throw new Error(`slot "${slot.id}" is single-select but received ${response.selectedOptionIds.length} option ids`);
278
287
  const hasFreetext = config.allowFreetext === true && (response.freetext?.trim() ?? '') !== '';
279
- if (config.mode === 'single' && response.selectedOptionIds.length !== 1 && !hasFreetext)
280
- throw new Error(`single-select slot "${slot.id}" requires exactly one selection or nonempty allowed freetext`);
281
- if (config.mode === 'multi' && response.selectedOptionIds.length === 0 && response.comments.length === 0 && !hasFreetext)
282
- throw new Error(`multi-select slot "${slot.id}" requires a selection, comment, or nonempty allowed freetext`);
288
+ if (optionsRequired(config)) {
289
+ if (config.mode === 'single' && response.selectedOptionIds.length !== 1 && !hasFreetext)
290
+ throw new Error(`single-select slot "${slot.id}" requires exactly one selection or nonempty allowed freetext`);
291
+ if (config.mode === 'multi' && response.selectedOptionIds.length === 0 && response.comments.length === 0 && !hasFreetext)
292
+ throw new Error(`multi-select slot "${slot.id}" requires a selection, comment, or nonempty allowed freetext`);
293
+ }
283
294
  validateComments(slot, response.comments);
284
295
  return response;
285
296
  }
@@ -287,6 +298,8 @@ function parseResponse(slot, raw) {
287
298
  const result = textResponseSchema.safeParse(raw);
288
299
  if (!result.success)
289
300
  throw new Error(`invalid response for slot "${slot.id}": ${issueText(result.error)}`);
301
+ if (slot.config.required === true && result.data.text.trim() === '')
302
+ throw new Error(`slot "${slot.id}" is required and was left empty`);
290
303
  return result.data;
291
304
  }
292
305
  if (slot.kind === 'table') {
@@ -305,6 +318,8 @@ function parseResponse(slot, raw) {
305
318
  for (const id of response.selectedColumnIds)
306
319
  if (!columnIds.has(id))
307
320
  throw new Error(`slot "${slot.id}" selects unknown column id "${id}"`);
321
+ if (config.required === true && response.selectedRowIds.length === 0 && response.selectedColumnIds.length === 0)
322
+ throw new Error(`slot "${slot.id}" is required and nothing is selected`);
308
323
  if ((config.rowSelect === 'none' && response.selectedRowIds.length > 0) || (config.rowSelect === 'single' && response.selectedRowIds.length > 1))
309
324
  throw new Error(`slot "${slot.id}" response violates row select mode "${config.rowSelect}"`);
310
325
  if ((config.columnSelect === 'none' && response.selectedColumnIds.length > 0) || (config.columnSelect === 'single' && response.selectedColumnIds.length > 1))
@@ -324,6 +339,8 @@ function parseResponse(slot, raw) {
324
339
  throw new Error(`slot "${slot.id}" selects unknown card id "${id}"`);
325
340
  if (config.mode === 'single' && response.selectedCardIds.length > 1)
326
341
  throw new Error(`slot "${slot.id}" is single-select but received ${response.selectedCardIds.length} card ids`);
342
+ if (config.required === true && response.selectedCardIds.length === 0)
343
+ throw new Error(`slot "${slot.id}" is required and nothing is selected`);
327
344
  return response;
328
345
  }
329
346
  throw new Error(`slot "${slot.id}" kind "${slot.kind}" does not accept a response`);
@@ -12,3 +12,5 @@ export declare function deriveHtmlPageManifest(document: string, source?: PageSo
12
12
  export declare function derivePageManifest(sourceFile: string, productComponents?: ProductPageComponents, source?: PageSource, delivery?: PageDelivery): PageManifest;
13
13
  /** Read and validate page.json from a ticket directory. */
14
14
  export declare function parsePage(dir: string): PageManifest;
15
+ /** Read only the stable delivery envelope without interpreting component config. */
16
+ export declare function parsePageDelivery(dir: string): PageDelivery;
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { transformSync } from 'esbuild';
4
4
  import ts from 'typescript';
5
5
  import { BUILTIN_KIND_BY_TAG, BUILTIN_PAGE_COMPONENT_TAGS, DISPLAY_PAGE_COMPONENT_TAGS, pageComponentTag } from './page-catalog.js';
6
- import { isResponseBearingSlot, validateAuthoredPageManifest, validatePageManifest } from './page-schema.js';
6
+ import { isResponseBearingSlot, pageDeliverySchema, validateAuthoredPageManifest, validatePageManifest } from './page-schema.js';
7
7
  import { evaluatePageTree, jsonProps } from './page-eval.js';
8
8
  import { PageAuthoringError } from './page-errors.js';
9
9
  export { PageAuthoringError };
@@ -174,14 +174,25 @@ export function deriveHtmlPageManifest(document, source, delivery = { placement:
174
174
  export function derivePageManifest(sourceFile, productComponents = [], source, delivery) {
175
175
  return derivePageManifestFromDocument(readFileSync(sourceFile, 'utf8'), productComponents, source, delivery);
176
176
  }
177
- /** Read and validate page.json from a ticket directory. */
178
- export function parsePage(dir) {
179
- let raw;
177
+ function readPageJson(dir) {
180
178
  try {
181
- raw = JSON.parse(readFileSync(join(dir, 'page.json'), 'utf8'));
179
+ return JSON.parse(readFileSync(join(dir, 'page.json'), 'utf8'));
182
180
  }
183
181
  catch {
184
182
  throw new PageAuthoringError('page.json is not valid JSON');
185
183
  }
186
- return validatePageManifest(raw);
184
+ }
185
+ /** Read and validate page.json from a ticket directory. */
186
+ export function parsePage(dir) {
187
+ return validatePageManifest(readPageJson(dir));
188
+ }
189
+ /** Read only the stable delivery envelope without interpreting component config. */
190
+ export function parsePageDelivery(dir) {
191
+ const raw = readPageJson(dir);
192
+ const parsed = pageDeliverySchema.safeParse(typeof raw === 'object' && raw !== null && !Array.isArray(raw)
193
+ ? raw['delivery']
194
+ : undefined);
195
+ if (!parsed.success)
196
+ throw new PageAuthoringError('page.json has an invalid delivery envelope');
197
+ return parsed.data;
187
198
  }