@crouton-kit/crouter 0.3.8 → 0.3.12

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 (184) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +25 -27
  23. package/dist/commands/{job.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/{agent.d.ts → daemon.d.ts} +1 -1
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +15 -427
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +8 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +12 -681
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +9 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/__tests__/resolver.test.js +69 -1
  84. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  85. package/dist/core/__tests__/unknown-path.test.js +52 -0
  86. package/dist/core/bootstrap.d.ts +2 -0
  87. package/dist/core/bootstrap.js +66 -0
  88. package/dist/core/canvas/attention.d.ts +24 -0
  89. package/dist/core/canvas/attention.js +94 -0
  90. package/dist/core/canvas/canvas.d.ts +40 -0
  91. package/dist/core/canvas/canvas.js +210 -0
  92. package/dist/core/canvas/db.d.ts +7 -0
  93. package/dist/core/canvas/db.js +61 -0
  94. package/dist/core/canvas/index.d.ts +4 -0
  95. package/dist/core/canvas/index.js +6 -0
  96. package/dist/core/canvas/paths.d.ts +16 -0
  97. package/dist/core/canvas/paths.js +62 -0
  98. package/dist/core/canvas/render.d.ts +30 -0
  99. package/dist/core/canvas/render.js +186 -0
  100. package/dist/core/canvas/types.d.ts +87 -0
  101. package/dist/core/canvas/types.js +8 -0
  102. package/dist/core/command.d.ts +63 -2
  103. package/dist/core/command.js +97 -24
  104. package/dist/core/feed/feed.d.ts +43 -0
  105. package/dist/core/feed/feed.js +116 -0
  106. package/dist/core/feed/inbox.d.ts +50 -0
  107. package/dist/core/feed/inbox.js +124 -0
  108. package/dist/core/frontmatter.d.ts +10 -0
  109. package/dist/core/frontmatter.js +24 -9
  110. package/dist/core/help.d.ts +39 -8
  111. package/dist/core/help.js +69 -35
  112. package/dist/core/io.d.ts +15 -1
  113. package/dist/core/io.js +56 -6
  114. package/dist/core/personas/index.d.ts +12 -0
  115. package/dist/core/personas/index.js +10 -0
  116. package/dist/core/personas/loader.d.ts +44 -0
  117. package/dist/core/personas/loader.js +157 -0
  118. package/dist/core/personas/resolve.d.ts +36 -0
  119. package/dist/core/personas/resolve.js +110 -0
  120. package/dist/core/render.d.ts +11 -0
  121. package/dist/core/render.js +126 -0
  122. package/dist/core/resolver.d.ts +10 -0
  123. package/dist/core/resolver.js +160 -2
  124. package/dist/core/runtime/front-door.d.ts +10 -0
  125. package/dist/core/runtime/front-door.js +97 -0
  126. package/dist/core/runtime/kickoff.d.ts +23 -0
  127. package/dist/core/runtime/kickoff.js +134 -0
  128. package/dist/core/runtime/launch.d.ts +34 -0
  129. package/dist/core/runtime/launch.js +85 -0
  130. package/dist/core/runtime/nodes.d.ts +38 -0
  131. package/dist/core/runtime/nodes.js +95 -0
  132. package/dist/core/runtime/presence.d.ts +38 -0
  133. package/dist/core/runtime/presence.js +152 -0
  134. package/dist/core/runtime/promote.d.ts +30 -0
  135. package/dist/core/runtime/promote.js +105 -0
  136. package/dist/core/runtime/reset.d.ts +13 -0
  137. package/dist/core/runtime/reset.js +97 -0
  138. package/dist/core/runtime/revive.d.ts +26 -0
  139. package/dist/core/runtime/revive.js +89 -0
  140. package/dist/core/runtime/roadmap.d.ts +12 -0
  141. package/dist/core/runtime/roadmap.js +52 -0
  142. package/dist/core/runtime/spawn.d.ts +33 -0
  143. package/dist/core/runtime/spawn.js +118 -0
  144. package/dist/core/runtime/stop-guard.d.ts +18 -0
  145. package/dist/core/runtime/stop-guard.js +33 -0
  146. package/dist/core/runtime/tmux.d.ts +88 -0
  147. package/dist/core/runtime/tmux.js +198 -0
  148. package/dist/core/spawn.d.ts +17 -80
  149. package/dist/core/spawn.js +15 -219
  150. package/dist/daemon/crtrd-cli.d.ts +1 -0
  151. package/dist/daemon/crtrd-cli.js +4 -0
  152. package/dist/daemon/crtrd.d.ts +20 -0
  153. package/dist/daemon/crtrd.js +200 -0
  154. package/dist/daemon/manage.d.ts +17 -0
  155. package/dist/daemon/manage.js +57 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  158. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  159. package/dist/pi-extensions/canvas-nav.js +536 -0
  160. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  161. package/dist/pi-extensions/canvas-stophook.js +373 -0
  162. package/dist/types.d.ts +21 -0
  163. package/dist/types.js +3 -0
  164. package/package.json +6 -5
  165. package/dist/commands/agent.js +0 -384
  166. package/dist/commands/debug.d.ts +0 -3
  167. package/dist/commands/debug.js +0 -179
  168. package/dist/commands/job.js +0 -344
  169. package/dist/commands/plan.d.ts +0 -4
  170. package/dist/commands/plan.js +0 -309
  171. package/dist/commands/spec.d.ts +0 -3
  172. package/dist/commands/spec.js +0 -286
  173. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  174. package/dist/core/__tests__/job.test.js +0 -310
  175. package/dist/core/__tests__/jobs.test.js +0 -66
  176. package/dist/core/jobs.d.ts +0 -101
  177. package/dist/core/jobs.js +0 -462
  178. package/dist/prompts/agent.d.ts +0 -18
  179. package/dist/prompts/agent.js +0 -153
  180. package/dist/prompts/debug.d.ts +0 -8
  181. package/dist/prompts/debug.js +0 -44
  182. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  183. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  184. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
@@ -0,0 +1,116 @@
1
+ // Push engine for the pi-native canvas runtime.
2
+ //
3
+ // `push(nodeId, opts)` writes a report to the node's reports/ directory, then
4
+ // fans out a lightweight inbox pointer to every subscriber. The inbox entry
5
+ // carries the report path (ref), not the body — subscribers dereference on
6
+ // demand.
7
+ //
8
+ // Compact timestamp format: 20260602T184512 (UTC, no separators) chosen for
9
+ // file-system friendliness and lexicographic sort alignment.
10
+ import { writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { reportsDir, subscribersOf, setStatus, updateNode, } from '../canvas/index.js';
13
+ import { appendInbox } from './inbox.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Internal helpers
16
+ // ---------------------------------------------------------------------------
17
+ /** Format a Date as `YYYYMMDDTHHmmss` (UTC, no separators). */
18
+ function compactTs(d = new Date()) {
19
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
20
+ return (String(d.getUTCFullYear()) +
21
+ pad(d.getUTCMonth() + 1) +
22
+ pad(d.getUTCDate()) +
23
+ 'T' +
24
+ pad(d.getUTCHours()) +
25
+ pad(d.getUTCMinutes()) +
26
+ pad(d.getUTCSeconds()));
27
+ }
28
+ /**
29
+ * Write a report file atomically (tmp + rename).
30
+ * Returns the final absolute path.
31
+ */
32
+ function writeReport(nodeId, kind, ts, body) {
33
+ const dir = reportsDir(nodeId);
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true });
36
+ const fileName = `${ts}-${kind}.md`;
37
+ const finalPath = join(dir, fileName);
38
+ const tmpPath = `${finalPath}.tmp`;
39
+ const isoTs = new Date().toISOString();
40
+ // YAML frontmatter: minimal, machine-readable, no freeform content in it.
41
+ const frontmatter = `---\nnode: ${nodeId}\nkind: ${kind}\nts: ${isoTs}\n---\n`;
42
+ writeFileSync(tmpPath, frontmatter + body, 'utf8');
43
+ renameSync(tmpPath, finalPath);
44
+ return finalPath;
45
+ }
46
+ /**
47
+ * Extract the first line of a string and truncate to `maxLen` chars.
48
+ * Used to populate the inbox entry's `label` field (~80 chars).
49
+ */
50
+ function firstLine(text, maxLen = 80) {
51
+ const line = text.split('\n')[0] ?? '';
52
+ return line.length > maxLen ? line.slice(0, maxLen - 1) + '…' : line;
53
+ }
54
+ /** Map a PushKind to the appropriate inbox delivery tier. */
55
+ function tierFor(kind) {
56
+ return kind === 'urgent' ? 'urgent' : 'normal';
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Core push
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Push a report from `nodeId` and fan it out as inbox pointers to all
63
+ * current subscribers.
64
+ *
65
+ * Steps:
66
+ * (a) Write nodes/<nodeId>/reports/<ts>-<kind>.md (YAML front + body).
67
+ * (b) For each active/passive subscriber, append a pointer to their inbox.
68
+ * (c) If kind === 'final', mark the node done.
69
+ */
70
+ export async function push(nodeId, opts) {
71
+ const { kind, body } = opts;
72
+ const from = opts.from ?? nodeId;
73
+ const now = new Date();
74
+ const ts = compactTs(now);
75
+ // (a) Write the report.
76
+ const reportPath = writeReport(nodeId, kind, ts, body);
77
+ // (b) Fan out inbox pointers to every subscriber (active and passive both
78
+ // receive the pointer; the daemon decides whether to wake active ones).
79
+ const subscribers = subscribersOf(nodeId);
80
+ const deliveredTo = [];
81
+ const label = firstLine(body);
82
+ for (const sub of subscribers) {
83
+ appendInbox(sub.node_id, {
84
+ from,
85
+ tier: tierFor(kind),
86
+ kind,
87
+ ref: reportPath,
88
+ label,
89
+ });
90
+ deliveredTo.push(sub.node_id);
91
+ }
92
+ // (c) Finalise node when kind === 'final'.
93
+ if (kind === 'final') {
94
+ setStatus(nodeId, 'done');
95
+ updateNode(nodeId, { intent: 'done' });
96
+ }
97
+ return { reportPath, deliveredTo };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Convenience wrappers
101
+ // ---------------------------------------------------------------------------
102
+ /** Emit a routine progress update from `nodeId`. */
103
+ export async function pushUpdate(nodeId, body, opts) {
104
+ return push(nodeId, { kind: 'update', body, ...opts });
105
+ }
106
+ /** Emit an urgent alert from `nodeId` (inbox tier: urgent). */
107
+ export async function pushUrgent(nodeId, body, opts) {
108
+ return push(nodeId, { kind: 'urgent', body, ...opts });
109
+ }
110
+ /**
111
+ * Emit the final report from `nodeId` (inbox tier: normal, kind: final).
112
+ * Also transitions the node to status=done / intent=done.
113
+ */
114
+ export async function pushFinal(nodeId, body, opts) {
115
+ return push(nodeId, { kind: 'final', body, ...opts });
116
+ }
@@ -0,0 +1,50 @@
1
+ export type InboxTier = 'critical' | 'urgent' | 'normal' | 'deferred';
2
+ export type InboxKind = 'update' | 'urgent' | 'final' | 'message' | 'completed';
3
+ /** A single inbox entry — a pointer, not a copy of the content. */
4
+ export interface InboxEntry {
5
+ /** ISO 8601 timestamp of delivery. */
6
+ ts: string;
7
+ /** Node id of the sender, or null for system-generated entries. */
8
+ from: string | null;
9
+ /** Priority band for the receiver's attention. */
10
+ tier: InboxTier;
11
+ /** Semantic kind of the push event. */
12
+ kind: InboxKind;
13
+ /** Absolute path to the report file, when this entry is a push pointer. */
14
+ ref?: string;
15
+ /** First ~80 chars of the body's first line — enough to decide if it matters. */
16
+ label: string;
17
+ /** Arbitrary structured payload for non-push message entries. */
18
+ data?: Record<string, unknown>;
19
+ }
20
+ /**
21
+ * Atomically append one inbox entry to `nodes/<nodeId>/inbox.jsonl`.
22
+ * Fills `ts` (current ISO time). Returns the completed entry.
23
+ */
24
+ export declare function appendInbox(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
25
+ /**
26
+ * Return all inbox entries strictly after `cursorIso`.
27
+ * When `cursorIso` is undefined, returns every entry in the file.
28
+ */
29
+ export declare function readInboxSince(nodeId: string, cursorIso?: string): InboxEntry[];
30
+ /**
31
+ * Read the persisted cursor ISO for a node's inbox.
32
+ * Returns undefined if no cursor file exists yet.
33
+ */
34
+ export declare function readCursor(nodeId: string): string | undefined;
35
+ /**
36
+ * Persist a new cursor ISO for a node's inbox (atomic tmp+rename).
37
+ */
38
+ export declare function writeCursor(nodeId: string, iso: string): void;
39
+ /**
40
+ * Render many unread inbox pointers into one compact digest string.
41
+ *
42
+ * Format (per sender group):
43
+ * From <sender> — <N> update(s):
44
+ * [<kind>] <label> (ref: <path>)
45
+ * …
46
+ *
47
+ * A header line announces the total count and instructs the receiver to
48
+ * dereference only what matters.
49
+ */
50
+ export declare function coalesce(entries: InboxEntry[]): string;
@@ -0,0 +1,124 @@
1
+ // Per-node inbox.jsonl primitive for the pi-native canvas runtime.
2
+ //
3
+ // An inbox entry is a lightweight POINTER (~30 tokens), never content.
4
+ // The report body lives in nodes/<id>/reports/; the inbox line carries only
5
+ // enough to find it and decide whether to dereference.
6
+ //
7
+ // Layout:
8
+ // nodes/<id>/inbox.jsonl — one JSON line per entry, append-only
9
+ // nodes/<id>/inbox.jsonl.cursor — ISO 8601 of last-read entry (sidecar)
10
+ import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
+ import { inboxPath } from '../canvas/index.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Cursor sidecar path
15
+ // ---------------------------------------------------------------------------
16
+ function cursorPath(nodeId) {
17
+ return `${inboxPath(nodeId)}.cursor`;
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // Append
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Atomically append one inbox entry to `nodes/<nodeId>/inbox.jsonl`.
24
+ * Fills `ts` (current ISO time). Returns the completed entry.
25
+ */
26
+ export function appendInbox(nodeId, entry) {
27
+ const full = { ts: new Date().toISOString(), ...entry };
28
+ const line = JSON.stringify(full) + '\n';
29
+ // Ensure the parent directory exists (inbox.jsonl lives directly under the
30
+ // node dir, which ensureNodeDirs creates — but guard anyway for callers that
31
+ // haven't yet scaffolded the node).
32
+ const dir = dirname(inboxPath(nodeId));
33
+ if (!existsSync(dir))
34
+ mkdirSync(dir, { recursive: true });
35
+ // appendFileSync is atomic within a single process (a single write(2) call for
36
+ // a short line is atomic on POSIX). For multi-process safety we rely on the
37
+ // OS-level append guarantee (O_APPEND) which Node honours via 'a' flag.
38
+ appendFileSync(inboxPath(nodeId), line, { encoding: 'utf8', flag: 'a' });
39
+ return full;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Read
43
+ // ---------------------------------------------------------------------------
44
+ /**
45
+ * Return all inbox entries strictly after `cursorIso`.
46
+ * When `cursorIso` is undefined, returns every entry in the file.
47
+ */
48
+ export function readInboxSince(nodeId, cursorIso) {
49
+ const p = inboxPath(nodeId);
50
+ if (!existsSync(p))
51
+ return [];
52
+ const raw = readFileSync(p, 'utf8');
53
+ const entries = raw
54
+ .split('\n')
55
+ .filter((l) => l.trim() !== '')
56
+ .map((l) => JSON.parse(l));
57
+ if (cursorIso === undefined)
58
+ return entries;
59
+ // Entries are appended in chronological order; filter to those strictly
60
+ // after the cursor. We compare ISO strings lexicographically (valid for UTC).
61
+ return entries.filter((e) => e.ts > cursorIso);
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Cursor persistence
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Read the persisted cursor ISO for a node's inbox.
68
+ * Returns undefined if no cursor file exists yet.
69
+ */
70
+ export function readCursor(nodeId) {
71
+ const p = cursorPath(nodeId);
72
+ if (!existsSync(p))
73
+ return undefined;
74
+ const val = readFileSync(p, 'utf8').trim();
75
+ return val !== '' ? val : undefined;
76
+ }
77
+ /**
78
+ * Persist a new cursor ISO for a node's inbox (atomic tmp+rename).
79
+ */
80
+ export function writeCursor(nodeId, iso) {
81
+ const p = cursorPath(nodeId);
82
+ const tmp = `${p}.tmp`;
83
+ const dir = dirname(p);
84
+ if (!existsSync(dir))
85
+ mkdirSync(dir, { recursive: true });
86
+ writeFileSync(tmp, iso, 'utf8');
87
+ renameSync(tmp, p);
88
+ }
89
+ // ---------------------------------------------------------------------------
90
+ // Coalesce
91
+ // ---------------------------------------------------------------------------
92
+ /**
93
+ * Render many unread inbox pointers into one compact digest string.
94
+ *
95
+ * Format (per sender group):
96
+ * From <sender> — <N> update(s):
97
+ * [<kind>] <label> (ref: <path>)
98
+ * …
99
+ *
100
+ * A header line announces the total count and instructs the receiver to
101
+ * dereference only what matters.
102
+ */
103
+ export function coalesce(entries) {
104
+ if (entries.length === 0)
105
+ return '(inbox empty)';
106
+ const header = `${entries.length} update${entries.length === 1 ? '' : 's'} since last read — dereference what matters.\n`;
107
+ // Group by `from` (null → 'system').
108
+ const groups = new Map();
109
+ for (const e of entries) {
110
+ const key = e.from ?? 'system';
111
+ if (!groups.has(key))
112
+ groups.set(key, []);
113
+ groups.get(key).push(e);
114
+ }
115
+ const sections = [];
116
+ for (const [sender, items] of groups) {
117
+ const lines = items.map((e) => {
118
+ const refPart = e.ref !== undefined ? ` (ref: ${e.ref})` : '';
119
+ return ` [${e.kind}] ${e.label}${refPart}`;
120
+ });
121
+ sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
122
+ }
123
+ return header + sections.join('\n\n');
124
+ }
@@ -5,4 +5,14 @@ export interface ParsedFrontmatter {
5
5
  raw: string;
6
6
  }
7
7
  export declare function parseFrontmatter(source: string): ParsedFrontmatter;
8
+ export interface ParsedFrontmatterGeneric {
9
+ /** Raw, uncoerced key/value record from the YAML block (null when absent). */
10
+ data: Record<string, unknown> | null;
11
+ body: string;
12
+ raw: string;
13
+ }
14
+ /** Like parseFrontmatter but returns the raw key/value record instead of
15
+ * coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
16
+ * fields skills don't declare, such as `tools` and `model`. */
17
+ export declare function parseFrontmatterGeneric(source: string): ParsedFrontmatterGeneric;
8
18
  export declare function serializeFrontmatter(data: SkillFrontmatter): string;
@@ -7,9 +7,30 @@ export function parseFrontmatter(source) {
7
7
  }
8
8
  const raw = match[1];
9
9
  const body = source.slice(match[0].length);
10
- return { data: parseSimpleYaml(raw), body, raw };
10
+ return { data: toSkillFrontmatter(parseYamlRecord(raw)), body, raw };
11
11
  }
12
- function parseSimpleYaml(yaml) {
12
+ /** Like parseFrontmatter but returns the raw key/value record instead of
13
+ * coercing to SkillFrontmatter. Used by consumers (e.g. subagents) that read
14
+ * fields skills don't declare, such as `tools` and `model`. */
15
+ export function parseFrontmatterGeneric(source) {
16
+ const match = source.match(FRONTMATTER_RE);
17
+ if (!match) {
18
+ return { data: null, body: source, raw: '' };
19
+ }
20
+ const raw = match[1];
21
+ const body = source.slice(match[0].length);
22
+ return { data: parseYamlRecord(raw), body, raw };
23
+ }
24
+ function toSkillFrontmatter(out) {
25
+ const fm = {
26
+ name: typeof out.name === 'string' ? out.name : '',
27
+ description: typeof out.description === 'string' ? out.description : undefined,
28
+ keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
29
+ type: isSkillType(out.type) ? out.type : undefined,
30
+ };
31
+ return fm;
32
+ }
33
+ function parseYamlRecord(yaml) {
13
34
  const lines = yaml.split(/\r?\n/);
14
35
  const out = {};
15
36
  let i = 0;
@@ -123,13 +144,7 @@ function parseSimpleYaml(yaml) {
123
144
  out[key] = stripQuotes(rest);
124
145
  i++;
125
146
  }
126
- const fm = {
127
- name: typeof out.name === 'string' ? out.name : '',
128
- description: typeof out.description === 'string' ? out.description : undefined,
129
- keywords: Array.isArray(out.keywords) ? out.keywords : undefined,
130
- type: isSkillType(out.type) ? out.type : undefined,
131
- };
132
- return fm;
147
+ return out;
133
148
  }
134
149
  function stripQuotes(s) {
135
150
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
@@ -47,17 +47,40 @@ export interface ContextFileParam {
47
47
  shape?: string;
48
48
  }
49
49
  export type InputParam = PositionalParam | FlagParam | StdinParam | ContextFileParam;
50
+ /** A subtree's self-description at the parent (root) level. Each subtree owns
51
+ * the content that represents it one level up: its vocabulary line, its
52
+ * selection rubric, and any bounded block it contributes to the parent's -h.
53
+ * defineRoot assembles the root help from these — root never hardcodes a
54
+ * subtree's representation. See cli-design "Each node owns its parent-level
55
+ * representation". */
56
+ export interface RootEntry {
57
+ /** One-line vocabulary desc — what this subtree is. Rendered first in the
58
+ * subtree's <name> block at root. */
59
+ concept: string;
60
+ /** Operations summary (verb list). Carried for completeness; the root block
61
+ * leads with concept + rubric, so this is available but not rendered. */
62
+ desc: string;
63
+ /** The selection rubric — `use when X` in the subtree's <name> block. */
64
+ useWhen: string;
65
+ /** Optional bounded block this subtree contributes to its <name> block at
66
+ * root. Returns a complete self-named state element (build it with
67
+ * stateBlock), e.g. `<skills count="42">…</skills>`. Aggregate, never an
68
+ * unbounded enumeration on a cold path. Soft-fails to omission on
69
+ * null/throw. */
70
+ dynamicState?: () => string | null;
71
+ }
50
72
  export interface RootHelp {
51
73
  tagline: string;
52
- /** Vocabulary block rendered before subtrees. */
53
- concepts: {
54
- name: string;
55
- desc: string;
56
- }[];
57
- subtrees: {
74
+ /** One entry per listed subtree. Each renders as its own <name> XML block at
75
+ * root, carrying the subtree's concept, selection rubric, and any nested
76
+ * runtime-state block. Assembled from subtrees' RootEntry by defineRoot;
77
+ * root hardcodes none of it. */
78
+ commands: {
58
79
  name: string;
80
+ concept: string;
59
81
  desc: string;
60
82
  useWhen: string;
83
+ dynamicState?: () => string | null;
61
84
  }[];
62
85
  globals: {
63
86
  name: string;
@@ -69,8 +92,9 @@ export interface BranchHelp {
69
92
  summary: string;
70
93
  /** Local lifecycle/model line that extends the parent definition. */
71
94
  model?: string;
72
- /** Bounded runtime aggregate, e.g. "Current: 2 draft, 1 active".
73
- * Renderer soft-fails to omission if this returns null or throws. */
95
+ /** Bounded runtime aggregate as a complete self-named state element (build
96
+ * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
97
+ * soft-fails to omission if this returns null or throws. */
74
98
  dynamicState?: () => string | null;
75
99
  children: {
76
100
  name: string;
@@ -93,6 +117,13 @@ export interface LeafHelp {
93
117
  * leaves use exactly: ["None. Read-only."] */
94
118
  effects: string[];
95
119
  }
120
+ /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
121
+ * subtree that owns the state authors it through this, so the tag name and any
122
+ * scalar metadata (e.g. a count) travel with the data and render identically
123
+ * at every level the block appears. The tag name carries the label, so the
124
+ * body never repeats it. Attribute values are controlled (counts, short
125
+ * tokens) and not escaped. */
126
+ export declare function stateBlock(tag: string, attrs: Record<string, string | number>, body: string): string;
96
127
  export declare function renderRoot(h: RootHelp): string;
97
128
  export declare function renderBranch(h: BranchHelp): string;
98
129
  export declare function renderLeafArgv(h: LeafHelp): string;
package/dist/core/help.js CHANGED
@@ -5,6 +5,30 @@
5
5
  // ---------------------------------------------------------------------------
6
6
  // Internal helpers
7
7
  // ---------------------------------------------------------------------------
8
+ /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
9
+ * subtree that owns the state authors it through this, so the tag name and any
10
+ * scalar metadata (e.g. a count) travel with the data and render identically
11
+ * at every level the block appears. The tag name carries the label, so the
12
+ * body never repeats it. Attribute values are controlled (counts, short
13
+ * tokens) and not escaped. */
14
+ export function stateBlock(tag, attrs, body) {
15
+ const a = Object.entries(attrs)
16
+ .map(([k, v]) => ` ${k}="${v}"`)
17
+ .join('');
18
+ return `<${tag}${a}>\n${body}\n</${tag}>`;
19
+ }
20
+ /** Evaluate a dynamicState hook, soft-failing to null on throw or empty. */
21
+ function evalDynamic(fn) {
22
+ if (fn === undefined)
23
+ return null;
24
+ try {
25
+ const s = fn();
26
+ return s !== null && s !== '' ? s : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
8
32
  /** Return the longest string length in an array of names. */
9
33
  function maxLen(names) {
10
34
  let max = 0;
@@ -21,29 +45,41 @@ function pad(s, width) {
21
45
  // ---------------------------------------------------------------------------
22
46
  // renderRoot
23
47
  // ---------------------------------------------------------------------------
24
- const IO_CONTRACT = 'I/O contract: flags and positional args on input, JSON on stdout (JSONL for streams).\n' +
48
+ const IO_CONTRACT = 'I/O contract: flags and positional args on input; stdout is agent-ready markdown/XML you\n' +
49
+ 'act on directly — read it as a continuation of your prompt, don\'t parse it as data.\n' +
25
50
  'Exit 0 on success, non-zero on failure. Schemas appear at leaf -h.';
51
+ // Behavioral instruction (not a schema) — engrained in the appended system
52
+ // prompt so the model treats unfamiliar capabilities as a cue to discover the
53
+ // contract, never to guess. Lives in the root guide, outside any leaf -h.
54
+ const CAPABILITY_DISCOVERY = "If the user mentions or implies a crtr capability you don't fully understand, " +
55
+ 'do not guess or assume it is unsupported — run `-h` on the relevant command ' +
56
+ '(append it anywhere along the path) to read the contract before acting.';
26
57
  export function renderRoot(h) {
27
58
  const lines = [];
28
59
  lines.push(`${h.tagline}`);
29
60
  lines.push('');
30
- // Concepts block
31
- lines.push('Concepts');
32
- const cNameW = maxLen(h.concepts.map((c) => c.name));
33
- for (const c of h.concepts) {
34
- lines.push(` ${pad(c.name, cNameW)} ${c.desc}`);
35
- }
36
- lines.push('');
37
- // Subtrees block
38
- lines.push('Subtrees');
39
- const sNameW = maxLen(h.subtrees.map((s) => s.name));
40
- // Align desc column so "| use when X" starts at a consistent offset
41
- const sDescW = maxLen(h.subtrees.map((s) => s.desc));
42
- for (const s of h.subtrees) {
43
- lines.push(` ${pad(s.name, sNameW)} ${pad(s.desc, sDescW)} | use when ${s.useWhen}`);
61
+ // Each subtree is one <command name="…"> block. The uniform wrapper states
62
+ // "this is a command you invoke as `crtr <name>`" — so the model reads them
63
+ // by one rule, and a nested state element (which is never a <command>) can't
64
+ // be mistaken for a sibling command. Inside: the concept (what it is), the
65
+ // selection rubric (when to pick it), then any self-named state element
66
+ // grouped with the command it belongs to. Once injected into a system prompt,
67
+ // each block reads as one self-contained concern domain. Header (tagline) and
68
+ // footer (Globals + I/O contract + capability-discovery rule) are the only
69
+ // non-command areas. Two levels of nesting: <command> → <state>.
70
+ for (const c of h.commands) {
71
+ lines.push(`<command name="${c.name}">`);
72
+ lines.push(c.concept);
73
+ lines.push(`use when ${c.useWhen}`);
74
+ // dynamicState returns a complete self-named element (e.g.
75
+ // <skills count="42">…</skills>) — emit it as-is, nested in the command.
76
+ const state = evalDynamic(c.dynamicState);
77
+ if (state !== null)
78
+ lines.push(state);
79
+ lines.push('</command>');
80
+ lines.push('');
44
81
  }
45
- lines.push('');
46
- // Globals block
82
+ // Globals block (footer)
47
83
  lines.push('Globals');
48
84
  const gNameW = maxLen(h.globals.map((g) => g.name));
49
85
  for (const g of h.globals) {
@@ -51,6 +87,8 @@ export function renderRoot(h) {
51
87
  }
52
88
  lines.push('');
53
89
  lines.push(IO_CONTRACT);
90
+ lines.push('');
91
+ lines.push(CAPABILITY_DISCOVERY);
54
92
  return lines.join('\n');
55
93
  }
56
94
  // ---------------------------------------------------------------------------
@@ -59,25 +97,20 @@ export function renderRoot(h) {
59
97
  export function renderBranch(h) {
60
98
  const lines = [];
61
99
  lines.push(`${h.name}: ${h.summary}.`);
100
+ // Dynamic content leads — the live aggregate (e.g. the <skills> catalog)
101
+ // renders right after the name, before the hardcoded model prose, so current
102
+ // state is read first. The subtree authors the whole element, so the same
103
+ // self-named block appears identically at root and at `skill -h`.
104
+ const branchState = evalDynamic(h.dynamicState);
105
+ if (branchState !== null) {
106
+ // dynamicState returns a complete self-named element — emit as-is.
107
+ lines.push('');
108
+ lines.push(branchState);
109
+ }
62
110
  if (h.model !== undefined) {
111
+ lines.push('');
63
112
  lines.push(h.model);
64
113
  }
65
- // Dynamic state — soft-fail to omission. Rendered as its own block,
66
- // blank-line separated from the summary, so a multi-line runtime
67
- // aggregate (e.g. the loaded-skills catalog) reads cleanly.
68
- if (h.dynamicState !== undefined) {
69
- let state = null;
70
- try {
71
- state = h.dynamicState();
72
- }
73
- catch {
74
- // soft-fail: omit the block
75
- }
76
- if (state !== null && state !== '') {
77
- lines.push('');
78
- lines.push(state);
79
- }
80
- }
81
114
  lines.push('');
82
115
  lines.push('Branches');
83
116
  const nameW = maxLen(h.children.map((c) => c.name));
@@ -148,8 +181,9 @@ export function renderLeafArgv(h) {
148
181
  lines.push(h.inputNote !== undefined ? h.inputNote : 'No input parameters.');
149
182
  }
150
183
  lines.push('');
151
- const outputLabel = h.outputKind === 'jsonl' ? 'Output (stdout, JSONL)' : 'Output (stdout, JSON)';
152
- lines.push(outputLabel);
184
+ // The result is rendered as instruction-shaped XML+markdown; these fields are
185
+ // the information it carries, in order, not a literal JSON shape.
186
+ lines.push('Output (fields carried in the rendered result)');
153
187
  const outNameW = maxLen(h.output.map((f) => f.name));
154
188
  for (const f of h.output) {
155
189
  lines.push(` ${pad(f.name, outNameW)} ${f.type}. ${f.constraint}`);
package/dist/core/io.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { CrtrError } from './errors.js';
2
2
  import { type ExitCodeValue } from '../types.js';
3
+ /** Set by the dispatcher when `--json` is present anywhere in argv. */
4
+ export declare function setJsonOutput(v: boolean): void;
5
+ /** True when the caller asked for raw JSON instead of rendered prose. */
6
+ export declare function isJsonOutput(): boolean;
3
7
  /** Structured error payload. `error` is a stable code the agent branches on;
4
8
  * `next` is the recovery road sign. */
5
9
  export interface ErrorPayload {
@@ -17,10 +21,20 @@ export declare class InputError extends CrtrError {
17
21
  /** Read raw stdin to EOF. Returns empty string when stdin is a TTY (no pipe).
18
22
  * Called by the argv parser for leaves declaring a `stdin` parameter. */
19
23
  export declare function readStdinRaw(): Promise<string>;
20
- /** Single-shot response: one JSON object. The whole response is one value. */
24
+ /** Raw-JSON mirror of a single-shot response (the `--json` escape hatch). The
25
+ * default path renders the result as prose instead — see render.ts. */
21
26
  export declare function emit(obj: Record<string, unknown>): void;
22
27
  /** One JSONL record. Call per event in a stream; partial reads stay parseable. */
23
28
  export declare function emitLine(obj: Record<string, unknown>): void;
29
+ /**
30
+ * Write to stdout and resolve true ONLY once the bytes are confirmed flushed to
31
+ * a connected reader. Resolves false if the consumer is gone (EPIPE) or the
32
+ * write fails. This is the reliable "the caller actually received it" signal:
33
+ * use it to gate side effects that must only happen on genuine delivery (e.g.
34
+ * acking a collected result). A killed process never resolves at all — also
35
+ * safe, since the gated side effect then never runs.
36
+ */
37
+ export declare function writeStdout(s: string): Promise<boolean>;
24
38
  export declare function diag(message: string): void;
25
39
  /** Terminal error handler. Command-level failures (bad input, not-found,
26
40
  * ambiguous) surface as the JSON response on stdout so the caller parses one