@kylecheng3146/agent-ops 0.7.2 → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { readWorktreeRecord, parseWorktreeListPorcelain } from "../parallel/service.js";
@@ -10,9 +10,11 @@ import { parseTaskStateSource } from "../task/store.js";
10
10
  import { findReviewAttestation } from "../review/attestation.js";
11
11
  import { FileEvidenceStore } from "../verify/evidence.js";
12
12
  import { readPrivateFile } from "../security/permissions.js";
13
+ import { redactSecrets } from "../security/redact.js";
13
14
  import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
14
15
  import { buildOfficeSnapshot, mergeOfficeSnapshots } from "./snapshot.js";
15
16
  import { forgetOfficeRepos, readOfficeRepos } from "./repos.js";
17
+ import { readActivities } from "./activity.js";
16
18
  const run = promisify(execFile);
17
19
  export const officeGit = async (cwd, args) => {
18
20
  try {
@@ -23,6 +25,119 @@ export const officeGit = async (cwd, args) => {
23
25
  }
24
26
  };
25
27
  const MAX_PATHS = 200;
28
+ // The office reads two kinds of proof files, never anything a task record merely points at:
29
+ // acceptance execution artifacts (by an exact path shape) and review report artifacts (by an exact name).
30
+ const ACCEPTANCE_ARTIFACT = /^\.agent-ops\/tasks\/acceptance\/[a-f0-9]{64}\.json$/u;
31
+ const TASK_ID = /^[A-Za-z0-9._-]{1,128}$/u;
32
+ const REVIEW_FILE_BYTES = 512 * 1024, ACCEPTANCE_FILE_BYTES = 4 * 1024 * 1024, REVIEW_FILES = 20, FINDINGS = 10;
33
+ const OUTPUT_BYTES = 2048, SUMMARY_BYTES = 1024, TITLE_BYTES = 300, DETAILS_BYTES = 1024, ADVICE_BYTES = 512;
34
+ /** At most `limit` bytes of UTF-8 text from the start, cut on a character boundary; the ellipsis counts. */
35
+ function headBytes(value, limit) {
36
+ if (Buffer.byteLength(value, "utf8") <= limit)
37
+ return value;
38
+ let out = "", used = 0;
39
+ for (const character of value) {
40
+ const size = Buffer.byteLength(character, "utf8");
41
+ if (used + size > limit - 3)
42
+ break;
43
+ out += character;
44
+ used += size;
45
+ }
46
+ return out + "…";
47
+ }
48
+ /** At most `limit` bytes of UTF-8 text from the end, cut on a character boundary; the ellipsis counts. */
49
+ function tailBytes(value, limit) {
50
+ if (Buffer.byteLength(value, "utf8") <= limit)
51
+ return value;
52
+ const characters = [...value];
53
+ let used = 0, start = characters.length;
54
+ while (start > 0) {
55
+ const size = Buffer.byteLength(characters[start - 1], "utf8");
56
+ if (used + size > limit - 3)
57
+ break;
58
+ used += size;
59
+ start--;
60
+ }
61
+ return "…" + characters.slice(start).join("");
62
+ }
63
+ /** Redact first, then cut, so a cut can never expose part of a secret the full text would hide. */
64
+ const clean = (value, limit) => typeof value === "string" ? headBytes(redactSecrets(value), limit) : "";
65
+ /** A private proof file read without following links, or null when absent, oversized or unreadable. */
66
+ async function proofFile(root, relative, limit) {
67
+ const source = await readPrivateFile(join(root, ...relative.split("/")), root).catch(() => null);
68
+ if (source === null || Buffer.byteLength(source, "utf8") > limit)
69
+ return null;
70
+ try {
71
+ return JSON.parse(source);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ async function failureOutput(root, evidence) {
78
+ const artifact = evidence?.acceptance?.executionArtifact;
79
+ if (artifact === undefined || !ACCEPTANCE_ARTIFACT.test(artifact))
80
+ return null;
81
+ const source = await readPrivateFile(join(root, ...artifact.split("/")), root).catch(() => null);
82
+ // An execution artifact is named by its own content hash; a swapped or edited file no longer matches.
83
+ if (source === null || Buffer.byteLength(source, "utf8") > ACCEPTANCE_FILE_BYTES ||
84
+ artifact !== ".agent-ops/tasks/acceptance/" + createHash("sha256").update(source).digest("hex") + ".json")
85
+ return null;
86
+ let value;
87
+ try {
88
+ value = JSON.parse(source);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ const output = typeof value === "object" && value !== null ? value.output : null;
94
+ if (typeof output !== "object" || output === null)
95
+ return null;
96
+ const { stdout, stderr } = output;
97
+ const text = [stdout, stderr].filter((part) => typeof part === "string" && part.length > 0).join("\n");
98
+ return text.length === 0 ? null : tailBytes(redactSecrets(text), OUTPUT_BYTES);
99
+ }
100
+ function reviewRound(target, report) {
101
+ if (typeof report !== "object" || report === null)
102
+ return null;
103
+ const value = report;
104
+ const findings = (Array.isArray(value.findings) ? value.findings : []).slice(0, FINDINGS).map(item => {
105
+ const finding = (typeof item === "object" && item !== null ? item : {});
106
+ return { severity: clean(finding.severity, 32), blocking: finding.blocking === true, title: clean(finding.title, TITLE_BYTES),
107
+ details: clean(finding.details, DETAILS_BYTES), recommendation: clean(finding.recommendation, ADVICE_BYTES) };
108
+ });
109
+ return { target: clean(target, 32) || "unknown", summary: clean(value.summary, SUMMARY_BYTES), findings };
110
+ }
111
+ /** The newest review report artifact for a task, or null. */
112
+ async function latestReview(root, taskId) {
113
+ if (!TASK_ID.test(taskId))
114
+ return null;
115
+ const directory = join(root, ".agent-ops", "reviews"), suffix = "." + taskId + ".reports.json";
116
+ const names = (await readdir(directory).catch(() => []))
117
+ .filter(name => name.endsWith(suffix) && /^[a-f0-9]{64}$/u.test(name.slice(0, -suffix.length)));
118
+ const dated = await Promise.all(names.map(async (name) => {
119
+ const info = await lstat(join(directory, name)).catch(() => null);
120
+ return info === null || !info.isFile() ? null : { name, at: info.mtimeMs };
121
+ }));
122
+ let best = null;
123
+ for (const entry of dated.filter((item) => item !== null).sort((a, b) => b.at - a.at).slice(0, REVIEW_FILES)) {
124
+ const value = await proofFile(root, ".agent-ops/reviews/" + entry.name, REVIEW_FILE_BYTES);
125
+ if (typeof value !== "object" || value === null)
126
+ continue;
127
+ const report = value;
128
+ const status = report.status === "PASS" || report.status === "FAIL" || report.status === "NOT_RUN" ? report.status : null;
129
+ if (status === null || typeof report.createdAt !== "string" || Number.isNaN(Date.parse(report.createdAt)))
130
+ continue;
131
+ if (best !== null && Date.parse(best.createdAt) >= Date.parse(report.createdAt))
132
+ continue;
133
+ const adversarial = typeof report.adversarial === "object" && report.adversarial !== null ? report.adversarial : null;
134
+ const rounds = [reviewRound(report.harness, report.report), adversarial === null ? null : reviewRound(adversarial.target, adversarial.report)]
135
+ .filter((round) => round !== null);
136
+ best = { status, createdAt: report.createdAt, reason: typeof report.reason === "string" ? clean(report.reason, SUMMARY_BYTES) : null, rounds,
137
+ refuted: adversarial === null ? null : adversarial.refuted === true };
138
+ }
139
+ return best;
140
+ }
26
141
  const OFFICE_PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
27
142
  function officePhase(value) {
28
143
  return value === undefined ? undefined : OFFICE_PHASES.has(value) ? value : "unknown";
@@ -63,13 +178,21 @@ async function taskView(root, record) {
63
178
  }
64
179
  const evidence = [...latest.values()].map(item => item.evidence);
65
180
  let passed = 0;
181
+ const criteria = [];
66
182
  for (const criterion of record.task.criteria) {
67
183
  const rows = evidence.filter(item => item.criterionId === criterion.id);
68
- const result = criteriaProgress([criterion], {
69
- results: rows.map(item => ({ status: item.status })),
70
- acceptance: rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? [])
71
- });
184
+ const checks = rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? []);
185
+ const result = criteriaProgress([criterion], { results: rows.map(item => ({ status: item.status })), acceptance: checks });
72
186
  passed += result.passed;
187
+ // The row is PASS exactly when it counts toward `passed`, so the list and the n/m summary agree.
188
+ const seen = [...rows.map(item => item.status), ...checks.map(check => check.status)];
189
+ const status = result.passed === 1 ? "PASS" : seen.includes("FAIL") ? "FAIL" : seen.includes("UNKNOWN") ? "UNKNOWN" : null;
190
+ const last = rows.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null);
191
+ const failing = status === "FAIL" || status === "UNKNOWN" ? rows.filter(row => row.acceptance?.executionArtifact !== undefined && row.status !== "PASS")
192
+ .reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null) : null;
193
+ criteria.push({ id: criterion.id, description: criterion.description, status, finishedAt: last?.finishedAt ?? null,
194
+ failureClass: status === "PASS" || last === null ? null : last.failureClass, exitCode: status === "PASS" ? null : last?.exitCode ?? null,
195
+ output: await failureOutput(root, failing) });
73
196
  }
74
197
  const total = record.task.criteria.length;
75
198
  const verify = evidence.some(item => item.status === "FAIL" || item.status === "UNKNOWN")
@@ -90,7 +213,8 @@ async function taskView(root, record) {
90
213
  : evidence.length > 0
91
214
  ? "implementing"
92
215
  : "planning";
93
- return { taskId: record.task.id, status: record.status, phase, progress, completedAt: record.completedAt };
216
+ return { taskId: record.task.id, status: record.status, phase, progress, title: record.task.title, criteria,
217
+ review: await latestReview(root, record.task.id), completedAt: record.completedAt };
94
218
  }
95
219
  async function taskState(root) {
96
220
  const source = await readPrivateFile(join(root, ".agent-ops", "tasks", "state.json"), root).catch(() => null);
@@ -141,6 +265,21 @@ export async function worktreeDiff(git, path, base) {
141
265
  }
142
266
  return { files: paths.length, insertions, deletions, paths: paths.slice(0, MAX_PATHS), recent: recent ?? paths[0] ?? null };
143
267
  }
268
+ /**
269
+ * Work already in the target branch by some route other than `worktree finish`
270
+ * (a merged pull request): commits past its base, all reachable from the
271
+ * target, and nothing uncommitted. A fresh worktree (HEAD at base) is not merged.
272
+ * ponytail: ancestry only, so a squash-merged branch stays visible until removed.
273
+ */
274
+ export async function worktreeMerged(git, path, base, targetBranch) {
275
+ const head = (await git(path, ["rev-parse", "HEAD"])).stdout.trim();
276
+ if (!/^[0-9a-f]{40,64}$/u.test(head) || head === base || !/^[A-Za-z0-9._/-]{1,128}$/u.test(targetBranch))
277
+ return false;
278
+ if ((await git(path, ["merge-base", "--is-ancestor", head, `refs/heads/${targetBranch}`])).exitCode !== 0)
279
+ return false;
280
+ const status = await git(path, ["status", "--porcelain"]);
281
+ return status.exitCode === 0 && status.stdout.trim() === "";
282
+ }
144
283
  /** Each run is read on its own: one unreadable run must not hide the building. */
145
284
  export async function readRuns(commonDir) {
146
285
  const directory = join(commonDir, "agent-ops", "runs");
@@ -171,7 +310,7 @@ async function readSessions(mainRoot, commonDir, now, trustedRoots = [mainRoot])
171
310
  : observedPhase ?? task.phase;
172
311
  return task === null
173
312
  ? session
174
- : { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress,
313
+ : { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress, title: task.title, criteria: task.criteria, review: task.review,
175
314
  completedAt: task.completedAt, status: task.status === "complete" ? "idle" : session.status };
176
315
  }));
