chamba 0.6.0 → 0.7.0

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 (55) hide show
  1. package/README.md +13 -6
  2. package/dist/commands/dev.js +11 -11
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +33 -7
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/dockerfile-builder.js +2 -1
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/safe-rm.js +13 -3
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +3 -5
  12. package/templates/Dockerfile +20 -1
  13. package/templates/context/web-pane-craft.md +1 -1
  14. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  15. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  16. package/templates/pane-apps/client/specs/index.html +13 -0
  17. package/templates/pane-apps/server/specs.mjs +1588 -0
  18. package/templates/skills/chamba-statusline/SKILL.md +1 -1
  19. package/templates/skills/dx-spec/SKILL.md +365 -0
  20. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  21. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  22. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  23. package/templates/skills/dx-spec/references/stages.md +399 -0
  24. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  25. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  26. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  27. package/templates/specs.sh +106 -0
  28. package/templates/webterm/README.md +50 -14
  29. package/templates/webterm/artifacts.js +11 -11
  30. package/templates/webterm/config.js +52 -9
  31. package/templates/webterm/conversation.js +3 -3
  32. package/templates/webterm/pane.js +14 -3
  33. package/templates/webterm/proc.js +1 -1
  34. package/templates/webterm/public/app/alerts.js +5 -5
  35. package/templates/webterm/public/app/composer.js +5 -2
  36. package/templates/webterm/public/app/connection.js +2 -2
  37. package/templates/webterm/public/app/dictation.js +1 -1
  38. package/templates/webterm/public/app/dom.js +13 -5
  39. package/templates/webterm/public/app/frames.js +8 -1
  40. package/templates/webterm/public/app/main.js +8 -2
  41. package/templates/webterm/public/app/new-session.js +1 -1
  42. package/templates/webterm/public/app/pane-shell.js +315 -0
  43. package/templates/webterm/public/app/pane.js +58 -183
  44. package/templates/webterm/public/app/specs-host.js +222 -0
  45. package/templates/webterm/public/app/state.js +1 -1
  46. package/templates/webterm/public/app/tabs.js +1 -1
  47. package/templates/webterm/public/app/terminal.js +8 -0
  48. package/templates/webterm/public/index.html +51 -27
  49. package/templates/webterm/public/styles.css +144 -30
  50. package/templates/webterm/server.js +300 -11
  51. package/templates/webterm/sessions.js +7 -7
  52. package/templates/webterm/snapshot.js +2 -2
  53. package/templates/webterm/specs.js +358 -0
  54. package/templates/webterm/tool-document.js +67 -0
  55. package/templates/webterm/typed-line.js +85 -0
