@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.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 (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +50 -5
  3. package/README.tr.md +64 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +9 -9
  7. package/install/_unattended-profile.mjs +61 -0
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +19 -3
  10. package/install/codex.mjs +2 -0
  11. package/install/copilot.mjs +13 -0
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +12 -1
  14. package/manifest.json +230 -88
  15. package/package.json +3 -1
  16. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  17. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  18. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  19. package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
  20. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  21. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  22. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  23. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  24. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  25. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  26. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  27. package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
  28. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  29. package/pipeline/contract/CHANGELOG.md +108 -0
  30. package/pipeline/contract/README.md +72 -12
  31. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  32. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  33. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  34. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  35. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  36. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  37. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  38. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  39. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  40. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  41. package/pipeline/contract/fixtures/runs-running.json +6 -1
  42. package/pipeline/contract/manifest.json +506 -27
  43. package/pipeline/contract/types/index.d.ts +829 -4
  44. package/pipeline/lib/bg-launch.mjs +106 -0
  45. package/pipeline/lib/code-digest.mjs +49 -0
  46. package/pipeline/lib/credential-resolve.mjs +42 -2
  47. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  48. package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
  49. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  50. package/pipeline/multi-agent-refs/help/en.md +3 -0
  51. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  53. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  54. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  55. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  56. package/pipeline/office/NOTICE.md +45 -0
  57. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  58. package/pipeline/office/assets/BIN.png +0 -0
  59. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  60. package/pipeline/office/assets/CACTUS.png +0 -0
  61. package/pipeline/office/assets/CLOCK.png +0 -0
  62. package/pipeline/office/assets/COFFEE.png +0 -0
  63. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  64. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  65. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  66. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  67. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  68. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  69. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  70. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  71. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  72. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  73. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  74. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  75. package/pipeline/office/assets/FRIDGE.png +0 -0
  76. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  77. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  78. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  79. package/pipeline/office/assets/MAILBOX.png +0 -0
  80. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  81. package/pipeline/office/assets/PC_BACK.png +0 -0
  82. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  83. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  84. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  85. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  86. package/pipeline/office/assets/PC_SIDE.png +0 -0
  87. package/pipeline/office/assets/PLANT.png +0 -0
  88. package/pipeline/office/assets/PLANT_2.png +0 -0
  89. package/pipeline/office/assets/POT.png +0 -0
  90. package/pipeline/office/assets/PRINTER.png +0 -0
  91. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  92. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  93. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  94. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  95. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  96. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  97. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  98. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  99. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  100. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  101. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  107. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  108. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  109. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  110. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  111. package/pipeline/office/assets/char_0.png +0 -0
  112. package/pipeline/office/assets/char_1.png +0 -0
  113. package/pipeline/office/assets/char_2.png +0 -0
  114. package/pipeline/office/assets/char_3.png +0 -0
  115. package/pipeline/office/assets/char_4.png +0 -0
  116. package/pipeline/office/assets/char_5.png +0 -0
  117. package/pipeline/office/assets/floor_0.png +0 -0
  118. package/pipeline/office/assets/floor_1.png +0 -0
  119. package/pipeline/office/assets/floor_2.png +0 -0
  120. package/pipeline/office/assets/floor_3.png +0 -0
  121. package/pipeline/office/assets/floor_4.png +0 -0
  122. package/pipeline/office/assets/floor_5.png +0 -0
  123. package/pipeline/office/assets/floor_6.png +0 -0
  124. package/pipeline/office/assets/floor_7.png +0 -0
  125. package/pipeline/office/assets/floor_8.png +0 -0
  126. package/pipeline/office/assets/wall_0.png +0 -0
  127. package/pipeline/office/index.html +8431 -0
  128. package/pipeline/office/toolkit.json +3 -0
  129. package/pipeline/schemas/activity-chat.schema.json +78 -0
  130. package/pipeline/schemas/activity-roles.json +48 -0
  131. package/pipeline/schemas/activity-roles.schema.json +49 -0
  132. package/pipeline/schemas/activity.schema.json +613 -0
  133. package/pipeline/schemas/agent-state.schema.json +493 -115
  134. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  135. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  136. package/pipeline/schemas/contract-error.schema.json +45 -4
  137. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  138. package/pipeline/schemas/job-change.schema.json +72 -0
  139. package/pipeline/schemas/job-request.schema.json +38 -0
  140. package/pipeline/schemas/launch.json +4 -4
  141. package/pipeline/schemas/ledger.schema.json +202 -0
  142. package/pipeline/schemas/office-credentials.schema.json +71 -0
  143. package/pipeline/schemas/office-jobs.json +253 -0
  144. package/pipeline/schemas/office-jobs.schema.json +62 -0
  145. package/pipeline/schemas/office-memory.schema.json +105 -0
  146. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  147. package/pipeline/schemas/office-settings.schema.json +67 -0
  148. package/pipeline/schemas/permissions.schema.json +47 -0
  149. package/pipeline/schemas/phases.json +10 -2
  150. package/pipeline/schemas/phases.schema.json +86 -19
  151. package/pipeline/schemas/prefs.schema.json +19 -0
  152. package/pipeline/schemas/prs.schema.json +78 -0
  153. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  154. package/pipeline/schemas/prune-logs.schema.json +52 -0
  155. package/pipeline/schemas/repo-change.schema.json +54 -0
  156. package/pipeline/schemas/repo-request.schema.json +29 -0
  157. package/pipeline/schemas/repos.schema.json +49 -1
  158. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  159. package/pipeline/schemas/review-verdict.schema.json +62 -0
  160. package/pipeline/schemas/routine-delete.schema.json +59 -0
  161. package/pipeline/schemas/routines.schema.json +38 -0
  162. package/pipeline/schemas/run-evidence.schema.json +29 -0
  163. package/pipeline/schemas/run-results.schema.json +218 -0
  164. package/pipeline/schemas/runs-index.schema.json +257 -46
  165. package/pipeline/schemas/service-read.schema.json +58 -0
  166. package/pipeline/schemas/stack-change.schema.json +82 -0
  167. package/pipeline/schemas/stack-request.schema.json +33 -0
  168. package/pipeline/schemas/stack.schema.json +59 -0
  169. package/pipeline/schemas/steer-request.schema.json +25 -0
  170. package/pipeline/schemas/steer.schema.json +59 -0
  171. package/pipeline/schemas/system-status.schema.json +111 -0
  172. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  173. package/pipeline/schemas/tool-call.schema.json +47 -0
  174. package/pipeline/schemas/tools.schema.json +65 -0
  175. package/pipeline/schemas/unattended-profile.json +7 -2
  176. package/pipeline/scripts/_active-run.mjs +2 -1
  177. package/pipeline/scripts/_cost.mjs +66 -1
  178. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  179. package/pipeline/scripts/_spawn-async.mjs +77 -0
  180. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  181. package/pipeline/scripts/activity-snapshot.mjs +1223 -0
  182. package/pipeline/scripts/autopilot-control.mjs +214 -9
  183. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  184. package/pipeline/scripts/contract-server.mjs +1853 -197
  185. package/pipeline/scripts/cost-analyze.mjs +2 -53
  186. package/pipeline/scripts/cost-table.json +1 -0
  187. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  188. package/pipeline/scripts/job-launch.mjs +477 -0
  189. package/pipeline/scripts/launch-request.mjs +94 -9
  190. package/pipeline/scripts/office-build.mjs +226 -0
  191. package/pipeline/scripts/office-control.mjs +410 -0
  192. package/pipeline/scripts/office-credentials.mjs +106 -0
  193. package/pipeline/scripts/office-ledger.mjs +409 -0
  194. package/pipeline/scripts/office-memory.mjs +200 -0
  195. package/pipeline/scripts/office-prune.mjs +125 -0
  196. package/pipeline/scripts/office-settings.mjs +145 -0
  197. package/pipeline/scripts/phase-tracker.sh +12 -5
  198. package/pipeline/scripts/prune-logs.sh +53 -2
  199. package/pipeline/scripts/routine-registry.mjs +42 -6
  200. package/pipeline/scripts/run-results.mjs +373 -0
  201. package/pipeline/scripts/runs-index.mjs +62 -2
  202. package/pipeline/scripts/service-read.mjs +244 -0
  203. package/pipeline/scripts/stack-toggle.mjs +285 -0
  204. package/pipeline/scripts/steer-run.mjs +97 -0
  205. package/pipeline/scripts/system-status.mjs +196 -0
  206. package/pipeline/scripts/task-mirror.mjs +150 -0
  207. package/pipeline/scripts/task-mirror.sh +20 -0
  208. package/pipeline/scripts/toolkit-tools.mjs +625 -0
  209. package/pipeline/scripts/transcript-index.mjs +4 -1
  210. package/pipeline/scripts/uninstall.mjs +3 -0
  211. package/pipeline/scripts/work-items.mjs +473 -0
  212. package/pipeline/skills/.skill-manifest.json +23 -11
  213. package/pipeline/skills/.skills-index.json +34 -1
  214. package/pipeline/skills/shared/README.md +6 -3
  215. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  216. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  217. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
  218. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  219. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  220. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  221. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  222. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  223. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  224. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  225. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  226. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  227. package/pipeline/skills/skills-index.md +4 -1
@@ -99,6 +99,7 @@ import {
99
99
  writeFileSync,
100
100
  } from "node:fs";
101
101
  import { spawnSync } from "node:child_process";
102
+ import { createHash } from "node:crypto";
102
103
  import { homedir } from "node:os";
103
104
  import { basename, isAbsolute, join } from "node:path";
104
105
  import { catalog, commandsDir } from "./commands.mjs";
@@ -108,6 +109,7 @@ import { autopilotRoot, unattendedLogsRoot } from "../lib/pr-request-location.mj
108
109
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
109
110
  import { unattendedSettingsPath } from "../lib/unattended-settings-location.mjs";
110
111
  import { isUnattended } from "../lib/unattended.mjs";
112
+ import { listRuns, logsRoot } from "./_run-paths.mjs";
111
113
 
112
114
  export const CONTRACT_VERSION = "1.1.0";
113
115
  export const MODES = ["interactive", "autopilot", "background"];
@@ -548,6 +550,84 @@ export function launchRepos(env = process.env) {
548
550
  return out;
549
551
  }
550
552
 
553
+ /** A stable, path-free handle for a repository path, as a client names one. */
554
+ export const repoId = (p) => createHash("sha256").update(String(p)).digest("hex").slice(0, 16);
555
+
556
+ const isCheckout = (real) => existsSync(join(real, ".git"));
557
+
558
+ /**
559
+ * Checkouts earlier runs worked in that a launch does not accept yet: each
560
+ * run's projectRoot (and every projects[].projectRoot) under both log roots,
561
+ * by real path, kept when it is a git checkout and neither the home directory
562
+ * nor the root. A client offers them for registration; nothing is accepted
563
+ * until registerRepo stores one.
564
+ *
565
+ * @returns {{id: string, path: string, name: string}[]}
566
+ */
567
+ export function suggestedRepos(env = process.env) {
568
+ const allowed = allowedLaunchRepos(env);
569
+ const home = realDir(homedir());
570
+ const out = new Map();
571
+ for (const root of new Set([logsRoot(), unattendedLogsRoot()])) {
572
+ for (const run of listRuns(root)) {
573
+ const state = readJsonOr(join(run.dir, "agent-state.json"), null);
574
+ if (!state || typeof state !== "object") continue;
575
+ const roots = [
576
+ state.projectRoot,
577
+ ...(Array.isArray(state.projects) ? state.projects.map((x) => x?.projectRoot) : []),
578
+ ];
579
+ for (const raw of roots) {
580
+ if (typeof raw !== "string" || !isAbsolute(raw)) continue;
581
+ const real = realDir(raw);
582
+ if (!real || real === "/" || real === home || allowed.has(real) || out.has(real)) continue;
583
+ if (!isCheckout(real)) continue;
584
+ out.set(real, { id: repoId(real), path: real, name: basename(real) });
585
+ }
586
+ }
587
+ }
588
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
589
+ }
590
+
591
+ /**
592
+ * Registered entries that are no longer a directory, as a client lists them
593
+ * for removal: launchRepos leaves them out, so nothing else would show them.
594
+ *
595
+ * @returns {{id: string, path: string, name: string}[]}
596
+ */
597
+ export function missingRepos(env = process.env) {
598
+ const registered = readJsonOr(launchReposPath(env), {});
599
+ return (Array.isArray(registered?.repos) ? registered.repos : [])
600
+ .filter((p) => typeof p === "string" && p && !realDir(expandHome(p)))
601
+ .map((p) => ({ id: repoId(p), path: p, name: basename(p) }));
602
+ }
603
+
604
+ /**
605
+ * Remove one registered entry, named by repoId of its stored path. Configured
606
+ * autopilot repos are not touched: they belong to the autopilot config.
607
+ * Returns the removed path, or null when no entry has that id.
608
+ */
609
+ export function unregisterRepo(id, env = process.env) {
610
+ let removed = null;
611
+ updateJsonFileSync(
612
+ launchReposPath(env),
613
+ (current) => {
614
+ const repos = Array.isArray(current?.repos) ? current.repos : [];
615
+ const keep = repos.filter((p) => {
616
+ // The list shows entries by their real path, so match that form too.
617
+ const real = typeof p === "string" ? realDir(expandHome(p)) : null;
618
+ if (removed === null && (repoId(p) === id || (real && repoId(real) === id))) {
619
+ removed = p;
620
+ return false;
621
+ }
622
+ return true;
623
+ });
624
+ return { ...current, repos: keep };
625
+ },
626
+ { fallback: {}, mode: 0o600, timeoutMs: lockTimeoutFrom(env) },
627
+ );
628
+ return removed;
629
+ }
630
+
551
631
  /** Is `repoPath` a directory a launch may start in? Compared by real path. */
552
632
  export function launchRepoAllowed(repoPath, env = process.env) {
553
633
  const real = typeof repoPath === "string" && isAbsolute(repoPath) ? realDir(repoPath) : null;
@@ -560,7 +640,7 @@ export function launchRepoAllowed(repoPath, env = process.env) {
560
640
  * The read-modify-write holds the file's lock and replaces it atomically, so
561
641
  * two registrations at once both land. Returns the stored path.
562
642
  */
563
- export function registerRepo(repoPath, env = process.env) {
643
+ export function registerRepo(repoPath, env = process.env, { requireCheckout = false } = {}) {
564
644
  if (typeof repoPath !== "string" || !isAbsolute(repoPath))
565
645
  throw new Error("the path must be absolute");
566
646
  const real = realDir(repoPath);
@@ -568,6 +648,7 @@ export function registerRepo(repoPath, env = process.env) {
568
648
  if (real === "/" || real === realDir(homedir())) {
569
649
  throw new Error("the home directory or the root is not a repository");
570
650
  }
651
+ if (requireCheckout && !isCheckout(real)) throw new Error("the path is not a git checkout");
571
652
  const file = launchReposPath(env);
572
653
  mkdirSync(autopilotRoot(env), { recursive: true, mode: 0o700 });
573
654
  updateJsonFileSync(
@@ -798,12 +879,15 @@ function planMain(rest) {
798
879
  }
799
880
 
800
881
  /** The `repos` document: launchRepos() with contractVersion; paths dropped under redact. */
801
- export function reposDocument({ redact = false, env = process.env } = {}) {
882
+ export function reposDocument({ redact = false, env = process.env, extras = false } = {}) {
802
883
  const repos = launchRepos(env);
803
- return {
804
- contractVersion: CONTRACT_VERSION,
805
- repos: redact ? repos.map(({ path: _p, ...r }) => r) : repos,
806
- };
884
+ const strip = (list) => (redact ? list.map(({ path: _p, ...r }) => r) : list);
885
+ const doc = { contractVersion: CONTRACT_VERSION, repos: strip(repos) };
886
+ if (extras) {
887
+ doc.suggested = strip(suggestedRepos(env));
888
+ doc.missing = strip(missingRepos(env));
889
+ }
890
+ return doc;
807
891
  }
808
892
 
809
893
  function resumeMain(rest) {
@@ -857,11 +941,12 @@ function main(argv) {
857
941
  const [cmd, ...rest] = argv;
858
942
  if (cmd === "repos") {
859
943
  const redact = rest.includes("--redact");
860
- if (rest.some((a) => a !== "--redact")) {
861
- process.stderr.write("launch-request: repos takes only --redact\n");
944
+ const extras = rest.includes("--all");
945
+ if (rest.some((a) => a !== "--redact" && a !== "--all")) {
946
+ process.stderr.write("launch-request: repos takes only --redact and --all\n");
862
947
  return 2;
863
948
  }
864
- process.stdout.write(JSON.stringify(reposDocument({ redact }), null, 2) + "\n");
949
+ process.stdout.write(JSON.stringify(reposDocument({ redact, extras }), null, 2) + "\n");
865
950
  return 0;
866
951
  }
867
952
  if (cmd === "questions" || cmd === "spec") {
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * office-build.mjs - assemble the office panel page from pipeline/office-src.
4
+ *
5
+ * The server's CSP allows one nonce'd inline script, one nonce'd inline style
6
+ * and data: images, so the page has to be a single file. The sources stay
7
+ * readable as separate files (shell.html, style.css, world.js, app.js,
8
+ * strings.json) and this script inlines them into
9
+ * pipeline/office/index.html: the list of sprite names in pipeline/office/assets
10
+ * (served by contract-server.mjs at /office/assets/), the strings as a JSON
11
+ * literal, the two code files in order inside one IIFE. It also renders the
12
+ * pixel grids in pipeline/office-art into PNG sprites in pipeline/office/assets
13
+ * and writes pipeline/office/toolkit.json, the toolkit version shipped with
14
+ * the page; --check holds all three to their sources.
15
+ *
16
+ * node pipeline/scripts/office-build.mjs write the page
17
+ * node pipeline/scripts/office-build.mjs --check exit 1 when the page on
18
+ * disk is not what the
19
+ * sources build to
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { readFileSync, readdirSync, writeFileSync } from "node:fs";
24
+ import { deflateSync } from "node:zlib";
25
+ import { basename, join } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+
28
+ const ROOT = join(import.meta.dirname, "..");
29
+ const SRC = join(ROOT, "office-src");
30
+ const OUT = join(ROOT, "office", "index.html");
31
+ const ASSETS = join(ROOT, "office", "assets");
32
+ const TOOLKIT = join(ROOT, "office", "toolkit.json");
33
+ const ART = join(ROOT, "office-art");
34
+ const FACTS = join(ROOT, "..", "docs", "facts.json");
35
+ const PNG_SIGNATURE = "89504e470d0a1a0a";
36
+
37
+ // --- the project's own sprites -----------------------------------------------
38
+ // Each office-art/<NAME>.txt is a "size WxH" line and H rows of W letters, one
39
+ // letter per pixel from office-art/palette.json, "." transparent. They are
40
+ // encoded here into office/assets/<NAME>.png byte for byte the same each time
41
+ // (no timestamp, fixed compression), so --check can hold the PNGs to their
42
+ // text and a review reads the drawing as a diff.
43
+
44
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
45
+ let c = n;
46
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
47
+ return c >>> 0;
48
+ });
49
+ function crc32(buf) {
50
+ let c = 0xffffffff;
51
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
52
+ return (c ^ 0xffffffff) >>> 0;
53
+ }
54
+ function chunk(type, data) {
55
+ const len = Buffer.alloc(4);
56
+ len.writeUInt32BE(data.length);
57
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
58
+ const crc = Buffer.alloc(4);
59
+ crc.writeUInt32BE(crc32(body));
60
+ return Buffer.concat([len, body, crc]);
61
+ }
62
+ /** An RGBA PNG, unfiltered rows, zlib level 9. */
63
+ export function encodePng(width, height, rgba) {
64
+ const header = Buffer.alloc(13);
65
+ header.writeUInt32BE(width, 0);
66
+ header.writeUInt32BE(height, 4);
67
+ header[8] = 8;
68
+ header[9] = 6;
69
+ const raw = Buffer.alloc((width * 4 + 1) * height);
70
+ for (let y = 0; y < height; y++) {
71
+ rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
72
+ }
73
+ return Buffer.concat([
74
+ Buffer.from(PNG_SIGNATURE, "hex"),
75
+ chunk("IHDR", header),
76
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
77
+ chunk("IEND", Buffer.alloc(0)),
78
+ ]);
79
+ }
80
+
81
+ /** One art file as a PNG; throws on a size line, row length or letter that does not fit. */
82
+ export function renderArt(text, colors, name = "sprite") {
83
+ const lines = text.replace(/\r/g, "").trimEnd().split("\n");
84
+ const m = /^size (\d{1,3})x(\d{1,3})$/.exec(lines[0] ?? "");
85
+ if (!m) throw new Error(`${name}: the first line is "size WxH"`);
86
+ const [w, h] = [Number(m[1]), Number(m[2])];
87
+ const rows = lines.slice(1);
88
+ if (rows.length !== h) throw new Error(`${name}: ${rows.length} rows, expected ${h}`);
89
+ const rgba = Buffer.alloc(w * h * 4);
90
+ rows.forEach((row, y) => {
91
+ if (row.length !== w)
92
+ throw new Error(`${name}: row ${y + 1} is ${row.length} wide, expected ${w}`);
93
+ [...row].forEach((ch, x) => {
94
+ if (ch === ".") return;
95
+ const hex = colors[ch];
96
+ if (!hex) throw new Error(`${name}: "${ch}" is not in the palette`);
97
+ const o = (y * w + x) * 4;
98
+ rgba[o] = parseInt(hex.slice(0, 2), 16);
99
+ rgba[o + 1] = parseInt(hex.slice(2, 4), 16);
100
+ rgba[o + 2] = parseInt(hex.slice(4, 6), 16);
101
+ rgba[o + 3] = 255;
102
+ });
103
+ });
104
+ return encodePng(w, h, rgba);
105
+ }
106
+
107
+ /** Every art sprite, name -> PNG bytes. */
108
+ export function artSprites() {
109
+ const { colors } = JSON.parse(readFileSync(join(ART, "palette.json"), "utf8"));
110
+ const out = new Map();
111
+ for (const f of readdirSync(ART).sort()) {
112
+ if (!f.endsWith(".txt")) continue;
113
+ const name = basename(f, ".txt");
114
+ if (!/^[A-Z0-9_]{1,48}$/.test(name)) throw new Error(`${f}: sprite names are A-Z, 0-9 and _`);
115
+ out.set(name, renderArt(readFileSync(join(ART, f), "utf8"), colors, name));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // The sprite names the page may load from /office/assets/: every PNG beside
121
+ // the page, checked to be one.
122
+ function atlas() {
123
+ const out = [];
124
+ for (const name of readdirSync(ASSETS).sort()) {
125
+ if (!name.endsWith(".png")) continue;
126
+ const buf = readFileSync(join(ASSETS, name));
127
+ if (buf.subarray(0, 8).toString("hex") !== PNG_SIGNATURE) {
128
+ throw new Error(`${name} is not a PNG file`);
129
+ }
130
+ out.push(basename(name, ".png"));
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * The toolkit version the office's Tools window starts, shipped beside the
137
+ * page: the version this release was checked against, from docs/facts.json.
138
+ * The host runs the toolkit as @latest; the panel only runs an exact version,
139
+ * and every pipeline update brings the newer one with it.
140
+ */
141
+ export function toolkitDoc() {
142
+ const v = JSON.parse(readFileSync(FACTS, "utf8")).toolkitVersion;
143
+ if (typeof v !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v)) {
144
+ throw new Error("docs/facts.json carries no exact toolkitVersion");
145
+ }
146
+ return JSON.stringify({ toolkitVersion: v }, null, 2) + "\n";
147
+ }
148
+
149
+ export function build() {
150
+ const read = (f) => readFileSync(join(SRC, f), "utf8");
151
+ const strings = JSON.parse(read("strings.json"));
152
+ const sprites = atlas();
153
+ const script = [
154
+ '"use strict";',
155
+ "(() => {",
156
+ `const ATLAS = ${JSON.stringify(sprites)};`,
157
+ `const STRINGS = ${JSON.stringify(strings)};`,
158
+ 'const spriteUrl = (name) => "/office/assets/" + name + ".png";',
159
+ read("world.js").trim(),
160
+ read("app.js").trim(),
161
+ "})();",
162
+ ].join("\n");
163
+ const style = read("style.css").trim();
164
+ // The build id the server reads back from the installed page: the page
165
+ // compares it with its own to tell that a newer one has been installed.
166
+ const hash = createHash("sha256").update(script).update(style).update(read("shell.html"));
167
+ // Redrawn sprites change the page too, though no source text did.
168
+ for (const [name, png] of artSprites()) hash.update(name).update(png);
169
+ const build = hash.digest("hex").slice(0, 12);
170
+ // Text that would close the inline element early, or open an HTML comment
171
+ // inside it, turns source into markup.
172
+ if (/<\/script|<!--/i.test(script)) throw new Error("the script contains </script or <!--");
173
+ if (/<\/style/i.test(style)) throw new Error("style.css contains </style");
174
+ const shell = read("shell.html");
175
+ for (const marker of ["/*STYLE*/", "/*SCRIPT*/"]) {
176
+ if (shell.split(marker).length !== 2) throw new Error(`shell.html needs exactly one ${marker}`);
177
+ }
178
+ const stamped = script.replace('"use strict";', () => `"use strict";\nconst BUILD = "${build}";`);
179
+ return shell.replace("/*STYLE*/", () => style).replace("/*SCRIPT*/", () => stamped);
180
+ }
181
+
182
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
183
+ if (isMain) {
184
+ const art = artSprites();
185
+ const check = process.argv.includes("--check");
186
+ if (!check) for (const [name, png] of art) writeFileSync(join(ASSETS, `${name}.png`), png);
187
+ const html = build();
188
+ const toolkit = toolkitDoc();
189
+ if (check) {
190
+ for (const [name, png] of art) {
191
+ let on = null;
192
+ try {
193
+ on = readFileSync(join(ASSETS, `${name}.png`));
194
+ } catch {
195
+ // Missing is stale.
196
+ }
197
+ if (!on || !on.equals(png)) {
198
+ console.error(
199
+ `office/assets/${name}.png is stale: run node pipeline/scripts/office-build.mjs`,
200
+ );
201
+ process.exit(1);
202
+ }
203
+ }
204
+ const read = (f) => {
205
+ try {
206
+ return readFileSync(f, "utf8");
207
+ } catch {
208
+ // A missing file is stale by definition.
209
+ return "";
210
+ }
211
+ };
212
+ if (read(OUT) !== html) {
213
+ console.error("office/index.html is stale: run node pipeline/scripts/office-build.mjs");
214
+ process.exit(1);
215
+ }
216
+ if (read(TOOLKIT) !== toolkit) {
217
+ console.error("office/toolkit.json is stale: run node pipeline/scripts/office-build.mjs");
218
+ process.exit(1);
219
+ }
220
+ console.log("office/index.html and office/toolkit.json match their sources");
221
+ } else {
222
+ writeFileSync(OUT, html);
223
+ writeFileSync(TOOLKIT, toolkit);
224
+ console.log(`wrote ${OUT} (${Buffer.byteLength(html)} bytes) and ${TOOLKIT}`);
225
+ }
226
+ }