177
316
  return views.filter(session => session.completedAt === undefined || session.completedAt === null ||
@@ -207,19 +346,28 @@ export async function readReviewSlots(commonDir) {
207
346
  export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, now = Date.now()) {
208
347
  const worktrees = [];
209
348
  const discovered = [];
349
+ const merged = new Set();
210
350
  for (const entry of parseWorktreeListPorcelain((await git(mainRoot, ["worktree", "list", "--porcelain"])).stdout)) {
211
351
  const path = await realpath(resolve(entry.path)).catch(() => resolve(entry.path));
212
352
  const record = await readWorktreeRecord(path).catch(() => null);
213
353
  if (record === null)
214
354
  continue;
355
+ if (record.runId === undefined && await worktreeMerged(git, path, record.base, record.targetBranch)) {
356
+ // A subagent's worktree shares its coordinator's session, which stays.
357
+ if (record.agentId === undefined)
358
+ merged.add(record.sessionId);
359
+ continue;
360
+ }
215
361
  discovered.push({ path, record });
216
362
  }
217
- const sessions = await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)]);
363
+ const sessions = (await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)])).filter(session => !merged.has(session.sessionId));
218
364
  for (const { path, record } of discovered) {
219
365
  const session = sessions.find(item => item.sessionId === record.sessionId);
220
366
  const task = await attachedTaskView(path, record.sessionId, session?.taskId);
367
+ const ahead = /^[0-9a-f]{40,64}$/u.test(record.base) ? Number.parseInt((await git(path, ["rev-list", "--count", record.base + "..HEAD"])).stdout.trim(), 10) : Number.NaN;
221
368
  const base = { name: record.name, path, branch: record.branch, sessionId: record.sessionId,
222
- ...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base) };
369
+ ...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base),
370
+ ...(/^[0-9a-f]{40,64}$/u.test(record.base) ? { base: record.base.slice(0, 12) } : {}), ...(Number.isInteger(ahead) && ahead >= 0 ? { ahead } : {}) };
223
371
  const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