@@ -0,0 +1,1588 @@
1
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ //#region src/specs/contracts/index.ts
5
+ /** Where the tool keeps everything it writes, inside a piece of work. */
6
+ var TOOL_DIR = ".specs";
7
+ /** The reader's own marks and drafts for one piece of work. */
8
+ var STATE_FILE = "reader-state.json";
9
+ /** The user's feedback, written by the tool and applied by an agent. */
10
+ var FEEDBACK_FILE = "user-feedback.json";
11
+ /** Assets referenced from the feedback file, relative to the tool's directory. */
12
+ var FEEDBACK_ASSETS_DIR = "user-feedback-assets";
13
+ var DEFAULT_TOOLBAR = {
14
+ textScale: 1,
15
+ contentWidth: 1100,
16
+ theme: "dark"
17
+ };
18
+ function emptyState() {
19
+ return {
20
+ version: 1,
21
+ units: {},
22
+ overallDraft: "",
23
+ dismissed: [],
24
+ toolbar: { ...DEFAULT_TOOLBAR }
25
+ };
26
+ }
27
+ function emptyFeedback(entry) {
28
+ return {
29
+ version: 1,
30
+ entry,
31
+ rounds: []
32
+ };
33
+ }
34
+ var isObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
35
+ var str$2 = (value, fallback = "") => typeof value === "string" ? value : fallback;
36
+ var num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
37
+ /**
38
+ * Read the reader's state from whatever is on disk. Anything missing,
39
+ * unreadable or written under a version this tool does not know reads as empty
40
+ * state, so a corrupt or future file costs the user their marks and drafts and
41
+ * nothing more.
42
+ */
43
+ function readState$1(raw) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw || "null");
47
+ } catch {
48
+ return emptyState();
49
+ }
50
+ if (!isObject$2(parsed) || parsed.version !== 1) return emptyState();
51
+ const state = emptyState();
52
+ if (isObject$2(parsed.units)) for (const [id, value] of Object.entries(parsed.units)) {
53
+ if (!isObject$2(value)) continue;
54
+ const unit = {};
55
+ if (typeof value.readHash === "string") unit.readHash = value.readHash;
56
+ if (typeof value.readAt === "string") unit.readAt = value.readAt;
57
+ if (typeof value.draft === "string") unit.draft = value.draft;
58
+ state.units[id] = unit;
59
+ }
60
+ state.overallDraft = str$2(parsed.overallDraft);
61
+ if (Array.isArray(parsed.dismissed)) state.dismissed = parsed.dismissed.filter((id) => typeof id === "string");
62
+ if (isObject$2(parsed.toolbar)) state.toolbar = {
63
+ textScale: num(parsed.toolbar.textScale, DEFAULT_TOOLBAR.textScale),
64
+ contentWidth: num(parsed.toolbar.contentWidth, DEFAULT_TOOLBAR.contentWidth),
65
+ theme: parsed.toolbar.theme === "light" ? "light" : DEFAULT_TOOLBAR.theme
66
+ };
67
+ return state;
68
+ }
69
+ /**
70
+ * Read a feedback file the tool wrote earlier and is about to merge into. An
71
+ * unreadable or unknown-version file reads as empty for the given entry: a new
72
+ * submit then starts a fresh file rather than appending to something the
73
+ * applying agent could not make sense of.
74
+ */
75
+ function readFeedback(raw, entry) {
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(raw || "null");
79
+ } catch {
80
+ return emptyFeedback(entry);
81
+ }
82
+ if (!isObject$2(parsed) || parsed.version !== 1) return emptyFeedback(entry);
83
+ const rounds = [];
84
+ if (Array.isArray(parsed.rounds)) for (const round of parsed.rounds) {
85
+ if (!isObject$2(round)) continue;
86
+ const comments = [];
87
+ if (Array.isArray(round.comments)) for (const comment of round.comments) {
88
+ if (!isObject$2(comment) || typeof comment.unit !== "string") continue;
89
+ const assets = [];
90
+ if (Array.isArray(comment.assets)) for (const asset of comment.assets) {
91
+ if (!isObject$2(asset) || typeof asset.path !== "string") continue;
92
+ assets.push({
93
+ path: asset.path,
94
+ name: str$2(asset.name, asset.path),
95
+ type: str$2(asset.type)
96
+ });
97
+ }
98
+ comments.push({
99
+ unit: comment.unit,
100
+ file: str$2(comment.file),
101
+ heading: str$2(comment.heading),
102
+ kind: comment.kind === "remove" ? "remove" : "comment",
103
+ quote: str$2(comment.quote),
104
+ text: str$2(comment.text),
105
+ assets
106
+ });
107
+ }
108
+ rounds.push({
109
+ submittedAt: str$2(round.submittedAt),
110
+ comments
111
+ });
112
+ }
113
+ return {
114
+ version: 1,
115
+ entry: str$2(parsed.entry, entry),
116
+ rounds
117
+ };
118
+ }
119
+ //#endregion
120
+ //#region src/specs/contracts/protocol.ts
121
+ var SPEC_STATE_FILE = "state.json";
122
+ /**
123
+ * Every stage a protocol may hold, in the one order every view uses.
124
+ *
125
+ * This is the list the agent composes a recommendation over. It is data rather
126
+ * than something an agent remembers, so a recommendation cannot name a stage
127
+ * that does not exist, and a stage added here reaches every view at once.
128
+ */
129
+ var STAGE_CATALOG = [
130
+ {
131
+ id: "exploration",
132
+ title: "High-level exploration",
133
+ group: "discovery",
134
+ about: "We think the problem through together first - directions to take, risks, and what is still unclear."
135
+ },
136
+ {
137
+ id: "interview",
138
+ title: "Interview",
139
+ group: "discovery",
140
+ about: "I ask you focused questions until nothing important is left open."
141
+ },
142
+ {
143
+ id: "research",
144
+ title: "Research",
145
+ group: "discovery",
146
+ about: "I look outside your code - a library, a protocol, an algorithm, a subject area - and write up what I find."
147
+ },
148
+ {
149
+ id: "codebase-analysis",
150
+ title: "Codebase analysis",
151
+ group: "discovery",
152
+ about: "I read the parts of your code this work touches and note what shapes the design."
153
+ },
154
+ {
155
+ id: "technical-specs",
156
+ title: "Technical specs",
157
+ group: "deliverables",
158
+ about: "A written specification another agent can build from without asking you anything."
159
+ },
160
+ {
161
+ id: "ui-mocks",
162
+ title: "UI mocks",
163
+ group: "deliverables",
164
+ about: "Mock screens you open in your browser and comment on before anything is built."
165
+ },
166
+ {
167
+ id: "execution-plan",
168
+ title: "Execution plan",
169
+ group: "deliverables",
170
+ about: "The work split into phases, each with a goal and a way to tell it is done."
171
+ },
172
+ {
173
+ id: "quality-review",
174
+ title: "Quality review",
175
+ group: "deliverables",
176
+ about: "Fresh helper agents review everything we wrote, and I fix what is plainly wrong and bring the judgment calls to you."
177
+ }
178
+ ];
179
+ /** The stages whose presence makes imagination mode worth offering. */
180
+ var IMAGINATION_TRIGGERS = ["exploration", "ui-mocks"];
181
+ function catalogStage(id) {
182
+ return STAGE_CATALOG.find((stage) => stage.id === id) ?? null;
183
+ }
184
+ function emptySpecState(name, at) {
185
+ return {
186
+ version: 1,
187
+ name,
188
+ created: at,
189
+ status: "specifying",
190
+ protocol: [],
191
+ modes: { imagination: false },
192
+ activity: {
193
+ said: "",
194
+ at: ""
195
+ },
196
+ awaiting: [],
197
+ phases: [],
198
+ reviews: [],
199
+ approvals: [],
200
+ log: []
201
+ };
202
+ }
203
+ var isObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
204
+ var str$1 = (value, fallback = "") => typeof value === "string" ? value : fallback;
205
+ var STATUSES = [
206
+ "specifying",
207
+ "ready",
208
+ "executing",
209
+ "complete"
210
+ ];
211
+ var STAGE_STATUSES$1 = [
212
+ "waiting",
213
+ "running",
214
+ "done"
215
+ ];
216
+ var KINDS = [
217
+ "gate",
218
+ "round",
219
+ "protocol"
220
+ ];
221
+ /**
222
+ * Read the state from whatever is on disk, and keep everything else about it.
223
+ *
224
+ * What this reader does not know is not carried here. `writeSpecState` merges a
225
+ * write into the file it read, which is what keeps a later tool's fields; this
226
+ * side stays a plain shape. A file this reader cannot parse at all reads as
227
+ * a fresh state rather than throwing: a spec whose state file is broken is still
228
+ * a spec, and the artifacts in it are the durable half.
229
+ */
230
+ function readSpecState(raw, name = "", at = "") {
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(raw || "null");
234
+ } catch {
235
+ return emptySpecState(name, at);
236
+ }
237
+ if (!isObject$1(parsed) || parsed.version !== 1) return emptySpecState(name, at);
238
+ const state = emptySpecState(str$1(parsed.name, name), str$1(parsed.created, at));
239
+ const status = str$1(parsed.status);
240
+ if (STATUSES.includes(status)) state.status = status;
241
+ if (Array.isArray(parsed.protocol)) for (const stage of parsed.protocol) {
242
+ if (!isObject$1(stage) || !catalogStage(str$1(stage.id))) continue;
243
+ const held = str$1(stage.status);
244
+ state.protocol.push({
245
+ id: str$1(stage.id),
246
+ status: STAGE_STATUSES$1.includes(held) ? held : "waiting",
247
+ artifacts: Array.isArray(stage.artifacts) ? stage.artifacts.filter((path) => typeof path === "string") : []
248
+ });
249
+ }
250
+ if (isObject$1(parsed.modes)) state.modes.imagination = parsed.modes.imagination === true;
251
+ if (isObject$1(parsed.activity)) state.activity = {
252
+ said: str$1(parsed.activity.said),
253
+ at: str$1(parsed.activity.at)
254
+ };
255
+ if (Array.isArray(parsed.awaiting)) for (const item of parsed.awaiting) {
256
+ if (!isObject$1(item) || !item.id) continue;
257
+ const kind = str$1(item.kind);
258
+ state.awaiting.push({
259
+ id: str$1(item.id),
260
+ kind: KINDS.includes(kind) ? kind : "round",
261
+ title: str$1(item.title),
262
+ file: str$1(item.file),
263
+ payload: str$1(item.payload),
264
+ declaredAt: str$1(item.declaredAt)
265
+ });
266
+ }
267
+ if (Array.isArray(parsed.phases)) for (const phase of parsed.phases) {
268
+ if (!isObject$1(phase) || !phase.id) continue;
269
+ const held = str$1(phase.status);
270
+ state.phases.push({
271
+ id: str$1(phase.id),
272
+ title: str$1(phase.title),
273
+ status: STAGE_STATUSES$1.includes(held) ? held : "waiting"
274
+ });
275
+ }
276
+ if (Array.isArray(parsed.reviews)) for (const review of parsed.reviews) {
277
+ if (!isObject$1(review) || typeof review.round !== "number") continue;
278
+ state.reviews.push({
279
+ round: review.round,
280
+ verdict: str$1(review.verdict),
281
+ findings: typeof review.findings === "number" ? review.findings : 0,
282
+ judgments: typeof review.judgments === "number" ? review.judgments : 0,
283
+ at: str$1(review.at)
284
+ });
285
+ }
286
+ if (Array.isArray(parsed.approvals)) for (const one of parsed.approvals) {
287
+ if (!isObject$1(one) || !one.id) continue;
288
+ state.approvals.push({
289
+ id: str$1(one.id),
290
+ at: str$1(one.at),
291
+ outcome: str$1(one.outcome)
292
+ });
293
+ }
294
+ if (Array.isArray(parsed.log)) for (const line of parsed.log) {
295
+ if (!isObject$1(line)) continue;
296
+ state.log.push({
297
+ at: str$1(line.at),
298
+ said: str$1(line.said)
299
+ });
300
+ }
301
+ return state;
302
+ }
303
+ /**
304
+ * The state as it goes to disk, carrying anything a later tool wrote.
305
+ *
306
+ * The known fields are this tool's and are replaced whole; everything else in
307
+ * the file it read is kept as it was.
308
+ */
309
+ function writeSpecState(previousRaw, state) {
310
+ let held = {};
311
+ try {
312
+ const parsed = JSON.parse(previousRaw || "null");
313
+ if (isObject$1(parsed)) held = parsed;
314
+ } catch {
315
+ held = {};
316
+ }
317
+ return `${JSON.stringify({
318
+ ...held,
319
+ ...state
320
+ }, null, 2)}\n`;
321
+ }
322
+ //#endregion
323
+ //#region src/lib/files.ts
324
+ var MARKDOWN = /* @__PURE__ */ new Set([
325
+ "md",
326
+ "markdown",
327
+ "mdown",
328
+ "mkd"
329
+ ]);
330
+ var HTML = /* @__PURE__ */ new Set(["html", "htm"]);
331
+ var IMAGE = /* @__PURE__ */ new Set([
332
+ "png",
333
+ "jpg",
334
+ "jpeg",
335
+ "gif",
336
+ "webp",
337
+ "svg",
338
+ "avif",
339
+ "bmp",
340
+ "ico"
341
+ ]);
342
+ var TEXT = new Set("txt text log json yaml yml toml ini cfg conf csv tsv xml css scss js jsx mjs cjs ts tsx py rb go rs java kt swift c h cpp hpp sh bash zsh fish sql graphql gql env diff patch lock gitignore editorconfig".split(" "));
343
+ /**
344
+ * The extension, lowercased and without its dot; empty when there is none.
345
+ * A dot-prefixed name is a name, not an extension: `.specs` has none.
346
+ */
347
+ function extensionOf(name) {
348
+ const match = name.slice(name.lastIndexOf("/") + 1).slice(1).match(/\.([^.]+)$/);
349
+ return match ? match[1].toLowerCase() : "";
350
+ }
351
+ /** Whether an entry is hidden from a tool: dot-prefixed names never render. */
352
+ function isHidden(name) {
353
+ return name.startsWith(".");
354
+ }
355
+ function fileKind(name) {
356
+ const extension = extensionOf(name);
357
+ if (MARKDOWN.has(extension)) return "markdown";
358
+ if (HTML.has(extension)) return "html";
359
+ if (IMAGE.has(extension)) return "image";
360
+ if (TEXT.has(extension)) return "text";
361
+ if (!extension) return "text";
362
+ return "binary";
363
+ }
364
+ var startsUppercase = (name) => /^[A-Z]/.test(name);
365
+ /**
366
+ * Names starting with an uppercase letter first, then the rest, both halves in
367
+ * plain name order. A stage's uppercase main document then renders before its
368
+ * lowercase supporting artifacts, and numbered artifacts still follow their
369
+ * prefixes. Plain ASCII order alone would put `01-input.md` above `SPEC.md`,
370
+ * since digits sort before uppercase letters.
371
+ */
372
+ function compareNames(a, b) {
373
+ const upperA = startsUppercase(a);
374
+ if (upperA !== startsUppercase(b)) return upperA ? -1 : 1;
375
+ return a.localeCompare(b, "en");
376
+ }
377
+ //#endregion
378
+ //#region server/specs/paths.ts
379
+ var PathRefused = class extends Error {
380
+ requested;
381
+ constructor(requested) {
382
+ super(`specs: refused path ${requested}`);
383
+ this.requested = requested;
384
+ this.name = "PathRefused";
385
+ }
386
+ };
387
+ /** A path the server was asked for but which is not a directory it can serve. */
388
+ var NoSuchEntry = class extends Error {
389
+ requested;
390
+ constructor(requested) {
391
+ super(`specs: no directory at ${requested}`);
392
+ this.requested = requested;
393
+ this.name = "NoSuchEntry";
394
+ }
395
+ };
396
+ /**
397
+ * Where a path really lands on disk, symlinks and all. A path being created
398
+ * does not exist yet, so the walk goes up to the nearest parent that does and
399
+ * puts the rest back on: the link that matters is always one of the parents.
400
+ */
401
+ function realPath(absolute) {
402
+ let candidate = absolute;
403
+ const tail = [];
404
+ for (;;) try {
405
+ return join(realpathSync(candidate), ...tail);
406
+ } catch {
407
+ const parent = dirname(candidate);
408
+ if (parent === candidate) return absolute;
409
+ tail.unshift(basename(candidate));
410
+ candidate = parent;
411
+ }
412
+ }
413
+ /** The served root, resolved through symlinks once at start. */
414
+ function resolveRoot(root) {
415
+ const absolute = resolve(root);
416
+ try {
417
+ return realpathSync(absolute);
418
+ } catch {
419
+ return absolute;
420
+ }
421
+ }
422
+ /** Whether any segment of a root-relative path is dot-prefixed. */
423
+ function hasHiddenSegment(relativePath) {
424
+ return relativePath.split("/").some((segment) => segment !== "" && isHidden(segment));
425
+ }
426
+ /**
427
+ * Resolve a root-relative path handed in by the browser.
428
+ * Refused: an absolute path, a path that climbs out of the root however it is
429
+ * spelled, a path that lands outside it through a symlink, and - depending on
430
+ * the caller - a dot-prefixed name.
431
+ *
432
+ * `hidden: 'refuse'` is the reading side: no path through a dot-prefixed name
433
+ * is served, the tool's own `.specs/` included, which is why no name of its
434
+ * own needs excluding anywhere else.
435
+ * `hidden: 'require'` is the writing side: everything the tool writes lives
436
+ * inside a dot-prefixed directory, so a path without one is not its to write.
437
+ *
438
+ * The dot-prefixed rule is applied to how the path is spelled and to where it
439
+ * lands, because the two can disagree: `.specs/../spec/SPEC.md` is spelled
440
+ * through a dot-prefixed directory and lands outside one.
441
+ */
442
+ function resolvePath(root, relativePath, hidden) {
443
+ const requested = relativePath.replace(/^\/+/, "");
444
+ if (isAbsolute(relativePath) || requested.includes("\0")) throw new PathRefused(relativePath);
445
+ const need = hidden === "require";
446
+ if (hasHiddenSegment(requested) !== need) throw new PathRefused(relativePath);
447
+ const absolute = resolve(root, requested);
448
+ if (!containsOrEquals(root, absolute)) throw new PathRefused(relativePath);
449
+ if (!containsOrEquals(root, realPath(absolute))) throw new PathRefused(relativePath);
450
+ if (hasHiddenSegment(relative(root, absolute).split(sep).join("/")) !== need) throw new PathRefused(relativePath);
451
+ return absolute;
452
+ }
453
+ /** Resolve a path the browser asked to read. */
454
+ function resolveWithin(root, relativePath) {
455
+ return resolvePath(root, relativePath, "refuse");
456
+ }
457
+ /** Resolve a path the tool writes to. */
458
+ function resolveToolWrite(root, relativePath) {
459
+ return resolvePath(root, relativePath, "require");
460
+ }
461
+ /** Whether one directory contains another, or is the same directory. */
462
+ function containsOrEquals(outer, inner) {
463
+ return inner === outer || inner.startsWith(outer + sep);
464
+ }
465
+ /**
466
+ * The entry a root-relative path belongs to: the top-level directory holding
467
+ * it, an archived directory named with its group, or the empty string for a
468
+ * file sitting loose at the served root. Under `self` the root is the only
469
+ * entry, so everything under it belongs to it.
470
+ */
471
+ function entryForPath(relativePath, mode = "children") {
472
+ if (mode === "self") return "";
473
+ const segments = relativePath.split("/").filter(Boolean);
474
+ if (segments.length < 2) return "";
475
+ if (segments[0] === "archive") return segments.length > 2 ? `archive/${segments[1]}` : "";
476
+ return segments[0];
477
+ }
478
+ //#endregion
479
+ //#region server/specs/listing.ts
480
+ /** The one name the tool knows: entries under it render read-only. */
481
+ var ARCHIVE_DIR = "archive";
482
+ function visibleEntries(directory) {
483
+ let entries;
484
+ try {
485
+ entries = readdirSync(directory, { withFileTypes: true });
486
+ } catch {
487
+ return [];
488
+ }
489
+ return entries.filter((entry) => !isHidden(entry.name)).sort((a, b) => compareNames(a.name, b.name));
490
+ }
491
+ /** A file this big is not read to hash it; its size and time answer instead. */
492
+ var HASHED_LIMIT = 4 * 1024 * 1024;
493
+ /**
494
+ * What an image or a binary hashes to, so a read mark hangs on its content.
495
+ * A file the page reads as text is hashed by the page itself from that text,
496
+ * and a file too big to read here keeps no hash - it falls back to size and time.
497
+ *
498
+ * Only a page asks for this. The dashboard is names, counts and times, and
499
+ * opens no file at all.
500
+ */
501
+ function bytesHash(absolute, kind, size) {
502
+ if (kind === "markdown" || kind === "text") return void 0;
503
+ if (size > HASHED_LIMIT) return void 0;
504
+ try {
505
+ return createHash("sha1").update(readFileSync(absolute)).digest("hex").slice(0, 16);
506
+ } catch {
507
+ return;
508
+ }
509
+ }
510
+ /**
511
+ * Walk a directory into a flat list of files in render order: a directory's own
512
+ * files first, then its subdirectories, each name-ordered so a stage's uppercase
513
+ * main document leads its group.
514
+ */
515
+ function walk(absolute, relative, recursive = true, hashed = false) {
516
+ const entries = visibleEntries(absolute);
517
+ const files = [];
518
+ let modified = 0;
519
+ for (const entry of entries) {
520
+ if (!entry.isFile() && !entry.isDirectory()) continue;
521
+ if (entry.isDirectory()) continue;
522
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
523
+ let stats;
524
+ try {
525
+ stats = statSync(join(absolute, entry.name));
526
+ } catch {
527
+ continue;
528
+ }
529
+ modified = Math.max(modified, stats.mtimeMs);
530
+ const kind = fileKind(entry.name);
531
+ files.push({
532
+ path,
533
+ name: entry.name,
534
+ dir: relative,
535
+ kind,
536
+ size: stats.size,
537
+ modified: new Date(stats.mtimeMs).toISOString(),
538
+ hash: hashed ? bytesHash(join(absolute, entry.name), kind, stats.size) : void 0
539
+ });
540
+ }
541
+ if (recursive) for (const entry of entries) {
542
+ if (!entry.isDirectory()) continue;
543
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
544
+ const inner = walk(join(absolute, entry.name), path, true, hashed);
545
+ files.push(...inner.files);
546
+ modified = Math.max(modified, inner.modified);
547
+ }
548
+ return {
549
+ files,
550
+ modified
551
+ };
552
+ }
553
+ function entryOf(root, path, recursive) {
554
+ const absolute = path ? join(root, path) : root;
555
+ const walked = walk(absolute, "", recursive);
556
+ let modified = walked.modified;
557
+ if (!modified) try {
558
+ modified = statSync(absolute).mtimeMs;
559
+ } catch {
560
+ modified = 0;
561
+ }
562
+ return {
563
+ path,
564
+ name: path ? basename(path) : basename(root),
565
+ files: walked.files.length,
566
+ modified: new Date(modified).toISOString(),
567
+ archived: isArchived(path),
568
+ status: statusOf(root, path),
569
+ awaiting: awaitingOf(root, path)
570
+ };
571
+ }
572
+ /**
573
+ * Where a piece of work stands, read off its own state file.
574
+ *
575
+ * The navigator says this beside each name, so a reader sees which work is moving and which is waiting
576
+ * without opening every one of them. A directory with no state file yet is work somebody started by hand
577
+ * rather than through the tool, and it has no status to show rather than a wrong one.
578
+ */
579
+ function readState(root, path) {
580
+ if (!path) return null;
581
+ try {
582
+ return readSpecState(readFileSync(join(root, path, TOOL_DIR, SPEC_STATE_FILE), "utf8"), basename(path));
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function statusOf(root, path) {
588
+ return readState(root, path)?.status ?? null;
589
+ }
590
+ function awaitingOf(root, path) {
591
+ return readState(root, path)?.awaiting.length ?? 0;
592
+ }
593
+ /**
594
+ * The dashboard.
595
+ *
596
+ * Serving a root that holds one directory per piece of work (`children`): its
597
+ * own page first when it holds visible files of its own, then its directories,
598
+ * with everything under `archive/` in the archived group at the end.
599
+ * Serving a piece of work directly (`self`): one entry, the root itself, whole.
600
+ */
601
+ function readDashboard(root, mode = "children") {
602
+ const rootName = basename(root);
603
+ if (mode === "self") return {
604
+ root,
605
+ rootName,
606
+ mode,
607
+ entries: [entryOf(root, "", true)]
608
+ };
609
+ const top = visibleEntries(root);
610
+ const entries = [];
611
+ if (top.some((entry) => entry.isFile())) entries.push(entryOf(root, "", false));
612
+ for (const entry of top) {
613
+ if (!entry.isDirectory() || entry.name === ARCHIVE_DIR) continue;
614
+ entries.push(entryOf(root, entry.name, true));
615
+ }
616
+ if (top.some((entry) => entry.isDirectory() && entry.name === ARCHIVE_DIR)) for (const archived of visibleEntries(join(root, ARCHIVE_DIR))) {
617
+ if (!archived.isDirectory()) continue;
618
+ entries.push(entryOf(root, `${ARCHIVE_DIR}/${archived.name}`, true));
619
+ }
620
+ return {
621
+ root,
622
+ rootName,
623
+ mode,
624
+ entries
625
+ };
626
+ }
627
+ /** Whether a root-relative path sits under the archive directory. */
628
+ function isArchived(path) {
629
+ return path === ARCHIVE_DIR || path.startsWith(`${ARCHIVE_DIR}/`);
630
+ }
631
+ /**
632
+ * One entry's visible content, in render order: the entry's own files first,
633
+ * then each subdirectory's, so the page reads as groups. Under `children`, the
634
+ * served root's own entry holds its loose files alone - each of its directories
635
+ * is an entry of its own. Under `self`, the root is the entry and holds it all.
636
+ *
637
+ * A path that is not a directory - one archived or renamed while a tab had it
638
+ * open - is refused rather than answered as an empty page.
639
+ */
640
+ function readEntry(root, path, mode = "children") {
641
+ const absolute = path ? resolveWithin(root, path) : root;
642
+ let stats;
643
+ try {
644
+ stats = statSync(absolute);
645
+ } catch {
646
+ throw new NoSuchEntry(path);
647
+ }
648
+ if (!stats.isDirectory()) throw new NoSuchEntry(path);
649
+ return {
650
+ path,
651
+ name: path ? basename(path) : basename(root),
652
+ archived: isArchived(path),
653
+ files: walk(absolute, "", path !== "" || mode === "self", true).files
654
+ };
655
+ }
656
+ //#endregion
657
+ //#region src/lib/units.ts
658
+ /**
659
+ * Lowercase, every run of characters that is not a letter or a digit turned
660
+ * into one dash, no dash left at either end. Backticks are dropped first, so a
661
+ * heading like "## The `.specs` directory" slugs as the words alone.
662
+ */
663
+ function slugify(text) {
664
+ return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "";
665
+ }
666
+ //#endregion
667
+ //#region src/specs/contracts/readme.ts
668
+ /** The day of an ISO timestamp, which is the resolution a log line is read at. */
669
+ function day(iso) {
670
+ return /^\d{4}-\d{2}-\d{2}/.test(iso) ? iso.slice(0, 10) : "";
671
+ }
672
+ function renderSpecReadme(state) {
673
+ const lines = [`# ${state.name || "A piece of work"}`, ""];
674
+ const created = day(state.created);
675
+ if (created) lines.push(`- Created: ${created}`);
676
+ lines.push(`- Status: ${state.status}`);
677
+ if (state.modes.imagination) lines.push("- Mode: imagination");
678
+ if (state.protocol.length > 0) {
679
+ lines.push("", "## Protocol", "");
680
+ for (const stage of state.protocol) {
681
+ const known = catalogStage(stage.id);
682
+ const running = stage.status === "running" ? " (running)" : "";
683
+ lines.push(`- [${stage.status === "done" ? "x" : " "}] ${known?.title ?? stage.id}${running}`);
684
+ }
685
+ }
686
+ if (state.log.length > 0) {
687
+ lines.push("", "## Log", "");
688
+ for (const line of state.log) {
689
+ const at = day(line.at);
690
+ lines.push(at ? `- ${at}: ${line.said}` : `- ${line.said}`);
691
+ }
692
+ }
693
+ return `${lines.join("\n")}\n`;
694
+ }
695
+ //#endregion
696
+ //#region src/specs/contracts/rounds.ts
697
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
698
+ var str = (value, fallback = "") => typeof value === "string" ? value : fallback;
699
+ var OUTCOMES = [
700
+ "approved",
701
+ "changes",
702
+ "apply",
703
+ "dismiss",
704
+ "answered"
705
+ ];
706
+ /** An answer as it stands on disk, or null for something nobody has answered. */
707
+ function readAnswer(value) {
708
+ if (!isObject(value)) return null;
709
+ const outcome = str(value.outcome);
710
+ if (!OUTCOMES.includes(outcome)) return null;
711
+ const questions = [];
712
+ for (const one of Array.isArray(value.questions) ? value.questions : []) {
713
+ if (!isObject(one) || typeof one.question !== "string") continue;
714
+ questions.push({
715
+ question: one.question,
716
+ chosen: Array.isArray(one.chosen) ? one.chosen.filter((id) => typeof id === "string") : [],
717
+ said: str(one.said),
718
+ answered: one.answered === true
719
+ });
720
+ }
721
+ return {
722
+ outcome,
723
+ said: str(value.said),
724
+ questions,
725
+ answeredAt: str(value.answeredAt)
726
+ };
727
+ }
728
+ //#endregion
729
+ //#region server/specs/write.ts
730
+ /** The tool's directory inside one entry, as a root-relative path. */
731
+ function toolDirOf(entry) {
732
+ return entry ? `${entry}/${TOOL_DIR}` : TOOL_DIR;
733
+ }
734
+ /** Write a file atomically: a temporary name beside it, then a rename. */
735
+ function writeAtomic(absolute, contents) {
736
+ mkdirSync(dirname(absolute), { recursive: true });
737
+ const temporary = `${absolute}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
738
+ try {
739
+ writeFileSync(temporary, contents);
740
+ renameSync(temporary, absolute);
741
+ } finally {
742
+ rmSync(temporary, { force: true });
743
+ }
744
+ }
745
+ /** Write one of the tool's own files, refusing any path outside a `.specs/`. */
746
+ function writeToolFile(root, relativePath, contents) {
747
+ writeAtomic(resolveToolWrite(root, relativePath), contents);
748
+ }
749
+ function readToolFile(root, relativePath) {
750
+ const absolute = resolveToolWrite(root, relativePath);
751
+ try {
752
+ return readFileSync(absolute, "utf8");
753
+ } catch {
754
+ return null;
755
+ }
756
+ }
757
+ /**
758
+ * An entry's state. A directory nobody has marked or drafted in yet has no
759
+ * state file, and starts from the reading preferences set at the served root,
760
+ * so the theme and the text size a reader chose carry into the next directory
761
+ * they open rather than snapping back to the defaults.
762
+ */
763
+ function readEntryState(root, entry) {
764
+ entryMustExist$1(root, entry);
765
+ const raw = readToolFile(root, `${toolDirOf(entry)}/${STATE_FILE}`);
766
+ if (raw !== null || entry === "") return readState$1(raw);
767
+ const state = readState$1(null);
768
+ state.toolbar = readState$1(readToolFile(root, `${TOOL_DIR}/${STATE_FILE}`)).toolbar;
769
+ return state;
770
+ }
771
+ /** Write an entry's state, creating its `.specs/` directory the first time. */
772
+ function writeEntryState(root, entry, state) {
773
+ entryMustAcceptWrites(root, entry);
774
+ writeToolFile(root, `${toolDirOf(entry)}/${STATE_FILE}`, `${JSON.stringify(state, null, 2)}\n`);
775
+ }
776
+ function entryMustExist$1(root, entry) {
777
+ if (!existsSync(entry ? resolveWithin(root, entry) : root)) throw new Error(`specs: no entry at ${entry}`);
778
+ }
779
+ /**
780
+ * An archived directory is signed off: the tool reads it and writes nothing
781
+ * into it, so browsing an archived piece of work leaves it exactly as it was.
782
+ */
783
+ function entryMustAcceptWrites(root, entry) {
784
+ entryMustExist$1(root, entry);
785
+ if (isArchived(entry)) throw new PathRefused(entry);
786
+ }
787
+ /** A name safe to write, keeping the extension the user's file arrived with. */
788
+ function assetFileName(stamp, name) {
789
+ return `${stamp}-${name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+/, "") || "attachment"}`;
790
+ }
791
+ function freeName(directory, name) {
792
+ if (!existsSync(join(directory, name))) return name;
793
+ const dot = name.lastIndexOf(".");
794
+ const stem = dot > 0 ? name.slice(0, dot) : name;
795
+ const extension = dot > 0 ? name.slice(dot) : "";
796
+ let n = 2;
797
+ while (existsSync(join(directory, `${stem}-${n}${extension}`))) n++;
798
+ return `${stem}-${n}${extension}`;
799
+ }
800
+ /**
801
+ * The rounds already filed for one spec, newest last.
802
+ *
803
+ * A window shows what it has queued of its own, which is unsent and nobody else's. This is the other
804
+ * half: what has been sent, which is on disk and is the same for every window on the same work.
805
+ */
806
+ function filedRounds(root, entry) {
807
+ return readFeedback(readToolFile(root, `${toolDirOf(entry)}/${FEEDBACK_FILE}`), entry).rounds;
808
+ }
809
+ function submitFeedback(root, entry, submission, now = /* @__PURE__ */ new Date()) {
810
+ entryMustAcceptWrites(root, entry);
811
+ const toolDir = toolDirOf(entry);
812
+ const assetsDir = `${toolDir}/${FEEDBACK_ASSETS_DIR}`;
813
+ const absoluteAssets = resolveToolWrite(root, assetsDir);
814
+ const stamp = now.toISOString().slice(0, 19).replace("T", "-").replace(/:/g, "");
815
+ const comments = [];
816
+ for (const comment of submission.comments) {
817
+ if (!comment.unit) continue;
818
+ const kind = comment.kind === "remove" ? "remove" : "comment";
819
+ if (kind === "comment" && !comment.text.trim() && comment.assets.length === 0) continue;
820
+ const assets = [];
821
+ for (const asset of comment.assets) {
822
+ mkdirSync(absoluteAssets, { recursive: true });
823
+ const name = freeName(absoluteAssets, assetFileName(stamp, asset.name));
824
+ writeToolFile(root, `${assetsDir}/${name}`, Buffer.from(asset.data, "base64"));
825
+ assets.push({
826
+ path: `${FEEDBACK_ASSETS_DIR}/${name}`,
827
+ name: asset.name,
828
+ type: asset.type
829
+ });
830
+ }
831
+ comments.push({
832
+ unit: comment.unit,
833
+ file: comment.file,
834
+ heading: comment.heading,
835
+ kind,
836
+ quote: comment.quote ?? "",
837
+ text: comment.text,
838
+ assets
839
+ });
840
+ }
841
+ const feedbackPath = `${toolDir}/${FEEDBACK_FILE}`;
842
+ const feedback = readFeedback(readToolFile(root, feedbackPath), entry);
843
+ feedback.version = 1;
844
+ feedback.entry = entry;
845
+ feedback.rounds.push({
846
+ submittedAt: now.toISOString(),
847
+ comments
848
+ });
849
+ writeToolFile(root, feedbackPath, `${JSON.stringify(feedback, null, 2)}\n`);
850
+ clearSentDrafts(root, entry, comments);
851
+ return {
852
+ path: feedbackPath,
853
+ round: feedback.rounds.length
854
+ };
855
+ }
856
+ /**
857
+ * A sent draft is no longer a draft. Read marks are left alone: what the user
858
+ * folded away stays folded, and the text stays visible in the boxes.
859
+ */
860
+ function clearSentDrafts(root, entry, comments) {
861
+ const state = readEntryState(root, entry);
862
+ let changed = false;
863
+ for (const comment of comments) {
864
+ if (comment.unit === "overall") {
865
+ if (state.overallDraft) {
866
+ state.overallDraft = "";
867
+ changed = true;
868
+ }
869
+ continue;
870
+ }
871
+ const unit = state.units[comment.unit];
872
+ if (unit?.draft) {
873
+ delete unit.draft;
874
+ changed = true;
875
+ }
876
+ }
877
+ if (changed) writeEntryState(root, entry, state);
878
+ }
879
+ //#endregion
880
+ //#region server/specs/protocol.ts
881
+ /** A request this module will not act on. The mount turns it into a bad request. */
882
+ var Refused = class extends Error {
883
+ constructor(said) {
884
+ super(`specs: ${said}`);
885
+ this.name = "Refused";
886
+ }
887
+ };
888
+ /** Where a spec's rounds and gates keep their payloads, inside the tool's directory. */
889
+ var ROUNDS_DIR = "rounds";
890
+ /** The artifact an intake becomes, at the root of the spec directory. */
891
+ var INTAKE_FILE = "intake.md";
892
+ var INTAKE_ASSETS_DIR = "intake-assets";
893
+ function statePathOf(entry) {
894
+ return `${toolDirOf(entry)}/${SPEC_STATE_FILE}`;
895
+ }
896
+ /**
897
+ * The tool reading one of its own files.
898
+ *
899
+ * This resolves through the writing door rather than the reading one: the
900
+ * reading door refuses every dot-prefixed name, which is where everything the
901
+ * tool writes lives. A file that is not there reads as nothing, since a spec
902
+ * with no state file yet is the ordinary case rather than a failure.
903
+ */
904
+ function readRaw(root, relativePath) {
905
+ try {
906
+ return readFileSync(resolveToolWrite(root, relativePath), "utf8");
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
911
+ /**
912
+ * A spec's state as it stands, with the file it came from, so a write keeps what this reader ignores.
913
+ *
914
+ * The spec has to be there. Every verb that moves a piece of work reads through here and writes back,
915
+ * and a write makes the directories under it, so without this check any name a caller hands over
916
+ * becomes a spec: a typo raises a directory that lists in the navigator as real work, and an empty
917
+ * name raises one at the served root itself, with a generated README beside the specs. Filing an
918
+ * intake is the one verb that creates a spec, and it makes the directory before it writes any state,
919
+ * so it does not come through here.
920
+ */
921
+ function specState(root, entry) {
922
+ entryMustExist(root, entry);
923
+ const raw = readRaw(root, statePathOf(entry));
924
+ return {
925
+ state: readSpecState(raw, entry.split("/").pop() ?? entry),
926
+ raw
927
+ };
928
+ }
929
+ /**
930
+ * Refuse a name that is not a piece of work sitting in the served root.
931
+ *
932
+ * Called at the head of the verbs that write their payload before they read the state, because a write
933
+ * makes the directory it needs and the name would exist by the time the state was read.
934
+ */
935
+ function entryMustExist(root, entry) {
936
+ const named = entry.trim();
937
+ if (!named) throw new Refused("which piece of work? name one");
938
+ let there = false;
939
+ try {
940
+ there = statSync(resolveWithin(root, named)).isDirectory();
941
+ } catch {
942
+ there = false;
943
+ }
944
+ if (!there) throw new Refused(`there is no piece of work called "${named}"`);
945
+ }
946
+ /** Write the state, and render the README beside it in the same act. */
947
+ function saveSpecState(root, entry, state, raw) {
948
+ writeToolFile(root, statePathOf(entry), writeSpecState(raw, state));
949
+ writeAtomic(resolveWithin(root, `${entry}/README.md`), renderSpecReadme(state));
950
+ }
951
+ /** Add one dated line to the log, which is what the rendered README shows. */
952
+ function log(state, said, at) {
953
+ state.log.push({
954
+ at,
955
+ said
956
+ });
957
+ }
958
+ /** A name safe to write, keeping the extension the file arrived with. */
959
+ function assetName(name) {
960
+ return name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+/, "") || "attachment";
961
+ }
962
+ /**
963
+ * The directory a working name becomes. A name already taken gets a number, so
964
+ * two pieces of work with one name are two directories rather than one that
965
+ * overwrote the other.
966
+ */
967
+ function freeSlug(root, name) {
968
+ const base = slugify(name) || "spec";
969
+ if (!existsSync(resolveWithin(root, base))) return base;
970
+ let n = 2;
971
+ while (existsSync(resolveWithin(root, `${base}-${n}`))) n++;
972
+ return `${base}-${n}`;
973
+ }
974
+ /**
975
+ * File an intake: the one way a piece of work begins, from the pane's form or
976
+ * from a session through the helper.
977
+ */
978
+ function fileIntake(root, intake, now = /* @__PURE__ */ new Date()) {
979
+ const name = intake.name.trim();
980
+ if (!name) throw new Refused("an intake needs a working name");
981
+ if (!intake.text.trim() && intake.attachments.length === 0) throw new Refused("an intake needs something in it");
982
+ const entry = freeSlug(root, name);
983
+ mkdirSync(resolveWithin(root, entry), { recursive: true });
984
+ const written = [];
985
+ for (const attachment of intake.attachments) {
986
+ const relative = `${INTAKE_ASSETS_DIR}/${assetName(attachment.name)}`;
987
+ mkdirSync(resolveWithin(root, `${entry}/${INTAKE_ASSETS_DIR}`), { recursive: true });
988
+ writeAtomic(resolveWithin(root, `${entry}/${relative}`), Buffer.from(attachment.data, "base64"));
989
+ written.push(relative);
990
+ }
991
+ const body = [
992
+ `# ${name}`,
993
+ "",
994
+ intake.text.trim(),
995
+ ""
996
+ ];
997
+ if (written.length > 0) {
998
+ body.push("## Attached", "");
999
+ for (const relative of written) body.push(`- [${relative.split("/").pop()}](${relative})`);
1000
+ body.push("");
1001
+ }
1002
+ writeAtomic(resolveWithin(root, `${entry}/${INTAKE_FILE}`), body.join("\n"));
1003
+ const at = now.toISOString();
1004
+ const state = readSpecState(null, name, at);
1005
+ log(state, "filed", at);
1006
+ saveSpecState(root, entry, state, null);
1007
+ return {
1008
+ entry,
1009
+ path: `${entry}/${INTAKE_FILE}`
1010
+ };
1011
+ }
1012
+ /** The proposal file the confirmation form is rendered from. */
1013
+ var PROPOSAL_FILE = `${ROUNDS_DIR}/protocol.json`;
1014
+ /**
1015
+ * Take the agent's recommendation and put the question to the user.
1016
+ *
1017
+ * Every catalog stage is answered here, whether it is recommended or not: a
1018
+ * form that shows only what was recommended asks the user to notice an absence,
1019
+ * which is the one thing a form is worst at.
1020
+ */
1021
+ function proposeProtocol(root, entry, proposal, now = /* @__PURE__ */ new Date()) {
1022
+ entryMustExist(root, entry);
1023
+ const said = /* @__PURE__ */ new Map();
1024
+ for (const stage of proposal.stages) {
1025
+ if (!catalogStage(stage.id)) throw new Refused(`no stage named ${stage.id}`);
1026
+ said.set(stage.id, stage);
1027
+ }
1028
+ const stages = STAGE_CATALOG.map((stage) => ({
1029
+ id: stage.id,
1030
+ recommended: said.get(stage.id)?.recommended === true,
1031
+ why: said.get(stage.id)?.why ?? ""
1032
+ }));
1033
+ const at = now.toISOString();
1034
+ writeToolFile(root, `${toolDirOf(entry)}/${PROPOSAL_FILE}`, `${JSON.stringify({
1035
+ stages,
1036
+ note: proposal.note,
1037
+ offeredAt: at
1038
+ }, null, 2)}\n`);
1039
+ const item = {
1040
+ id: "protocol",
1041
+ kind: "protocol",
1042
+ title: "Confirm the steps for this work",
1043
+ file: "",
1044
+ payload: PROPOSAL_FILE,
1045
+ declaredAt: at
1046
+ };
1047
+ const { state, raw } = specState(root, entry);
1048
+ state.awaiting = [...state.awaiting.filter((held) => held.id !== item.id), item];
1049
+ saveSpecState(root, entry, state, raw);
1050
+ return item;
1051
+ }
1052
+ /**
1053
+ * Write the confirmed protocol. The order is the catalog's, whatever order the
1054
+ * answers arrived in, so every view of a run reads the same sequence.
1055
+ */
1056
+ function confirmProtocol(root, entry, confirmation, now = /* @__PURE__ */ new Date()) {
1057
+ const chosen = new Set(confirmation.stages);
1058
+ for (const id of chosen) if (!catalogStage(id)) throw new Refused(`no stage named ${id}`);
1059
+ const { state, raw } = specState(root, entry);
1060
+ const held = new Map(state.protocol.map((stage) => [stage.id, stage]));
1061
+ state.protocol = STAGE_CATALOG.filter((stage) => chosen.has(stage.id)).map((stage) => held.get(stage.id) ?? {
1062
+ id: stage.id,
1063
+ status: "waiting",
1064
+ artifacts: []
1065
+ });
1066
+ state.modes.imagination = confirmation.imagination && state.protocol.some((stage) => IMAGINATION_TRIGGERS.includes(stage.id));
1067
+ state.awaiting = state.awaiting.filter((item) => item.kind !== "protocol");
1068
+ const at = now.toISOString();
1069
+ state.approvals.push({
1070
+ id: "protocol",
1071
+ at,
1072
+ outcome: "confirmed"
1073
+ });
1074
+ log(state, `protocol confirmed${state.modes.imagination ? " - imagination mode" : ""}`, at);
1075
+ if (confirmation.note.trim()) log(state, `said with the protocol: ${confirmation.note.trim()}`, at);
1076
+ saveSpecState(root, entry, state, raw);
1077
+ return state;
1078
+ }
1079
+ function postProgress(root, entry, post, now = /* @__PURE__ */ new Date()) {
1080
+ const { state, raw } = specState(root, entry);
1081
+ const at = now.toISOString();
1082
+ if (post.kind === "activity") state.activity = {
1083
+ said: post.said,
1084
+ at
1085
+ };
1086
+ else if (post.kind === "stage") {
1087
+ const known = catalogStage(post.stage);
1088
+ if (!known) throw new Refused(`no stage named ${post.stage}`);
1089
+ const stage = state.protocol.find((held) => held.id === post.stage);
1090
+ if (!stage) throw new Refused(`${post.stage} is not in this protocol`);
1091
+ stage.status = post.status;
1092
+ if (post.artifacts) stage.artifacts = [.../* @__PURE__ */ new Set([...stage.artifacts, ...post.artifacts])];
1093
+ if (post.status === "done") log(state, `${known.title.toLowerCase()} complete`, at);
1094
+ if (post.status === "running") state.activity = {
1095
+ said: known.title,
1096
+ at
1097
+ };
1098
+ } else if (post.kind === "phase") {
1099
+ const id = slugify(post.phase);
1100
+ if (!id) throw new Refused("a phase needs a name");
1101
+ const held = state.phases.find((phase) => phase.id === id);
1102
+ const title = post.title ?? held?.title ?? post.phase;
1103
+ if (held) {
1104
+ held.status = post.status;
1105
+ held.title = title;
1106
+ } else state.phases.push({
1107
+ id,
1108
+ title,
1109
+ status: post.status
1110
+ });
1111
+ if (post.status === "done") log(state, `${title} done`, at);
1112
+ if (post.status === "running") state.activity = {
1113
+ said: title,
1114
+ at
1115
+ };
1116
+ } else if (post.kind === "status") {
1117
+ state.status = post.status;
1118
+ log(state, `status: ${post.status}`, at);
1119
+ } else log(state, post.said, at);
1120
+ saveSpecState(root, entry, state, raw);
1121
+ return state;
1122
+ }
1123
+ function declareAwaiting(root, entry, declaration, now = /* @__PURE__ */ new Date()) {
1124
+ entryMustExist(root, entry);
1125
+ const id = slugify(declaration.id);
1126
+ if (!id) throw new Refused("an awaiting item needs an id");
1127
+ const payloadPath = `${ROUNDS_DIR}/${id}.json`;
1128
+ const at = now.toISOString();
1129
+ writeToolFile(root, `${toolDirOf(entry)}/${payloadPath}`, `${JSON.stringify({
1130
+ id,
1131
+ kind: declaration.kind,
1132
+ title: declaration.title,
1133
+ file: declaration.file,
1134
+ declaredAt: at,
1135
+ payload: declaration.payload
1136
+ }, null, 2)}\n`);
1137
+ const item = {
1138
+ id,
1139
+ kind: declaration.kind,
1140
+ title: declaration.title,
1141
+ file: declaration.file,
1142
+ payload: payloadPath,
1143
+ declaredAt: at
1144
+ };
1145
+ const { state, raw } = specState(root, entry);
1146
+ state.awaiting = [...state.awaiting.filter((held) => held.id !== id), item];
1147
+ saveSpecState(root, entry, state, raw);
1148
+ return item;
1149
+ }
1150
+ /**
1151
+ * One round or gate as it was declared, with the answer if it has one.
1152
+ *
1153
+ * This is how the pane reads what it has to render: the payload files live
1154
+ * inside the tool's dot-directory, which the reading route refuses on purpose,
1155
+ * so a round is read through the verb rather than as a workspace file.
1156
+ */
1157
+ function readRound(root, entry, id) {
1158
+ const name = slugify(id);
1159
+ if (!name) throw new Refused("a round has an id");
1160
+ const raw = readRaw(root, `${toolDirOf(entry)}/${ROUNDS_DIR}/${name}.json`);
1161
+ if (raw === null) throw new Refused(`no round named ${id}`);
1162
+ try {
1163
+ const parsed = JSON.parse(raw);
1164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Refused(`the round named ${id} could not be read`);
1165
+ return parsed;
1166
+ } catch {
1167
+ throw new Refused(`the round named ${id} could not be read`);
1168
+ }
1169
+ }
1170
+ /**
1171
+ * What the user answered, or nothing when they have not.
1172
+ *
1173
+ * A round is answered by writing the answer into its own file, so reading it
1174
+ * back is reading one file rather than matching a submission to a question.
1175
+ */
1176
+ function readAnswers(root, entry, id) {
1177
+ return readRound(root, entry, id).answer ?? null;
1178
+ }
1179
+ /**
1180
+ * Answer one thing the run is waiting on.
1181
+ *
1182
+ * The answer goes into the round's own file, and the item stops awaiting. The
1183
+ * first answer wins: two windows can be looking at one gate, and the second one
1184
+ * is told it was already answered rather than quietly overwriting the first.
1185
+ */
1186
+ function answerRound(root, entry, id, answer, now = /* @__PURE__ */ new Date()) {
1187
+ const name = slugify(id);
1188
+ const round = readRound(root, entry, name);
1189
+ if (readAnswer(round.answer) !== null) throw new Refused(`${id} was already answered`);
1190
+ const at = now.toISOString();
1191
+ const written = {
1192
+ ...answer,
1193
+ answeredAt: at
1194
+ };
1195
+ const payloadPath = `${toolDirOf(entry)}/${ROUNDS_DIR}/${name}.json`;
1196
+ writeToolFile(root, payloadPath, `${JSON.stringify({
1197
+ ...round,
1198
+ answer: written
1199
+ }, null, 2)}\n`);
1200
+ const { state, raw } = specState(root, entry);
1201
+ const item = state.awaiting.find((held) => held.id === name);
1202
+ state.awaiting = state.awaiting.filter((held) => held.id !== name);
1203
+ state.approvals.push({
1204
+ id: name,
1205
+ at,
1206
+ outcome: answer.outcome
1207
+ });
1208
+ log(state, `${item?.title || name}: ${SAID[answer.outcome]}`, at);
1209
+ saveSpecState(root, entry, state, raw);
1210
+ return {
1211
+ path: payloadPath,
1212
+ outcome: answer.outcome
1213
+ };
1214
+ }
1215
+ /** How each outcome reads in the log, which is what the rendered README shows. */
1216
+ var SAID = {
1217
+ approved: "approved",
1218
+ changes: "changes asked for",
1219
+ apply: "applied",
1220
+ dismiss: "dismissed",
1221
+ answered: "answered"
1222
+ };
1223
+ /** Post one round of quality review, which the board shows as it lands. */
1224
+ function postReview(root, entry, review, now = /* @__PURE__ */ new Date()) {
1225
+ const at = now.toISOString();
1226
+ const { state, raw } = specState(root, entry);
1227
+ const round = {
1228
+ ...review,
1229
+ at
1230
+ };
1231
+ state.reviews = [...state.reviews.filter((held) => held.round !== round.round), round].sort((one, two) => one.round - two.round);
1232
+ log(state, `review round ${round.round}: ${round.verdict}`, at);
1233
+ saveSpecState(root, entry, state, raw);
1234
+ return state;
1235
+ }
1236
+ //#endregion
1237
+ //#region server/specs/read.ts
1238
+ var MEDIA_TYPES = {
1239
+ md: "text/markdown; charset=utf-8",
1240
+ markdown: "text/markdown; charset=utf-8",
1241
+ html: "text/html; charset=utf-8",
1242
+ htm: "text/html; charset=utf-8",
1243
+ css: "text/css; charset=utf-8",
1244
+ js: "text/javascript; charset=utf-8",
1245
+ json: "application/json; charset=utf-8",
1246
+ svg: "image/svg+xml",
1247
+ png: "image/png",
1248
+ jpg: "image/jpeg",
1249
+ jpeg: "image/jpeg",
1250
+ gif: "image/gif",
1251
+ webp: "image/webp",
1252
+ avif: "image/avif",
1253
+ bmp: "image/bmp",
1254
+ ico: "image/x-icon",
1255
+ pdf: "application/pdf",
1256
+ txt: "text/plain; charset=utf-8"
1257
+ };
1258
+ /** The media type to serve a name with; plain text for anything textual. */
1259
+ function mediaType(name) {
1260
+ return MEDIA_TYPES[extensionOf(name)] ?? "application/octet-stream";
1261
+ }
1262
+ /** A file that exists and is not served, because reading it whole is not something a request may ask for. */
1263
+ var FileTooLarge = class extends Error {
1264
+ requested;
1265
+ size;
1266
+ constructor(requested, size) {
1267
+ super(`specs: too large to serve - ${requested}`);
1268
+ this.requested = requested;
1269
+ this.size = size;
1270
+ this.name = "FileTooLarge";
1271
+ }
1272
+ };
1273
+ /**
1274
+ * Read a file inside the served root. Throws `PathRefused` for anything outside it.
1275
+ *
1276
+ * The file is opened once and everything after that is asked of the open handle, so the size and the
1277
+ * bytes describe the same file. The open refuses to follow a link at the last step, which is what the
1278
+ * resolve above cannot promise on its own: it reads where the path lands now, and a process in the
1279
+ * container can put a link where the file was in the moment between the two, and have this route hand
1280
+ * a frame a file from outside the root - the one thing the frame exists to be unable to reach. So a
1281
+ * file reached through a link is not read here, and the resolve stays for everything above the leaf.
1282
+ */
1283
+ function readWithin(root, relativePath) {
1284
+ const handle = openSync(resolveWithin(root, relativePath), constants.O_RDONLY | constants.O_NOFOLLOW);
1285
+ try {
1286
+ const stats = fstatSync(handle);
1287
+ if (!stats.isFile()) throw new Error(`specs: not a file - ${relativePath}`);
1288
+ if (stats.size > 33554432) throw new FileTooLarge(relativePath, stats.size);
1289
+ return {
1290
+ bytes: readFileSync(handle),
1291
+ mediaType: mediaType(relativePath),
1292
+ size: stats.size,
1293
+ modified: new Date(stats.mtimeMs).toISOString()
1294
+ };
1295
+ } finally {
1296
+ closeSync(handle);
1297
+ }
1298
+ }
1299
+ /**
1300
+ * Watch the served root recursively and hand each change to `onChange`, at most
1301
+ * once per path per debounce window. A platform without recursive watching
1302
+ * still reports changes at the root itself, which is the honest fallback.
1303
+ */
1304
+ function watchRoot(root, onChange, debounceMs = 120, mode = "children") {
1305
+ const pending = /* @__PURE__ */ new Map();
1306
+ let timer = null;
1307
+ const flush = () => {
1308
+ timer = null;
1309
+ const events = [...pending.values()];
1310
+ pending.clear();
1311
+ for (const event of events) try {
1312
+ onChange(event);
1313
+ } catch {}
1314
+ };
1315
+ const record = (relative) => {
1316
+ const path = relative.split(/[\\/]/).filter(Boolean).join("/");
1317
+ if (!path) return;
1318
+ if (hasHiddenSegment(path) && !path.endsWith(`/.specs/user-feedback.json`)) return;
1319
+ const kind = existsSync(`${root}/${path}`) ? "changed" : "gone";
1320
+ pending.set(path, {
1321
+ entry: entryForPath(path, mode),
1322
+ path,
1323
+ kind
1324
+ });
1325
+ if (!timer) timer = setTimeout(flush, debounceMs);
1326
+ };
1327
+ let watcher = null;
1328
+ try {
1329
+ watcher = watch(root, { recursive: true }, (_event, filename) => {
1330
+ if (filename) record(filename.toString());
1331
+ });
1332
+ watcher.on("error", () => {
1333
+ watcher?.close();
1334
+ watcher = null;
1335
+ });
1336
+ } catch {
1337
+ watcher = null;
1338
+ }
1339
+ return { close() {
1340
+ if (timer) clearTimeout(timer);
1341
+ timer = null;
1342
+ watcher?.close();
1343
+ } };
1344
+ }
1345
+ //#endregion
1346
+ //#region server/specs/index.ts
1347
+ /** A request the module understood and refused. */
1348
+ var BadRequest = class extends Error {
1349
+ code = "bad-request";
1350
+ constructor(what) {
1351
+ super(`specs: ${what}`);
1352
+ this.name = "BadRequest";
1353
+ }
1354
+ };
1355
+ /**
1356
+ * The status a mount should answer a thrown error with. A path outside the root
1357
+ * is refused, a missing entry or file is not found, a body the module could not
1358
+ * use is a bad request, a file too big to read whole is too large, and anything
1359
+ * else is the mount's own fault.
1360
+ */
1361
+ function statusFor(error) {
1362
+ if (error instanceof PathRefused) return 403;
1363
+ if (error instanceof NoSuchEntry) return 404;
1364
+ if (error instanceof BadRequest || error instanceof Refused) return 400;
1365
+ if (error instanceof FileTooLarge) return 413;
1366
+ if (error instanceof Error && /^specs: (no entry|not a file)/.test(error.message)) return 404;
1367
+ if (isMissingFile(error)) return 404;
1368
+ return 500;
1369
+ }
1370
+ function isMissingFile(error) {
1371
+ const code = error?.code;
1372
+ return code === "ENOENT" || code === "ENOTDIR";
1373
+ }
1374
+ /**
1375
+ * The two artifacts this tool writes outside its own dot-directory: the intake
1376
+ * it files, and the README it renders from the state. Everything else at the
1377
+ * top of a spec directory was written by whoever is doing the work.
1378
+ */
1379
+ var TOOL_ARTIFACTS = /* @__PURE__ */ new Set([INTAKE_FILE, "README.md"]);
1380
+ /**
1381
+ * Where a file this tool wrote sits on disk.
1382
+ *
1383
+ * Two kinds, and no third: everything inside a `.specs/`, and the artifacts the
1384
+ * tool renders into the spec directory itself. A path that is neither is
1385
+ * refused, which is what keeps "a delivery names something this tool wrote"
1386
+ * true rather than approximate.
1387
+ */
1388
+ function toolWritten(root, path) {
1389
+ const parts = path.split("/");
1390
+ const name = parts.pop() ?? "";
1391
+ if (parts.length === 1 && parts[0] && TOOL_ARTIFACTS.has(name)) return resolveWithin(root, path);
1392
+ return resolveToolWrite(root, path);
1393
+ }
1394
+ var asObject = (value, what) => {
1395
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new BadRequest(`${what} is an object`);
1396
+ return value;
1397
+ };
1398
+ var asText = (value, fallback = "") => typeof value === "string" ? value : fallback;
1399
+ var asList = (value) => Array.isArray(value) ? value : [];
1400
+ function asIntake(payload) {
1401
+ const body = asObject(payload, "an intake");
1402
+ const attachments = [];
1403
+ for (const one of asList(body.attachments)) {
1404
+ const file = asObject(one, "an attachment");
1405
+ if (typeof file.data !== "string" || typeof file.name !== "string") throw new BadRequest("an attachment carries a name and base64 data");
1406
+ attachments.push({
1407
+ name: file.name,
1408
+ type: asText(file.type),
1409
+ data: file.data
1410
+ });
1411
+ }
1412
+ return {
1413
+ name: asText(body.name),
1414
+ text: asText(body.text),
1415
+ attachments
1416
+ };
1417
+ }
1418
+ function asProposal(payload) {
1419
+ const body = asObject(payload, "a proposal");
1420
+ const stages = [];
1421
+ for (const one of asList(body.stages)) {
1422
+ const stage = asObject(one, "a proposed stage");
1423
+ if (typeof stage.id !== "string") throw new BadRequest("a proposed stage names one");
1424
+ stages.push({
1425
+ id: stage.id,
1426
+ recommended: stage.recommended === true,
1427
+ why: asText(stage.why)
1428
+ });
1429
+ }
1430
+ return {
1431
+ stages,
1432
+ note: asText(body.note)
1433
+ };
1434
+ }
1435
+ function asConfirmation(payload) {
1436
+ const body = asObject(payload, "a confirmation");
1437
+ return {
1438
+ stages: asList(body.stages).filter((id) => typeof id === "string"),
1439
+ imagination: body.imagination === true,
1440
+ note: asText(body.note)
1441
+ };
1442
+ }
1443
+ function asPost(payload) {
1444
+ const body = asObject(payload, "a post");
1445
+ const kind = asText(body.kind);
1446
+ if (kind === "activity" || kind === "note") return {
1447
+ kind,
1448
+ said: asText(body.said)
1449
+ };
1450
+ if (kind === "status") {
1451
+ const status = asText(body.status);
1452
+ if (!SPEC_STATUSES.includes(status)) throw new BadRequest(`a status is one of ${SPEC_STATUSES.join(", ")}`);
1453
+ return {
1454
+ kind,
1455
+ status
1456
+ };
1457
+ }
1458
+ if (kind === "phase") {
1459
+ const status = asText(body.status);
1460
+ if (!STAGE_STATUSES.includes(status)) throw new BadRequest(`a phase status is one of ${STAGE_STATUSES.join(", ")}`);
1461
+ const named = asText(body.title);
1462
+ return {
1463
+ kind,
1464
+ phase: asText(body.phase),
1465
+ status,
1466
+ ...named ? { title: named } : {}
1467
+ };
1468
+ }
1469
+ if (kind === "stage") {
1470
+ const status = asText(body.status);
1471
+ if (!STAGE_STATUSES.includes(status)) throw new BadRequest(`a stage status is one of ${STAGE_STATUSES.join(", ")}`);
1472
+ const artifacts = asList(body.artifacts).filter((path) => typeof path === "string");
1473
+ return {
1474
+ kind,
1475
+ stage: asText(body.stage),
1476
+ status,
1477
+ artifacts
1478
+ };
1479
+ }
1480
+ throw new BadRequest("a post is an activity, a stage, a phase, a status or a note");
1481
+ }
1482
+ function asAnswer(payload) {
1483
+ const body = asObject(payload, "an answer");
1484
+ const outcome = asText(body.outcome);
1485
+ if (!ANSWER_OUTCOMES.includes(outcome)) throw new BadRequest(`an outcome is one of ${ANSWER_OUTCOMES.join(", ")}`);
1486
+ const questions = [];
1487
+ for (const one of asList(body.questions)) {
1488
+ const given = asObject(one, "an answered question");
1489
+ if (typeof given.question !== "string") throw new BadRequest("an answer names its question");
1490
+ const chosen = asList(given.chosen).filter((id) => typeof id === "string");
1491
+ const said = asText(given.said);
1492
+ questions.push({
1493
+ question: given.question,
1494
+ chosen,
1495
+ said,
1496
+ answered: given.answered === true
1497
+ });
1498
+ }
1499
+ return {
1500
+ outcome,
1501
+ said: asText(body.said),
1502
+ questions,
1503
+ answeredAt: ""
1504
+ };
1505
+ }
1506
+ function asReview(payload) {
1507
+ const body = asObject(payload, "a review round");
1508
+ if (typeof body.round !== "number") throw new BadRequest("a review round is numbered");
1509
+ return {
1510
+ round: body.round,
1511
+ verdict: asText(body.verdict),
1512
+ findings: typeof body.findings === "number" ? body.findings : 0,
1513
+ judgments: typeof body.judgments === "number" ? body.judgments : 0,
1514
+ at: ""
1515
+ };
1516
+ }
1517
+ var ANSWER_OUTCOMES = [
1518
+ "approved",
1519
+ "changes",
1520
+ "apply",
1521
+ "dismiss",
1522
+ "answered"
1523
+ ];
1524
+ function asDeclaration(payload) {
1525
+ const body = asObject(payload, "a declaration");
1526
+ const kind = asText(body.kind);
1527
+ if (kind !== "gate" && kind !== "round") throw new BadRequest("a declaration is a gate or a round");
1528
+ return {
1529
+ id: asText(body.id),
1530
+ kind,
1531
+ title: asText(body.title),
1532
+ file: asText(body.file),
1533
+ payload: body.payload ?? null
1534
+ };
1535
+ }
1536
+ var SPEC_STATUSES = [
1537
+ "specifying",
1538
+ "ready",
1539
+ "executing",
1540
+ "complete"
1541
+ ];
1542
+ var STAGE_STATUSES = [
1543
+ "waiting",
1544
+ "running",
1545
+ "done"
1546
+ ];
1547
+ /** Mount the Specs tool over one root. */
1548
+ function createSpecsModule(options) {
1549
+ const root = resolveRoot(options.root);
1550
+ const mode = options.entries ?? "children";
1551
+ const onChange = options.onChange;
1552
+ const watcher = onChange ? watchRoot(root, onChange, void 0, mode) : null;
1553
+ return {
1554
+ root,
1555
+ dashboard: () => readDashboard(root, mode),
1556
+ entry: (path) => readEntry(root, path, mode),
1557
+ state: (entry) => readEntryState(root, entry),
1558
+ writeState(entry, value) {
1559
+ writeEntryState(root, entry, readState$1(typeof value === "string" ? value : JSON.stringify(value)));
1560
+ },
1561
+ feedback(entry, submission) {
1562
+ const comments = submission?.comments;
1563
+ if (!Array.isArray(comments)) throw new BadRequest("a submission is a list of comments");
1564
+ return submitFeedback(root, entry, { comments });
1565
+ },
1566
+ filed: (entry) => filedRounds(root, entry),
1567
+ toolPath(path) {
1568
+ const absolute = toolWritten(root, path);
1569
+ if (!existsSync(absolute)) throw new Error(`specs: not a file at ${path}`);
1570
+ return absolute;
1571
+ },
1572
+ stages: () => STAGE_CATALOG,
1573
+ specState: (entry) => specState(root, entry).state,
1574
+ intake: (payload) => fileIntake(root, asIntake(payload)),
1575
+ propose: (entry, payload) => proposeProtocol(root, entry, asProposal(payload)),
1576
+ confirm: (entry, payload) => confirmProtocol(root, entry, asConfirmation(payload)),
1577
+ post: (entry, payload) => postProgress(root, entry, asPost(payload)),
1578
+ declare: (entry, payload) => declareAwaiting(root, entry, asDeclaration(payload)),
1579
+ answers: (entry, id) => readAnswers(root, entry, id),
1580
+ answer: (entry, id, payload) => answerRound(root, entry, id, asAnswer(payload)),
1581
+ review: (entry, payload) => postReview(root, entry, asReview(payload)),
1582
+ round: (entry, id) => readRound(root, entry, id),
1583
+ file: (path) => readWithin(root, path),
1584
+ close: () => watcher?.close()
1585
+ };
1586
+ }
1587
+ //#endregion
1588
+ export { BadRequest, createSpecsModule, statusFor };