@crouton-kit/crouter 0.3.36 → 0.3.38

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 (177) hide show
  1. package/dist/build-root.js +3 -6
  2. package/dist/{builtin-personas/runtime-base.md → builtin-memory/00-runtime-base.md} +8 -1
  3. package/dist/{builtin-personas/spine/has-manager.md → builtin-memory/01-spine/00-has-manager.md} +8 -0
  4. package/dist/builtin-memory/01-spine/01-no-manager.md +10 -0
  5. package/dist/{builtin-personas/lifecycle/terminal.md → builtin-memory/02-lifecycle/00-terminal.md} +8 -0
  6. package/dist/{builtin-personas/lifecycle/resident.md → builtin-memory/02-lifecycle/01-resident.md} +8 -0
  7. package/dist/{builtin-personas/waiting.md → builtin-memory/03-waiting.md} +7 -0
  8. package/dist/{builtin-personas/orchestration-kernel.md → builtin-memory/04-orchestration-kernel.md} +8 -0
  9. package/dist/{builtin-personas/advisor/PERSONA.md → builtin-memory/05-kinds/advisor/00-base.md} +5 -2
  10. package/dist/{builtin-personas/design/PERSONA.md → builtin-memory/05-kinds/design/00-base.md} +5 -2
  11. package/dist/{builtin-personas/design/orchestrator.md → builtin-memory/05-kinds/design/01-orchestrator.md} +5 -4
  12. package/dist/{builtin-personas/developer/PERSONA.md → builtin-memory/05-kinds/developer/00-base.md} +5 -2
  13. package/dist/{builtin-personas/developer/orchestrator.md → builtin-memory/05-kinds/developer/01-orchestrator.md} +5 -4
  14. package/dist/{builtin-personas/explore/PERSONA.md → builtin-memory/05-kinds/explore/00-base.md} +5 -2
  15. package/dist/{builtin-personas/explore/orchestrator.md → builtin-memory/05-kinds/explore/01-orchestrator.md} +5 -3
  16. package/dist/{builtin-personas/general/PERSONA.md → builtin-memory/05-kinds/general/00-base.md} +5 -2
  17. package/dist/{builtin-personas/general/orchestrator.md → builtin-memory/05-kinds/general/01-orchestrator.md} +5 -3
  18. package/dist/{builtin-personas/plan/PERSONA.md → builtin-memory/05-kinds/plan/00-base.md} +5 -2
  19. package/dist/{builtin-personas/plan/orchestrator.md → builtin-memory/05-kinds/plan/01-orchestrator.md} +5 -4
  20. package/dist/{builtin-personas/plan/reviewers/architecture-fit/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/architecture-fit.md} +5 -2
  21. package/dist/{builtin-personas/plan/reviewers/code-smells/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/code-smells.md} +5 -2
  22. package/dist/{builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md} +5 -2
  23. package/dist/{builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md} +5 -2
  24. package/dist/{builtin-personas/plan/reviewers/security/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/security.md} +5 -2
  25. package/dist/{builtin-personas/product/PERSONA.md → builtin-memory/05-kinds/product/00-base.md} +5 -2
  26. package/dist/{builtin-personas/product/orchestrator.md → builtin-memory/05-kinds/product/01-orchestrator.md} +5 -4
  27. package/dist/{builtin-personas/product/teardown/PERSONA.md → builtin-memory/05-kinds/product/teardown.md} +5 -3
  28. package/dist/{builtin-personas/review/PERSONA.md → builtin-memory/05-kinds/review/00-base.md} +5 -2
  29. package/dist/{builtin-personas/review/orchestrator.md → builtin-memory/05-kinds/review/01-orchestrator.md} +5 -3
  30. package/dist/{builtin-personas/spec/PERSONA.md → builtin-memory/05-kinds/spec/00-base.md} +5 -2
  31. package/dist/{builtin-personas/spec/orchestrator.md → builtin-memory/05-kinds/spec/01-orchestrator.md} +5 -4
  32. package/dist/{builtin-personas/spec/requirements/PERSONA.md → builtin-memory/05-kinds/spec/requirements.md} +5 -2
  33. package/dist/builtin-memory/internal/INDEX.md +1 -1
  34. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  35. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  36. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
  37. package/dist/builtin-views/canvas/core.mjs +3 -2
  38. package/dist/builtin-views/prompt-review/core.mjs +6 -134
  39. package/dist/builtin-views/settings/core.mjs +26 -94
  40. package/dist/builtin-views/settings/text.mjs +0 -5
  41. package/dist/builtin-views/settings/tui.mjs +1 -7
  42. package/dist/builtin-views/settings/web.jsx +3 -49
  43. package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
  44. package/dist/clients/attach/attach-cmd.js +429 -432
  45. package/dist/clients/web/web-cmd.js +7 -7
  46. package/dist/commands/__tests__/human.test.js +1 -15
  47. package/dist/commands/attention.js +9 -6
  48. package/dist/commands/canvas-history/search.js +2 -2
  49. package/dist/commands/canvas-history/show.js +3 -3
  50. package/dist/commands/canvas-history.js +1 -1
  51. package/dist/commands/canvas-issue.js +1 -0
  52. package/dist/commands/canvas-snapshot.js +1 -0
  53. package/dist/commands/canvas.js +10 -11
  54. package/dist/commands/chord.js +1 -0
  55. package/dist/commands/human/prompts.js +0 -1
  56. package/dist/commands/human/queue.js +1 -0
  57. package/dist/commands/memory/read.js +3 -3
  58. package/dist/commands/memory/shared.d.ts +13 -8
  59. package/dist/commands/memory/shared.js +22 -8
  60. package/dist/commands/memory/write.js +5 -3
  61. package/dist/commands/node-snapshot.js +1 -0
  62. package/dist/commands/node.js +52 -22
  63. package/dist/commands/profile/add-project.d.ts +1 -0
  64. package/dist/commands/profile/add-project.js +42 -0
  65. package/dist/commands/profile/delete.d.ts +1 -0
  66. package/dist/commands/profile/delete.js +39 -0
  67. package/dist/commands/profile/list.d.ts +1 -0
  68. package/dist/commands/profile/list.js +35 -0
  69. package/dist/commands/profile/new.d.ts +1 -0
  70. package/dist/commands/profile/new.js +50 -0
  71. package/dist/commands/profile/remove-project.d.ts +1 -0
  72. package/dist/commands/profile/remove-project.js +42 -0
  73. package/dist/commands/profile/rename.d.ts +1 -0
  74. package/dist/commands/profile/rename.js +42 -0
  75. package/dist/commands/profile/show.d.ts +1 -0
  76. package/dist/commands/profile/show.js +50 -0
  77. package/dist/commands/profile.d.ts +2 -0
  78. package/dist/commands/profile.js +33 -0
  79. package/dist/commands/push.d.ts +0 -1
  80. package/dist/commands/push.js +2 -228
  81. package/dist/commands/revive.js +2 -2
  82. package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
  83. package/dist/commands/surface.js +3 -2
  84. package/dist/commands/sys/config.js +6 -21
  85. package/dist/commands/sys/daemon.d.ts +2 -0
  86. package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
  87. package/dist/commands/sys/doctor.js +1 -0
  88. package/dist/commands/sys/prompt-review.js +90 -17
  89. package/dist/commands/sys.js +4 -3
  90. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  91. package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
  92. package/dist/core/__tests__/daemon-wedge.test.js +87 -0
  93. package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
  94. package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
  95. package/dist/core/__tests__/history-inbox.test.js +105 -0
  96. package/dist/core/__tests__/kickoff.test.js +3 -3
  97. package/dist/core/__tests__/revive.test.js +39 -1
  98. package/dist/core/canvas/canvas.js +10 -9
  99. package/dist/core/canvas/db.js +11 -0
  100. package/dist/core/canvas/history.d.ts +1 -1
  101. package/dist/core/canvas/history.js +91 -3
  102. package/dist/core/canvas/paths.d.ts +6 -0
  103. package/dist/core/canvas/paths.js +9 -0
  104. package/dist/core/canvas/status-glyph.js +1 -0
  105. package/dist/core/canvas/types.d.ts +10 -0
  106. package/dist/core/config.d.ts +44 -1
  107. package/dist/core/config.js +155 -12
  108. package/dist/core/fault-classifier.d.ts +2 -2
  109. package/dist/core/feed/inbox.d.ts +7 -12
  110. package/dist/core/feed/inbox.js +45 -29
  111. package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
  112. package/dist/core/memory-resolver.d.ts +26 -15
  113. package/dist/core/memory-resolver.js +158 -40
  114. package/dist/core/preview-registry.js +14 -112
  115. package/dist/core/profiles/manifest.d.ts +33 -0
  116. package/dist/core/profiles/manifest.js +332 -0
  117. package/dist/core/profiles/select.d.ts +11 -0
  118. package/dist/core/profiles/select.js +115 -0
  119. package/dist/core/render.js +1 -1
  120. package/dist/core/runtime/bearings.d.ts +9 -0
  121. package/dist/core/runtime/bearings.js +31 -0
  122. package/dist/core/runtime/busy.d.ts +15 -0
  123. package/dist/core/runtime/busy.js +32 -1
  124. package/dist/core/runtime/close.d.ts +10 -0
  125. package/dist/core/runtime/close.js +18 -13
  126. package/dist/core/runtime/fault-recovery.js +4 -0
  127. package/dist/core/runtime/front-door.js +9 -2
  128. package/dist/core/runtime/kickoff.js +5 -7
  129. package/dist/core/runtime/launch.d.ts +25 -7
  130. package/dist/core/runtime/launch.js +42 -55
  131. package/dist/core/runtime/nodes.d.ts +7 -0
  132. package/dist/core/runtime/nodes.js +6 -0
  133. package/dist/core/runtime/persona.js +18 -32
  134. package/dist/core/runtime/placement.js +1 -1
  135. package/dist/core/runtime/revive.js +9 -1
  136. package/dist/core/runtime/spawn.d.ts +13 -0
  137. package/dist/core/runtime/spawn.js +25 -0
  138. package/dist/core/runtime/tmux.d.ts +2 -2
  139. package/dist/core/runtime/tmux.js +2 -2
  140. package/dist/core/scope.d.ts +14 -7
  141. package/dist/core/scope.js +76 -20
  142. package/dist/core/substrate/index.d.ts +3 -3
  143. package/dist/core/substrate/index.js +5 -3
  144. package/dist/core/substrate/on-read.js +3 -2
  145. package/dist/core/substrate/render.d.ts +16 -11
  146. package/dist/core/substrate/render.js +249 -62
  147. package/dist/core/substrate/schema.d.ts +11 -3
  148. package/dist/core/substrate/schema.js +25 -0
  149. package/dist/core/substrate/subject.d.ts +15 -2
  150. package/dist/core/substrate/subject.js +24 -2
  151. package/dist/daemon/crtrd-cli.js +1 -1
  152. package/dist/daemon/crtrd.d.ts +24 -0
  153. package/dist/daemon/crtrd.js +213 -5
  154. package/dist/daemon/manage.js +2 -2
  155. package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
  156. package/dist/pi-extensions/canvas-nav.js +2 -2
  157. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  158. package/dist/pi-extensions/canvas-stophook.js +59 -2
  159. package/dist/types.d.ts +57 -1
  160. package/dist/types.js +83 -1
  161. package/dist/web-client/assets/{index-IAJVtuVe.js → index-CbO8L0mN.js} +4 -4
  162. package/dist/web-client/assets/index-DwO46Cs5.css +2 -0
  163. package/dist/web-client/index.html +2 -2
  164. package/package.json +2 -2
  165. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +0 -56
  166. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +0 -57
  167. package/dist/builtin-memory/crouter-development/personas.md +0 -112
  168. package/dist/builtin-personas/spine/no-manager.md +0 -2
  169. package/dist/commands/daemon.d.ts +0 -2
  170. package/dist/core/personas/index.d.ts +0 -13
  171. package/dist/core/personas/index.js +0 -11
  172. package/dist/core/personas/loader.d.ts +0 -159
  173. package/dist/core/personas/loader.js +0 -327
  174. package/dist/core/personas/resolve.d.ts +0 -59
  175. package/dist/core/personas/resolve.js +0 -376
  176. package/dist/web-client/assets/index-DUThOUzU.css +0 -2
  177. /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