224
372
  const phase = task === null
225
373
  ? observedPhase
@@ -229,10 +377,11 @@ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, n
229
377
  worktrees.push(task === null
230
378
  ? session === undefined ? base : { ...base, ...(officePhase(session.phase) === undefined ? {} : { phase: officePhase(session.phase) }), status: session.status,
231
379
  ...(session.taskId === undefined ? {} : { taskId: session.taskId }), host: session.host ?? session.harness }
232
- : { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress,
380
+ : { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress, title: task.title, criteria: task.criteria, review: task.review,
233
381
  ...(task.completedAt === null ? {} : { completedAt: task.completedAt }), host: session?.host ?? session?.harness });
234
382
  }
235
- return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir), now };
383
+ return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir),
384
+ activities: await readActivities(commonDir).catch(() => []), now };
236
385
  }
237
386
  /**
238
387
  * Every registered repository that still exists and still opts in, as one
@@ -0,0 +1,200 @@
1
+ import { roomSprites } from "./art.js";
2
+ export const ROOM_ORDER = ["planning", "implementing", "verifying", "reviewing", "integrating", "lobby"];
3
+ const W = 576, H = 320;
4
+ const WALL = 40, ROW1 = { top: 40, bottom: 152 }, MIDDLE = { top: 152, bottom: 192 }, ROW2 = { top: 192, bottom: 312 };
5
+ const VERTICAL = [188, 380], THICK = 8, COLUMN = [4, 196, 388], ROOM_W = 184;
6
+ /** Bounding box of the opaque pixels in the bottom `rows` rows of a sprite. */
7
+ function baseOf(rows, depth = 10) {
8
+ let left = Infinity, right = -1, top = Infinity;
9
+ for (let y = Math.max(0, rows.length - depth); y < rows.length; y++)
10
+ for (let x = 0; x < rows[y].length; x++) {
11
+ if (rows[y][x] === ".")
12
+ continue;
13
+ left = Math.min(left, x);
14
+ right = Math.max(right, x);
15
+ top = Math.min(top, y);
16
+ }
17
+ return right < 0 ? { x: 0, y: rows.length, w: 0, h: 0 } : { x: left, y: top, w: right - left + 1, h: rows.length - top };
18
+ }
19
+ export function officeLayout() {
20
+ const sprites = roomSprites();
21
+ const room = (id, row, column, floor, slots) => {
22
+ const band = row === 1 ? ROW1 : ROW2;
23
+ return { id, x: COLUMN[column], y: band.top, w: ROOM_W, h: band.bottom - band.top, floor, slots };
24
+ };
25
+ // Feet sit on the path-finding grid so a walk ends exactly on them.
26
+ const onGrid = (p) => ({ x: Math.round(p.x / STEP) * STEP, y: Math.round(p.y / STEP) * STEP });
27
+ const stand = (x, y, pose = "down0") => ({ x, y, pose, z: y + 50, feet: onGrid({ x: x + 17, y: y + 49 }) });
28
+ const seat = (x, y, pose, z, feet) => ({ x, y, pose, z, feet: onGrid(feet) });
29
+ const rooms = [
30
+ room("planning", 1, 0, "carpet", [seat(9, 28, "down0", 69, { x: 30, y: 140 }), seat(43, 28, "down0", 69, { x: 64, y: 140 }), seat(77, 28, "down0", 69, { x: 98, y: 140 })]),
31
+ room("implementing", 1, 1, "carpet", [seat(228, 52, "up0", 99, { x: 245, y: 120 }), seat(318, 52, "up0", 99, { x: 345, y: 120 }), stand(240, 96)]),
32
+ room("verifying", 1, 2, "vinyl", [seat(480, 64, "up0", 113, { x: 497, y: 112 }), stand(406, 96), stand(452, 96)]),
33
+ room("reviewing", 2, 2, "carpet", [seat(516, 204, "down0", 223, { x: 520, y: 270 }), seat(428, 246, "down0", 271, { x: 446, y: 306 }), seat(502, 246, "down0", 271, { x: 520, y: 308 })]),
34
+ room("integrating", 2, 1, "vinyl", [seat(306, 206, "up0", 256, { x: 323, y: 254 }), stand(208, 254), stand(328, 254)]),
35
+ room("lobby", 2, 0, "checker", [seat(22, 202, "up0", 252, { x: 39, y: 252 }), stand(138, 210), stand(56, 240)])
36
+ ];
37
+ const items = [];
38
+ const put = (sprite, x, y, inRoom, extra = {}) => {
39
+ const rows = sprites[extra.variant === undefined ? sprite : sprite + "Pass"];
40
+ const footprint = (extra.foot ?? [baseOf(rows)]).map(r => ({ x: x + r.x, y: y + r.y, w: r.w, h: r.h })).filter(r => r.w > 0 && r.h > 0);
41
+ items.push({ sprite, x, y, z: extra.z ?? y + rows.length, room: inRoom, ...(extra.variant === undefined ? {} : { variant: extra.variant }), footprint });
42
+ };
43
+ // Cubicles are solid except the open front where the chair goes.
44
+ const cubicleFoot = [{ x: 0, y: 0, w: 86, h: 46 }, { x: 0, y: 46, w: 6, h: 12 }, { x: 80, y: 46, w: 6, h: 12 }];
45
+ // planning: meeting table against the wall, people seated behind it
46
+ [16, 50, 84].forEach(x => put("chairFront", x, 44, "planning"));
47
+ put("confTable", 6, 68, "planning");
48
+ put("board", 118, 42, "planning");
49
+ [22, 56, 90].forEach(x => put("chairBack", x, 100, "planning", { z: 128 }));
50
+ put("deskPlant", 158, 128, "planning");
51
+ // implementing: two cubicles on the wall; the floor in front stays open so both doors connect
52
+ put("cubicle", 198, 40, "implementing", { foot: cubicleFoot });
53
+ put("cubicleMirror", 288, 40, "implementing", { foot: cubicleFoot });
54
+ put("chairBack", 232, 70, "implementing", { z: 100 });
55
+ put("chairBack", 322, 70, "implementing", { z: 100 });
56
+ put("trash", 200, 136, "implementing");
57
+ put("plant", 300, 100, "implementing");
58
+ // verifying: racks, a test bench and filing along the wall
59
+ put("rackPass", 388, 46, "verifying");
60
+ put("rack", 416, 46, "verifying", { variant: "verify" });
61
+ put("bench", 460, 54, "verifying", { variant: "verify" });
62
+ put("filing", 548, 54, "verifying");
63
+ put("trash", 392, 136, "verifying");
64
+ put("stool", 504, 104, "verifying");
65
+ put("plant", 536, 100, "verifying");
66
+ // reviewing: bookcase and filing on the wall, the reviewer's desk, two armchairs
67
+ put("lowBookcase", 392, 180, "reviewing");
68
+ put("filing", 444, 178, "reviewing");
69
+ put("execDesk", 500, 222, "reviewing");
70
+ put("armchair", 432, 270, "reviewing");
71
+ put("coffeeTable", 462, 286, "reviewing");
72
+ put("armchair", 506, 270, "reviewing");
73
+ put("plant", 536, 258, "reviewing");
74
+ // integrating: shelving, copier, sorting table, a hand truck and stacked crates
75
+ put("metalShelf", 200, 178, "integrating");
76
+ put("copier", 256, 194, "integrating");
77
+ put("sortTable", 296, 202, "integrating");
78
+ put("handTruck", 356, 204, "integrating");
79
+ put("crates", 252, 258, "integrating");
80
+ put("trash", 236, 294, "integrating");
81
+ // lobby: kitchenette on the wall, sofa, cafe table, plant by the entrance
82
+ put("counter", 8, 184, "lobby");
83
+ put("fridge", 92, 176, "lobby");
84
+ put("vending", 116, 176, "lobby");
85
+ put("cooler", 146, 188, "lobby");
86
+ put("sofa", 84, 230, "lobby");
87
+ put("cafeTable", 120, 270, "lobby");
88
+ put("plant", 8, 258, "lobby");
89
+ put("trash", 92, 296, "lobby");
90
+ const decor = [
91
+ { sprite: "tv", x: 30, y: 3, wall: "back" }, { sprite: "frame", x: 112, y: 9, wall: "back" }, { sprite: "frame", x: 132, y: 13, wall: "back" },
92
+ { sprite: "blinds", x: 210, y: 4, wall: "back" }, { sprite: "clock", x: 281, y: 9, wall: "back" }, { sprite: "blinds", x: 320, y: 4, wall: "back" },
93
+ { sprite: "status", x: 400, y: 3, wall: "back", variant: "verify" }, { sprite: "poster", x: 520, y: 3, wall: "back" },
94
+ { sprite: "diploma", x: 404, y: 160, wall: "middle" }, { sprite: "diploma", x: 424, y: 163, wall: "middle" },
95
+ { sprite: "cubby", x: 258, y: 158, wall: "middle" }, { sprite: "clock", x: 60, y: 160, wall: "middle" }, { sprite: "frame", x: 152, y: 162, wall: "middle" }
96
+ ];
97
+ const side = (id, x, y, between) => ({ id, kind: "side", x, y, between, gap: { x, y: y + 3, w: THICK, h: 28 } });
98
+ const doors = [
99
+ side("planning-implementing", VERTICAL[0], 96, ["planning", "implementing"]),
100
+ side("implementing-verifying", VERTICAL[1], 96, ["implementing", "verifying"]),
101
+ { id: "verifying-reviewing", kind: "front", x: 468, y: MIDDLE.top + 2, between: ["verifying", "reviewing"], gap: { x: 470, y: MIDDLE.top, w: 26, h: MIDDLE.bottom - MIDDLE.top } },
102
+ side("reviewing-integrating", VERTICAL[1], 258, ["reviewing", "integrating"]),
103
+ side("integrating-lobby", VERTICAL[0], 258, ["integrating", "lobby"])
104
+ ];
105
+ const entrance = { x: 56, y: ROW2.bottom, w: 34, h: H - ROW2.bottom };
106
+ const area = (sprite, x, y) => ({ x, y, w: sprites[sprite][0].length, h: sprites[sprite].length });
107
+ return {
108
+ width: W, height: H,
109
+ faces: { back: { x: 0, y: 3, w: W, h: 31 }, middle: { x: 0, y: MIDDLE.top + 3, w: W, h: 31 } },
110
+ walls: { back: { x: 0, y: 0, w: W, h: WALL }, middle: { x: 0, y: MIDDLE.top, w: W, h: MIDDLE.bottom - MIDDLE.top },
111
+ vertical: VERTICAL.flatMap(x => [{ x, y: ROW1.top, w: THICK, h: ROW1.bottom - ROW1.top }, { x, y: ROW2.top, w: THICK, h: ROW2.bottom - ROW2.top }]),
112
+ sides: [{ x: 0, y: WALL, w: 4, h: H - WALL }, { x: W - 4, y: WALL, w: 4, h: H - WALL }], bottom: { x: 0, y: ROW2.bottom, w: W, h: H - ROW2.bottom } },
113
+ rooms, doors, entrance, spawn: { x: entrance.x + 17, y: ROW2.bottom - 2 },
114
+ items, decor,
115
+ rugs: [{ x: 6, y: 50, w: 120, h: 46, fill: "b", border: "c" }, { x: 426, y: 264, w: 100, h: 40, fill: "f", border: "g" }],
116
+ board: { x: 118, y: 42 },
117
+ staff: {
118
+ stations: { qa: rooms[2].slots[0], desk: rooms[3].slots[0], armchair: rooms[3].slots[1], integrator: rooms[4].slots[0] },
119
+ // Two sit on the sofa, in front of its back; two stand chatting at the cafe table.
120
+ rest: [seat(83, 212, "sit", 262, { x: 100, y: 268 }), seat(101, 212, "sit", 262, { x: 118, y: 268 }),
121
+ seat(88, 241, "right0", 291, { x: 106, y: 290 }), seat(148, 241, "left0", 291, { x: 166, y: 290 })]
122
+ },
123
+ hotspots: [
124
+ { kind: "task", room: "planning", rects: [area("board", 118, 42)] },
125
+ // The monitor sits at (30, 1) inside a cubicle, one pixel further in once outlined.
126
+ { kind: "diff", room: "implementing", rects: [{ x: 198 + 31, y: 40 + 2, w: 26, h: 22 }, { x: 288 + 31, y: 40 + 2, w: 26, h: 22 }] },
127
+ { kind: "verify", room: "verifying", rects: [area("statusPass", 400, 3), area("benchPass", 460, 54)] },
128
+ { kind: "review", room: "reviewing", rects: [area("execDesk", 500, 222)] },
129
+ { kind: "integration", room: "integrating", rects: [area("sortTable", 296, 202)] }
130
+ ],
131
+ open: [...rooms.map(({ x, y, w, h }) => ({ x, y, w, h })), ...doors.map(door => door.gap), entrance],
132
+ blocked: items.flatMap(item => item.footprint)
133
+ };
134
+ }
135
+ /** Size of one path-finding cell, in art pixels. */
136
+ export const STEP = 2;
137
+ /** A person's feet: a 14x4 box centred on x, ending at y. */
138
+ export const FOOT = { w: 14, h: 4 };
139
+ /**
140
+ * Free cells for a person's feet. Self-contained: the page embeds it with
141
+ * toString, so it may not reference imports or module-level values.
142
+ */
143
+ export function walkGrid(layout) {
144
+ const step = 2, footW = 14, footH = 4, width = layout.width, height = layout.height;
145
+ const solid = new Array(width * height).fill(1);
146
+ layout.open.forEach(r => { for (let y = r.y; y < r.y + r.h; y++)
147
+ for (let x = r.x; x < r.x + r.w; x++)
148
+ if (x >= 0 && y >= 0 && x < width && y < height)
149
+ solid[y * width + x] = 0; });
150
+ layout.blocked.forEach(r => { for (let y = r.y; y < r.y + r.h; y++)
151
+ for (let x = r.x; x < r.x + r.w; x++)
152
+ if (x >= 0 && y >= 0 && x < width && y < height)
153
+ solid[y * width + x] = 1; });
154
+ // Summed area table: a feet box is free when it covers no solid pixel.
155
+ const sum = new Array((width + 1) * (height + 1)).fill(0);
156
+ for (let y = 0; y < height; y++)
157
+ for (let x = 0; x < width; x++)
158
+ sum[(y + 1) * (width + 1) + x + 1] = solid[y * width + x] + sum[y * (width + 1) + x + 1] + sum[(y + 1) * (width + 1) + x] - sum[y * (width + 1) + x];
159
+ const cols = Math.floor(width / step), rows = Math.floor(height / step), free = new Array(cols * rows).fill(0);
160
+ for (let cy = 0; cy < rows; cy++)
161
+ for (let cx = 0; cx < cols; cx++) {
162
+ const x0 = cx * step - footW / 2, y0 = cy * step - footH, x1 = x0 + footW, y1 = y0 + footH;
163
+ if (x0 < 0 || y0 < 0 || x1 > width || y1 > height)
164
+ continue;
165
+ const blocked = sum[y1 * (width + 1) + x1] - sum[y0 * (width + 1) + x1] - sum[y1 * (width + 1) + x0] + sum[y0 * (width + 1) + x0];
166
+ free[cy * cols + cx] = blocked === 0 ? 1 : 0;
167
+ }
168
+ return { cols, rows, free };
169
+ }
170
+ /**
171
+ * Shortest four-way walk between two feet points, as feet points every cell,
172
+ * or null when unreachable. Self-contained for the page, like walkGrid.
173
+ */
174
+ export function findPath(grid, from, to) {
175
+ const step = 2, cols = grid.cols, rows = grid.rows;
176
+ const cell = (p) => Math.max(0, Math.min(rows - 1, Math.round(p.y / step))) * cols + Math.max(0, Math.min(cols - 1, Math.round(p.x / step)));
177
+ const start = cell(from), goal = cell(to);
178
+ if (!grid.free[start] || !grid.free[goal])
179
+ return null;
180
+ const previous = new Array(cols * rows).fill(-1), queue = [start];
181
+ previous[start] = start;
182
+ for (let head = 0; head < queue.length && previous[goal] === -1; head++) {
183
+ const current = queue[head], cx = current % cols, cy = Math.floor(current / cols);
184
+ const next = [cx > 0 ? current - 1 : -1, cx < cols - 1 ? current + 1 : -1, cy > 0 ? current - cols : -1, cy < rows - 1 ? current + cols : -1];
185
+ for (const candidate of next)
186
+ if (candidate >= 0 && grid.free[candidate] && previous[candidate] === -1) {
187
+ previous[candidate] = current;
188
+ queue.push(candidate);
189
+ }
190
+ }
191
+ if (previous[goal] === -1)
192
+ return null;
193
+ const path = [];
194
+ for (let at = goal;; at = previous[at]) {
195
+ path.push({ x: (at % cols) * step, y: Math.floor(at / cols) * step });
196
+ if (at === start)
197
+ break;
198
+ }
199
+ return path.reverse();
200
+ }