@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.
- package/README.md +2 -0
- package/dist/packages/cli/src/args.js +16 -0
- package/dist/packages/cli/src/bin.js +40 -8
- package/dist/packages/cli/src/cli.js +2 -0
- package/dist/packages/cli/src/commands/batch.js +3 -1
- package/dist/packages/cli/src/commands/update.js +18 -0
- package/dist/packages/cli/src/commands/verify.js +3 -1
- package/dist/packages/cli/src/hook-process.js +48 -0
- package/dist/packages/cli/src/office-entry.js +34 -6
- package/dist/packages/cli/src/run-deps.js +8 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -0
- package/dist/runtime/src/install/update.js +10 -2
- package/dist/runtime/src/office/activity.js +105 -0
- package/dist/runtime/src/office/art.js +999 -0
- package/dist/runtime/src/office/collect.js +160 -11
- package/dist/runtime/src/office/layout.js +200 -0
- package/dist/runtime/src/office/page.js +760 -645
- package/dist/runtime/src/office/scene.js +27 -31
- package/dist/runtime/src/office/sessions.js +22 -6
- package/dist/runtime/src/office/snapshot.js +62 -1
- package/dist/runtime/src/parallel/finish.js +4 -2
- package/dist/runtime/src/review/batch.js +10 -0
- package/dist/runtime/src/review/execute.js +9 -0
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/docs/en/guides/configuration.md +7 -0
- package/docs/en/spec/harness-adapters.md +16 -2
- package/docs/zh-TW/guides/configuration.md +6 -0
- package/docs/zh-TW/spec/harness-adapters.md +12 -2
- package/package.json +1 -1
|
@@ -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
|
|
69
|
-
|
|
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,
|
|
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),
|
|
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
|
+
}
|