@@ -0,0 +1,332 @@
1
+ // The profile store: `~/.crouter/profiles/<slug>-<id>/` — a manifest
2
+ // (`profile.json`) naming N project directories, plus the profile's own
3
+ // `memory/` store (spec §2.1). This module is the SOLE place that resolves a
4
+ // profile id/name to a path and writes/mutates a manifest: every join against
5
+ // the canonical profiles root goes through the id-shape + containment checks
6
+ // here, so no caller ever passes a raw CLI operand or `CRTR_PROFILE_ID`
7
+ // straight into `join(profilesRoot(), value)`.
8
+ import { randomUUID } from 'node:crypto';
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
10
+ import { dirname, join, resolve as resolvePath, sep } from 'node:path';
11
+ import { SCHEMA_VERSION, PROFILE_DIR } from '../../types.js';
12
+ import { userScopeRoot, resetScopeCache } from '../scope.js';
13
+ import { ensureDir, nowIso } from '../fs-utils.js';
14
+ import { usage, notFound, ambiguous, general } from '../errors.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Path safety — the only code in the tree that builds a path under the
17
+ // profiles root. Every other module reaches profile paths through this file.
18
+ // ---------------------------------------------------------------------------
19
+ /** `<slug>-<shortid>`: slug is lower-case, non-alphanumeric collapsed to `-`,
20
+ * trimmed to 40 chars (defaulting to `profile`); shortid is 8 lowercase hex
21
+ * chars. The char class alone rules out `/`, `\`, `.`, and `..` segments —
22
+ * this is the "generated id shape" check every exact-id lookup requires. */
23
+ const ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
24
+ export function profilesRoot() {
25
+ return join(userScopeRoot(), PROFILE_DIR);
26
+ }
27
+ function assertSafeProfileId(profileId) {
28
+ if (!ID_SHAPE.test(profileId)) {
29
+ throw usage(`invalid profile id: ${profileId}`, {
30
+ received: profileId,
31
+ next: 'Profile ids look like <slug>-<8-hex-chars> (as printed by `crtr profile new`). Pass an exact id or a profile name, not a raw path.',
32
+ });
33
+ }
34
+ }
35
+ /** Before any read/write/lock/delete: the resolved target must live INSIDE
36
+ * the canonical profiles root, even after symlink resolution. Every caller
37
+ * below reaches this only via `profileRoot`, which already ran
38
+ * `assertSafeProfileId` first — this is defense-in-depth, not the primary
39
+ * guard, and is what makes an unexpected symlink under a profile dir inert. */
40
+ function assertInsideProfilesRoot(path) {
41
+ const root = profilesRoot();
42
+ const resolvedRoot = existsSync(root) ? realpathSync(root) : resolvePath(root);
43
+ const resolvedPath = existsSync(path) ? realpathSync(path) : resolvePath(path);
44
+ if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
45
+ throw usage(`profile path escapes the profiles root: ${path}`);
46
+ }
47
+ }
48
+ export function profileRoot(profileId) {
49
+ assertSafeProfileId(profileId);
50
+ const root = join(profilesRoot(), profileId);
51
+ assertInsideProfilesRoot(root);
52
+ return root;
53
+ }
54
+ export function profileManifestPath(profileId) {
55
+ return join(profileRoot(profileId), 'profile.json');
56
+ }
57
+ export function profileMemoryDir(profileId) {
58
+ return join(profileRoot(profileId), 'memory');
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Enumeration + resolution — the ONLY functions that turn a CLI operand into
62
+ // a profile id. Both walk immediate children of the canonical root; neither
63
+ // ever joins an unvalidated operand into a path.
64
+ // ---------------------------------------------------------------------------
65
+ function readManifestFile(path) {
66
+ if (!existsSync(path))
67
+ return null;
68
+ try {
69
+ return JSON.parse(readFileSync(path, 'utf8'));
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** Every profile with a readable manifest, in directory-listing order.
76
+ * Enumerates ONLY immediate children of `profilesRoot()` — never recurses,
77
+ * never trusts a caller-supplied id. A dir whose name doesn't match the
78
+ * generated id shape, or whose `profile.json` is missing/corrupt, is skipped
79
+ * rather than crashing the whole listing. */
80
+ export function listProfiles() {
81
+ const root = profilesRoot();
82
+ if (!existsSync(root))
83
+ return [];
84
+ const out = [];
85
+ for (const dirent of readdirSync(root, { withFileTypes: true })) {
86
+ if (!dirent.isDirectory())
87
+ continue;
88
+ const profileId = dirent.name;
89
+ if (!ID_SHAPE.test(profileId))
90
+ continue;
91
+ const manifest = readManifestFile(join(root, profileId, 'profile.json'));
92
+ if (manifest === null)
93
+ continue;
94
+ out.push({ profileId, manifest });
95
+ }
96
+ return out;
97
+ }
98
+ /** Resolve a CLI operand to its profile — exact id first (checked against the
99
+ * actually-enumerated dirs, never a blind join), then a unique manifest
100
+ * `name` match. Ambiguous names fail listing every matching id; no match
101
+ * fails naming `profile list`/`profile new` as recovery. This is the ONLY
102
+ * function every command leaf and future runtime consumer (Phase 5/6) should
103
+ * call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
104
+ * safe profile id. */
105
+ export function loadProfileManifest(profileIdOrName) {
106
+ const all = listProfiles();
107
+ if (ID_SHAPE.test(profileIdOrName)) {
108
+ const byId = all.find((p) => p.profileId === profileIdOrName);
109
+ if (byId !== undefined)
110
+ return byId;
111
+ }
112
+ const byName = all.filter((p) => p.manifest.name === profileIdOrName);
113
+ if (byName.length === 1)
114
+ return byName[0];
115
+ if (byName.length > 1) {
116
+ const ids = byName.map((p) => p.profileId);
117
+ throw ambiguous(`profile name "${profileIdOrName}" matches ${ids.length} profiles: ${ids.join(', ')}`, {
118
+ received: profileIdOrName,
119
+ candidates: ids,
120
+ next: `Re-run with one exact profile id: ${ids.join(', ')}.`,
121
+ });
122
+ }
123
+ throw notFound(`profile not found: ${profileIdOrName}`, {
124
+ received: profileIdOrName,
125
+ next: 'Run `crtr profile list` to see available profiles, or `crtr profile new --name <name>` to create one.',
126
+ });
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Locking — a plain mkdir-based mutex under `profilesRoot()/.locks/`. mkdir is
130
+ // atomic (EEXIST when already held), so this needs no extra dependency. The
131
+ // blocking wait uses Atomics.wait on a throwaway SharedArrayBuffer to stay
132
+ // synchronous, matching every other sync fs call in this module.
133
+ // ---------------------------------------------------------------------------
134
+ const LOCK_TIMEOUT_MS = 5000;
135
+ const LOCK_POLL_MS = 25;
136
+ function locksDir() {
137
+ return join(profilesRoot(), '.locks');
138
+ }
139
+ function sleepSync(ms) {
140
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
141
+ }
142
+ /** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
143
+ * mutations (create/rename/add-project/remove-project/delete/last-used)
144
+ * below run inside this. */
145
+ export function withProfileManifestLock(profileId, fn) {
146
+ assertSafeProfileId(profileId);
147
+ ensureDir(locksDir());
148
+ const lockPath = join(locksDir(), `${profileId}.lock`);
149
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
150
+ for (;;) {
151
+ try {
152
+ mkdirSync(lockPath);
153
+ break;
154
+ }
155
+ catch (err) {
156
+ if (err.code !== 'EEXIST')
157
+ throw err;
158
+ if (Date.now() > deadline) {
159
+ throw general(`timed out waiting for the profile lock: ${profileId}`, {
160
+ next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
161
+ });
162
+ }
163
+ sleepSync(LOCK_POLL_MS);
164
+ }
165
+ }
166
+ try {
167
+ return fn();
168
+ }
169
+ finally {
170
+ try {
171
+ rmSync(lockPath, { recursive: true, force: true });
172
+ }
173
+ catch {
174
+ /* best-effort release */
175
+ }
176
+ }
177
+ }
178
+ function writeManifestAtomic(path, manifest) {
179
+ ensureDir(dirname(path));
180
+ const tmp = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
181
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
182
+ renameSync(tmp, path);
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Project dirs — resolved to absolute real paths, must exist + be a directory
186
+ // on add/create, deduped by real path, stored in manifest order.
187
+ // ---------------------------------------------------------------------------
188
+ function resolveExistingProjectDir(dir) {
189
+ const abs = resolvePath(dir);
190
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
191
+ throw usage(`project directory does not exist or is not a directory: ${dir}`, {
192
+ received: dir,
193
+ next: 'Pass an existing directory path.',
194
+ });
195
+ }
196
+ try {
197
+ return realpathSync(abs);
198
+ }
199
+ catch {
200
+ return abs;
201
+ }
202
+ }
203
+ function dedupeOrdered(values) {
204
+ const seen = new Set();
205
+ const out = [];
206
+ for (const v of values) {
207
+ if (seen.has(v))
208
+ continue;
209
+ seen.add(v);
210
+ out.push(v);
211
+ }
212
+ return out;
213
+ }
214
+ function slugify(name) {
215
+ let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
216
+ if (slug.length > 40)
217
+ slug = slug.slice(0, 40).replace(/-+$/g, '');
218
+ return slug.length > 0 ? slug : 'profile';
219
+ }
220
+ function generateProfileId(name) {
221
+ const slug = slugify(name);
222
+ const root = profilesRoot();
223
+ for (let attempt = 0; attempt < 20; attempt++) {
224
+ const shortId = randomUUID().replace(/-/g, '').slice(0, 8);
225
+ const candidate = `${slug}-${shortId}`;
226
+ if (!existsSync(join(root, candidate)))
227
+ return candidate;
228
+ }
229
+ throw general(`failed to generate a unique profile id for "${name}" after 20 attempts`);
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Mutations — all under withProfileManifestLock, all atomic writes, all call
233
+ // resetScopeCache() when they can change project dirs or the profile name
234
+ // (never for a bare last-used-at touch).
235
+ // ---------------------------------------------------------------------------
236
+ export function createProfile(name, projects = []) {
237
+ const trimmed = name.trim();
238
+ if (trimmed === '')
239
+ throw usage('profile name must not be empty');
240
+ const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
241
+ const profileId = generateProfileId(trimmed);
242
+ return withProfileManifestLock(profileId, () => {
243
+ const root = profileRoot(profileId);
244
+ ensureDir(root);
245
+ ensureDir(join(root, 'memory'));
246
+ const manifest = {
247
+ schema_version: SCHEMA_VERSION,
248
+ name: trimmed,
249
+ projects: resolvedProjects,
250
+ created_at: nowIso(),
251
+ last_used_at: null,
252
+ };
253
+ writeManifestAtomic(profileManifestPath(profileId), manifest);
254
+ resetScopeCache();
255
+ return { profileId, manifest };
256
+ });
257
+ }
258
+ function requireExistingProfile(profileId) {
259
+ assertSafeProfileId(profileId);
260
+ if (!existsSync(profileManifestPath(profileId))) {
261
+ throw notFound(`profile not found: ${profileId}`, {
262
+ received: profileId,
263
+ next: 'Run `crtr profile list` to see available profiles.',
264
+ });
265
+ }
266
+ }
267
+ function mutateManifest(profileId, mutate) {
268
+ requireExistingProfile(profileId);
269
+ return withProfileManifestLock(profileId, () => {
270
+ const path = profileManifestPath(profileId);
271
+ const current = readManifestFile(path);
272
+ if (current === null) {
273
+ throw notFound(`profile not found: ${profileId}`, { received: profileId });
274
+ }
275
+ const next = mutate(current);
276
+ writeManifestAtomic(path, next);
277
+ return { profileId, manifest: next };
278
+ });
279
+ }
280
+ export function updateProfileLastUsed(profileId) {
281
+ return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
282
+ }
283
+ export function renameProfile(profileId, name) {
284
+ const trimmed = name.trim();
285
+ if (trimmed === '')
286
+ throw usage('profile name must not be empty');
287
+ const result = mutateManifest(profileId, (m) => ({ ...m, name: trimmed }));
288
+ resetScopeCache();
289
+ return result;
290
+ }
291
+ export function addProfileProject(profileId, dir) {
292
+ const resolved = resolveExistingProjectDir(dir);
293
+ const result = mutateManifest(profileId, (m) => ({
294
+ ...m,
295
+ projects: dedupeOrdered([...m.projects, resolved]),
296
+ }));
297
+ resetScopeCache();
298
+ return result;
299
+ }
300
+ export function removeProfileProject(profileId, dir) {
301
+ // Lenient resolution: a project dir may have been deleted since it was
302
+ // added, so removal must not require it to still exist on disk. Compare
303
+ // against both its real path (if still resolvable) and its plain resolved
304
+ // path (how it would have been stored had it never been a symlink).
305
+ const abs = resolvePath(dir);
306
+ let real = abs;
307
+ try {
308
+ real = realpathSync(abs);
309
+ }
310
+ catch {
311
+ /* dir no longer exists — fall back to comparing by its resolved path */
312
+ }
313
+ const result = mutateManifest(profileId, (m) => {
314
+ const filtered = m.projects.filter((p) => p !== real && p !== abs);
315
+ if (filtered.length === m.projects.length) {
316
+ throw notFound(`project not found on profile: ${dir}`, {
317
+ received: dir,
318
+ next: `Current projects: ${m.projects.length > 0 ? m.projects.join(', ') : '(none)'}.`,
319
+ });
320
+ }
321
+ return { ...m, projects: filtered };
322
+ });
323
+ resetScopeCache();
324
+ return result;
325
+ }
326
+ export function deleteProfile(profileId) {
327
+ requireExistingProfile(profileId);
328
+ withProfileManifestLock(profileId, () => {
329
+ rmSync(profileRoot(profileId), { recursive: true, force: true });
330
+ });
331
+ resetScopeCache();
332
+ }
@@ -0,0 +1,11 @@
1
+ /** Select the profile a node about to boot at `cwd` should run under.
2
+ *
3
+ * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
4
+ * bump `last_used_at`, return the id.
5
+ * 2. Else, among profiles whose project list COVERS `cwd`, pick the
6
+ * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
7
+ * 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
8
+ * create a profile here or proceed as root, BEFORE pi boots. Non-
9
+ * interactive (no TTY): default to root (null) and print the recovery
10
+ * instruction to STDERR — never stdout, which the caller may be piping. */
11
+ export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
@@ -0,0 +1,115 @@
1
+ // Startup profile selection — the CTO-decided order applied at the front door
2
+ // and at any non-inherited `crtr node new`: explicit `--profile` > MRU profile
3
+ // covering cwd > synchronous create-or-root decision. This is the ONLY place
4
+ // that decision runs; front-door.ts/spawn.ts call it, never re-derive it.
5
+ import { existsSync, realpathSync } from 'node:fs';
6
+ import { basename, resolve as resolvePath, sep } from 'node:path';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
9
+ /** Resolve cwd to an absolute, realpath'd form so it compares against the
10
+ * realpath'd project dirs `createProfile`/`addProfileProject` already store
11
+ * (manifest.ts resolves every project dir through `realpathSync`). Falls back
12
+ * to the plain resolved path when the dir can't be stat'd (mirrors
13
+ * `resolveExistingProjectDir`'s own fallback). */
14
+ function resolveCwd(cwd) {
15
+ const abs = resolvePath(cwd);
16
+ if (!existsSync(abs))
17
+ return abs;
18
+ try {
19
+ return realpathSync(abs);
20
+ }
21
+ catch {
22
+ return abs;
23
+ }
24
+ }
25
+ /** A project dir "covers" cwd when cwd sits at or below it, OR — the
26
+ * workspace-root case — the project dir itself sits below cwd (a profile can
27
+ * point at one project nested under a wider workspace cwd). */
28
+ function projectCovers(cwd, project) {
29
+ if (cwd === project)
30
+ return true;
31
+ if (cwd.startsWith(project + sep))
32
+ return true;
33
+ if (project.startsWith(cwd + sep))
34
+ return true;
35
+ return false;
36
+ }
37
+ function profileCoversCwd(entry, cwd) {
38
+ return entry.manifest.projects.some((p) => projectCovers(cwd, p));
39
+ }
40
+ /** `a` more recent than `b`, treating null as the oldest possible value (never
41
+ * wins over a real timestamp). ISO 8601 strings compare correctly lexically. */
42
+ function isMoreRecent(a, b) {
43
+ if (a === null)
44
+ return false;
45
+ if (b === null)
46
+ return true;
47
+ return a > b;
48
+ }
49
+ function pickMostRecent(entries) {
50
+ return entries.reduce((best, cur) => isMoreRecent(cur.manifest.last_used_at, best.manifest.last_used_at) ? cur : best);
51
+ }
52
+ function isInteractive() {
53
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
54
+ }
55
+ function recoveryMessage(cwd) {
56
+ return (`crtr: no profile covers ${cwd}, and this session is non-interactive — starting without a profile (root).\n` +
57
+ `Run \`crtr profile new --name <name> --project ${cwd}\` to create one, or pass --profile <id-or-name> next time.\n`);
58
+ }
59
+ /** The one synchronous (pre-boot) decision point: no profile covers cwd, and
60
+ * the session IS interactive. Ask on the controlling terminal — create a
61
+ * profile pointing at cwd, or proceed as root — and block until answered.
62
+ * Deliberately a minimal `readline` prompt, not `crtr human ask`: that bridge
63
+ * is asynchronous (writes a deck, waits for a separate process to answer it)
64
+ * and cannot gate pi's boot; the codebase's other humanloop entry points
65
+ * (`launchReview`, the `human _run` bridge) are built for reviewing an
66
+ * artifact or a queued interaction, not a two-choice gate on the same TTY pi
67
+ * is about to take over — a raw `readline` question is the actual minimal
68
+ * fit here. */
69
+ async function promptCreateOrRoot(cwd) {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ try {
72
+ process.stdout.write(`\nNo profile covers this directory:\n ${cwd}\n\n`);
73
+ const choice = (await rl.question('Create a profile here, or run as root? [c]reate / [r]oot: '))
74
+ .trim()
75
+ .toLowerCase();
76
+ if (choice !== 'c' && choice !== 'create')
77
+ return null;
78
+ const defaultName = basename(cwd) || 'profile';
79
+ const nameInput = (await rl.question(`Profile name [${defaultName}]: `)).trim();
80
+ const name = nameInput !== '' ? nameInput : defaultName;
81
+ return createProfile(name, [cwd]).profileId;
82
+ }
83
+ finally {
84
+ rl.close();
85
+ }
86
+ }
87
+ /** Select the profile a node about to boot at `cwd` should run under.
88
+ *
89
+ * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
90
+ * bump `last_used_at`, return the id.
91
+ * 2. Else, among profiles whose project list COVERS `cwd`, pick the
92
+ * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
93
+ * 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
94
+ * create a profile here or proceed as root, BEFORE pi boots. Non-
95
+ * interactive (no TTY): default to root (null) and print the recovery
96
+ * instruction to STDERR — never stdout, which the caller may be piping. */
97
+ export async function selectProfileForCwd(cwd, explicitProfile) {
98
+ if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
99
+ const entry = loadProfileManifest(explicitProfile);
100
+ updateProfileLastUsed(entry.profileId);
101
+ return entry.profileId;
102
+ }
103
+ const resolvedCwd = resolveCwd(cwd);
104
+ const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
105
+ if (covering.length > 0) {
106
+ const winner = pickMostRecent(covering);
107
+ updateProfileLastUsed(winner.profileId);
108
+ return winner.profileId;
109
+ }
110
+ if (!isInteractive()) {
111
+ process.stderr.write(recoveryMessage(resolvedCwd));
112
+ return null;
113
+ }
114
+ return await promptCreateOrRoot(resolvedCwd);
115
+ }
@@ -6,7 +6,7 @@
6
6
  // The raw JSON object is still available behind the `--json` global for tooling.
7
7
  //
8
8
  // A leaf may hand `defineLeaf` a bespoke `render(result)` for instruction-shaped
9
- // output that leads with the outcome (see `node new`, `push`, `feed read`).
9
+ // output that leads with the outcome (see `node new`, `push`).
10
10
  // Everything else falls through to the schema-driven generic renderer here, so
11
11
  // every command obeys the markdown paradigm without a hand-written renderer.
12
12
  // `renderError` is the one sanctioned exception: a failure is a different domain
@@ -9,6 +9,15 @@ export declare function buildProjectContextBlock(cwd: string): string;
9
9
  * Present for every node, rendered as the `## Your context directory`
10
10
  * subsection of <crtr-bearings>. */
11
11
  export declare function contextDirNote(nodeId: string): string;
12
+ /** The profile-purview note — rendered ONLY when `profile_id` resolves to a
13
+ * manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
14
+ * replacement: the node's cwd is still its one working dir (bash/edits run
15
+ * there), the profile just widens what it may ALSO read/act on. Omitted
16
+ * entirely for a root/no-profile node or a deleted/unreadable manifest —
17
+ * never throws (mirrors `assembleNodeSubject`'s profile resolution). This
18
+ * absolute-path disclosure is intentional (spec-approved): the agent must
19
+ * know its full purview, not a redacted count. */
20
+ export declare function profileNote(nodeId: string): string;
12
21
  /** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
13
22
  * so its context dir is also where a future cycle of itself resumes the work.
14
23
  * Used inside the bearings block AND in the promotion guidance dump, so a
@@ -41,6 +41,7 @@ import { homedir } from 'node:os';
41
41
  import { join, resolve } from 'node:path';
42
42
  import { cadenceDisplay } from '../wake.js';
43
43
  import { renderKnowledgeBlock } from '../substrate/index.js';
44
+ import { loadProfileManifest } from '../profiles/manifest.js';
44
45
  // ---------------------------------------------------------------------------
45
46
  // Project context (<project_context>) — the AGENTS.md/CLAUDE.md files pi would
46
47
  // otherwise inject into its SYSTEM PROMPT, rendered here so they ride the
@@ -206,6 +207,32 @@ export function contextDirNote(nodeId) {
206
207
  'this directory, so a bare relative `context/...` lands in the repo, not here — and other nodes ' +
207
208
  'can only read what is actually here. When you report a file, report its absolute path.');
208
209
  }
210
+ /** The profile-purview note — rendered ONLY when `profile_id` resolves to a
211
+ * manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
212
+ * replacement: the node's cwd is still its one working dir (bash/edits run
213
+ * there), the profile just widens what it may ALSO read/act on. Omitted
214
+ * entirely for a root/no-profile node or a deleted/unreadable manifest —
215
+ * never throws (mirrors `assembleNodeSubject`'s profile resolution). This
216
+ * absolute-path disclosure is intentional (spec-approved): the agent must
217
+ * know its full purview, not a redacted count. */
218
+ export function profileNote(nodeId) {
219
+ const meta = getNode(nodeId);
220
+ const profileId = meta?.profile_id;
221
+ if (profileId === undefined || profileId === null || profileId === '')
222
+ return '';
223
+ let entry;
224
+ try {
225
+ entry = loadProfileManifest(profileId);
226
+ }
227
+ catch {
228
+ return '';
229
+ }
230
+ const projects = entry.manifest.projects;
231
+ if (projects.length === 0)
232
+ return '';
233
+ return (`Your working dir is \`${meta.cwd}\`. Your profile **${entry.manifest.name}** also has purview ` +
234
+ `over: ${projects.join(', ')}.`);
235
+ }
209
236
  /** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
210
237
  * so its context dir is also where a future cycle of itself resumes the work.
211
238
  * Used inside the bearings block AND in the promotion guidance dump, so a
@@ -415,6 +442,10 @@ export function buildContextBearings(nodeId) {
415
442
  if (map !== '')
416
443
  bearings.push('', '## Your place in the canvas', map);
417
444
  bearings.push('', '## Your context directory', contextDirNote(nodeId));
445
+ // Profile purview — omitted entirely for a root/no-profile node (see profileNote).
446
+ const profileSection = profileNote(nodeId);
447
+ if (profileSection !== '')
448
+ bearings.push('', profileSection);
418
449
  // Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
419
450
  if (node?.mode === 'orchestrator')
420
451
  bearings.push('', orchestratorContextNote(nodeId));
@@ -6,3 +6,18 @@ export declare function clearBusy(nodeId: string): void;
6
6
  * site — a stale marker from a crashed pi is harmless because the dead pid
7
7
  * fails the AND. */
8
8
  export declare function isBusy(nodeId: string): boolean;
9
+ /** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
10
+ * heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
11
+ * every engine-level sign of life WITHIN a turn (a tool starting/streaming/
12
+ * ending, a streamed assistant token, a turn ending) so the marker's mtime
13
+ * always reflects the last moment the engine visibly did something, not just
14
+ * when the turn began. No-ops when the node isn't currently busy — a touch
15
+ * arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
16
+ * never resurrect a stale marker. Best-effort. */
17
+ export declare function touchBusy(nodeId: string): void;
18
+ /** The mid-turn marker's mtime: the last moment ANY engine-level progress was
19
+ * observed while the node was busy (see `touchBusy`). Null when the node isn't
20
+ * busy or the marker is unreadable. This is the sole heartbeat the daemon's
21
+ * wedge detector reads — it needs no new instrumentation beyond re-touching an
22
+ * already-existing file. */
23
+ export declare function busySince(nodeId: string): number | null;
@@ -13,7 +13,7 @@
13
13
  // read site, so a stale marker (process crashed mid-turn without firing
14
14
  // agent_end) is harmless — the dead pid fails the AND and the node is reaped.
15
15
  // No db migration, atomic touch/unlink, best-effort (never throws).
16
- import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
16
+ import { existsSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { jobDir } from '../canvas/index.js';
19
19
  function busyPath(nodeId) {
@@ -44,3 +44,34 @@ export function clearBusy(nodeId) {
44
44
  export function isBusy(nodeId) {
45
45
  return existsSync(busyPath(nodeId));
46
46
  }
47
+ /** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
48
+ * heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
49
+ * every engine-level sign of life WITHIN a turn (a tool starting/streaming/
50
+ * ending, a streamed assistant token, a turn ending) so the marker's mtime
51
+ * always reflects the last moment the engine visibly did something, not just
52
+ * when the turn began. No-ops when the node isn't currently busy — a touch
53
+ * arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
54
+ * never resurrect a stale marker. Best-effort. */
55
+ export function touchBusy(nodeId) {
56
+ try {
57
+ if (!existsSync(busyPath(nodeId)))
58
+ return;
59
+ writeFileSync(busyPath(nodeId), '');
60
+ }
61
+ catch {
62
+ /* best-effort */
63
+ }
64
+ }
65
+ /** The mid-turn marker's mtime: the last moment ANY engine-level progress was
66
+ * observed while the node was busy (see `touchBusy`). Null when the node isn't
67
+ * busy or the marker is unreadable. This is the sole heartbeat the daemon's
68
+ * wedge detector reads — it needs no new instrumentation beyond re-touching an
69
+ * already-existing file. */
70
+ export function busySince(nodeId) {
71
+ try {
72
+ return statSync(busyPath(nodeId)).mtimeMs;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
@@ -1,3 +1,13 @@
1
+ import { type SubscriptionRef } from '../canvas/index.js';
2
+ /** The doctrine wake: fan a system notice to every given subscriber of a node
3
+ * whose lifecycle just ended out from under it (closed, or crashed to dead) —
4
+ * the runtime guarantee that a manager waiting dormant on a child always
5
+ * learns of its terminal outcome instead of hanging forever. Active subscriber
6
+ * → inbox (wakes a dormant manager via its live watcher / the daemon's
7
+ * dormant-revive pass); passive → the passive accumulator (delivered, not
8
+ * woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
9
+ * crash path (revive.ts). */
10
+ export declare function fanDoctrineWake(fromId: string, subscribers: readonly SubscriptionRef[], label: string, data: Record<string, unknown>): void;
1
11
  export interface CloseNodeResult {
2
12
  /** The focused node that was closed — the cascade root. */
3
13
  root: string;
@@ -36,6 +36,23 @@ import { headlessBrokerHost } from './host.js';
36
36
  import { tearDownNode, reapIfEmpty } from './placement.js';
37
37
  import { appendInbox } from '../feed/inbox.js';
38
38
  import { appendPassive } from '../feed/passive.js';
39
+ /** The doctrine wake: fan a system notice to every given subscriber of a node
40
+ * whose lifecycle just ended out from under it (closed, or crashed to dead) —
41
+ * the runtime guarantee that a manager waiting dormant on a child always
42
+ * learns of its terminal outcome instead of hanging forever. Active subscriber
43
+ * → inbox (wakes a dormant manager via its live watcher / the daemon's
44
+ * dormant-revive pass); passive → the passive accumulator (delivered, not
45
+ * woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
46
+ * crash path (revive.ts). */
47
+ export function fanDoctrineWake(fromId, subscribers, label, data) {
48
+ for (const sub of subscribers) {
49
+ const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
50
+ if (sub.active)
51
+ appendInbox(sub.node_id, notice);
52
+ else
53
+ appendPassive(sub.node_id, notice);
54
+ }
55
+ }
39
56
  /** The set of nodes to close: the root plus every descendant reachable down the
40
57
  * subscriptions spine, all of whose managers are themselves in the set. Grown
41
58
  * to a fixpoint — a node added this pass can qualify its own children next
@@ -186,19 +203,7 @@ export function closeNode(rootId, opts = {}) {
186
203
  // never fires inside a self-contained cascade. Active → inbox (wakes a
187
204
  // dormant manager via its live watcher / the daemon's dormant-revive
188
205
  // second pass); passive → passive accumulator (delivered, not woken).
189
- for (const sub of survivors) {
190
- const notice = {
191
- from: id,
192
- tier: 'normal',
193
- kind: 'message',
194
- label: `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`,
195
- data: { reason: 'child-closed', child: id },
196
- };
197
- if (sub.active)
198
- appendInbox(sub.node_id, notice);
199
- else
200
- appendPassive(sub.node_id, notice);
201
- }
206
+ fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
202
207
  closed.push(id);
203
208
  }
204
209
  catch {