@comento/pota-harness 0.0.0-stage → 1.0.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 (99) hide show
  1. package/.agents/workflows/pota-doctor.md +16 -0
  2. package/.agents/workflows/pota-install.md +15 -0
  3. package/.agents/workflows/pota-memory.md +17 -0
  4. package/.agents/workflows/pota-plan.md +22 -0
  5. package/.agents/workflows/pota-review.md +16 -0
  6. package/.agents/workflows/pota-toolcheck.md +9 -0
  7. package/.agents/workflows/pota-uninstall.md +9 -0
  8. package/.agents/workflows/pota-upgrade.md +15 -0
  9. package/.agents/workflows/pota-work.md +24 -0
  10. package/.claude/agents/explorer.md +7 -0
  11. package/.claude/agents/worker.md +7 -0
  12. package/.claude/commands/pota-doctor.md +5 -0
  13. package/.claude/commands/pota-install.md +9 -0
  14. package/.claude/commands/pota-memory.md +5 -0
  15. package/.claude/commands/pota-plan.md +5 -0
  16. package/.claude/commands/pota-review.md +5 -0
  17. package/.claude/commands/pota-toolcheck.md +7 -0
  18. package/.claude/commands/pota-uninstall.md +5 -0
  19. package/.claude/commands/pota-upgrade.md +9 -0
  20. package/.claude/commands/pota-work.md +7 -0
  21. package/.codex-plugin/plugin.json +33 -0
  22. package/LICENSE +21 -0
  23. package/README.de.md +166 -0
  24. package/README.es.md +166 -0
  25. package/README.fr.md +166 -0
  26. package/README.ja.md +166 -0
  27. package/README.ko.md +184 -0
  28. package/README.md +191 -2
  29. package/README.pt.md +166 -0
  30. package/README.zh.md +166 -0
  31. package/agents/explorer.md +36 -0
  32. package/agents/worker.md +45 -0
  33. package/commands/pota-doctor.md +28 -0
  34. package/commands/pota-install.md +29 -0
  35. package/commands/pota-memory.md +17 -0
  36. package/commands/pota-plan.md +22 -0
  37. package/commands/pota-review.md +16 -0
  38. package/commands/pota-toolcheck.md +17 -0
  39. package/commands/pota-uninstall.md +27 -0
  40. package/commands/pota-upgrade.md +26 -0
  41. package/commands/pota-work.md +24 -0
  42. package/docs/model-guidance/README.md +67 -0
  43. package/docs/model-guidance/claude.md +49 -0
  44. package/docs/model-guidance/codex.md +68 -0
  45. package/hooks/hooks.json +65 -0
  46. package/package.json +47 -4
  47. package/scripts/pota +14 -0
  48. package/scripts/pota-auto-upgrade.mjs +342 -0
  49. package/scripts/pota-cleanup.mjs +194 -0
  50. package/scripts/pota-goal-cli.mjs +337 -0
  51. package/scripts/pota-goal-evidence.mjs +186 -0
  52. package/scripts/pota-goal-history.mjs +316 -0
  53. package/scripts/pota-goal-operations-evidence.mjs +213 -0
  54. package/scripts/pota-goal-operations-mutations.mjs +97 -0
  55. package/scripts/pota-goal-operations.mjs +281 -0
  56. package/scripts/pota-goal-policy.mjs +62 -0
  57. package/scripts/pota-goal-state-validation.mjs +361 -0
  58. package/scripts/pota-goal-state.mjs +136 -0
  59. package/scripts/pota-goal.mjs +69 -0
  60. package/scripts/pota-index.mjs +1052 -0
  61. package/scripts/pota-lifecycle-claude.mjs +173 -0
  62. package/scripts/pota-lifecycle-commands.mjs +418 -0
  63. package/scripts/pota-lifecycle-config.mjs +153 -0
  64. package/scripts/pota-lifecycle-hosts.mjs +98 -0
  65. package/scripts/pota-lifecycle-integrity.mjs +272 -0
  66. package/scripts/pota-lifecycle-layout.mjs +52 -0
  67. package/scripts/pota-lifecycle-lock.mjs +205 -0
  68. package/scripts/pota-lifecycle-mirror.mjs +83 -0
  69. package/scripts/pota-lifecycle-staging.mjs +156 -0
  70. package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
  71. package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
  72. package/scripts/pota-lifecycle-transaction.mjs +12 -0
  73. package/scripts/pota-lifecycle.mjs +244 -0
  74. package/scripts/pota-paths.mjs +190 -0
  75. package/scripts/pota-safe-fs.mjs +277 -0
  76. package/scripts/pota-state-context.mjs +281 -0
  77. package/scripts/pota-state-hook.mjs +26 -0
  78. package/scripts/pota-state-init.mjs +80 -0
  79. package/scripts/pota-state.mjs +245 -0
  80. package/scripts/pota-toolcheck-detect.mjs +229 -0
  81. package/scripts/pota-toolcheck-runtime.mjs +178 -0
  82. package/scripts/pota-toolcheck.mjs +221 -0
  83. package/scripts/pota-update-state.mjs +219 -0
  84. package/scripts/pota.mjs +200 -0
  85. package/skills/pota-memory/SKILL.md +52 -0
  86. package/skills/pota-plan/SKILL.md +66 -0
  87. package/skills/pota-plan/delegation.md +65 -0
  88. package/skills/pota-plan/heavy-path.md +28 -0
  89. package/skills/pota-plan/planning-rules.md +78 -0
  90. package/skills/pota-review/SKILL.md +56 -0
  91. package/skills/pota-work/SKILL.md +71 -0
  92. package/skills/pota-work/delegation.md +65 -0
  93. package/skills/pota-work/heavy-path.md +18 -0
  94. package/skills/pota-work/recovery.md +29 -0
  95. package/skills/pota-work/verification.md +79 -0
  96. package/templates/MEMORY.md +12 -0
  97. package/templates/PLAN.md +9 -0
  98. package/templates/domain-memory.md +22 -0
  99. package/templates/work-plan.md +177 -0
@@ -0,0 +1,337 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { memoryPromotionReminder } from "./pota-goal-history.mjs";
4
+ import { resolvePluginRoot, resolveRepoRoot } from "./pota-paths.mjs";
5
+
6
+ const GOAL_COMMANDS = ["start", "status", "ready", "stats", "task", "replan", "claim", "assign", "receipt", "verify", "finish", "pause", "resume", "block", "cancel", "complete"];
7
+ const GOAL_USAGE = [
8
+ `Usage: pota goal <${GOAL_COMMANDS.join("|")}> [options] [--root PATH]`,
9
+ "Note: --criteria (completionCriteria) is advisory and is not enforced; completion is gated only by required evidence.",
10
+ ].join("\n");
11
+
12
+ export async function runGoalCli(args, {
13
+ stdout = console.log,
14
+ stderr = console.error,
15
+ defaultGoalEvidence,
16
+ operations,
17
+ }) {
18
+ const [firstCommand = "help", ...remaining] = args;
19
+ const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
20
+ // Help and version are answered before parsing options or touching state; arguments after -- are passed through.
21
+ const leading = [command, ...rest];
22
+ const separator = leading.indexOf("--");
23
+ const beforeSeparator = separator === -1 ? leading : leading.slice(0, separator);
24
+ const help = command === "help" || beforeSeparator.includes("--help");
25
+ const version = beforeSeparator.includes("--version");
26
+ if (help && version) throw new Error("--help and --version cannot be used together");
27
+ if (help) {
28
+ stdout(GOAL_USAGE);
29
+ return null;
30
+ }
31
+ if (version) {
32
+ stdout(`pota ${JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8")).version}`);
33
+ return null;
34
+ }
35
+ if (!GOAL_COMMANDS.includes(command)) throw new Error(unknownCommandMessage(command));
36
+ const { options, positional, passthrough } = parseArgs(rest, command);
37
+ // Without --root, resolve the repository (HARNESS_REPO_ROOT or the nearest .git ancestor), not a subdirectory cwd.
38
+ const root = resolveRepoRoot({ explicitRoot: options.root ?? null });
39
+ const emit = options.quiet ? () => {} : stdout;
40
+ // Terminal notices go to stderr only; stdout and the exit code stay those of the transition.
41
+ const onTerminal = (record, { historyError } = {}) => {
42
+ if (historyError) stderr(`Pota warning: goal history was not recorded: ${firstLine(historyError)}`);
43
+ if (record?.status === "completed" && record.memoryPromotion === "missing") stderr(memoryPromotionReminder(record.planId));
44
+ };
45
+ if (command === "start") {
46
+ const tasks = parseTasks(options.tasks ?? positional.slice(0));
47
+ const state = await operations.startGoal({
48
+ root,
49
+ goal: options.goal,
50
+ planId: options.plan,
51
+ tasks,
52
+ completionCriteria: splitValues(options.criteria),
53
+ requiredEvidence: options.requiredEvidence ? splitValues(options.requiredEvidence) : defaultGoalEvidence,
54
+ ownerSessionId: options.session,
55
+ evidencePolicy: options.evidencePolicy ?? options["evidence-policy"] ?? "tool-bound",
56
+ });
57
+ emit(JSON.stringify(state));
58
+ return state;
59
+ }
60
+ if (command === "status") {
61
+ const state = await operations.readGoalState({ root });
62
+ stdout(JSON.stringify(state));
63
+ return state;
64
+ }
65
+ if (command === "task") {
66
+ if (positional[0] === "status" && options.status === undefined) {
67
+ const state = await operations.readGoalState({ root });
68
+ if (!state) {
69
+ stdout(JSON.stringify(null));
70
+ return null;
71
+ }
72
+ const requestedTaskId = options.task ?? positional[1];
73
+ const task = requestedTaskId === undefined
74
+ ? state.tasks.find((candidate) => candidate.status === "active") ?? null
75
+ : state.tasks.find((candidate) => candidate.id === requestedTaskId);
76
+ if (requestedTaskId !== undefined && task === undefined) throw new Error(`Unknown task: ${requestedTaskId}`);
77
+ stdout(JSON.stringify(task));
78
+ return task;
79
+ }
80
+ const taskAction = taskStatusFromAction(positional[0]);
81
+ const state = await operations.setTaskStatus({
82
+ root,
83
+ taskId: options.task ?? (taskAction ? positional[1] : positional[0]),
84
+ status: options.status ?? taskAction ?? positional[1],
85
+ blocker: options.reason,
86
+ effortWaiver: options["allow-inherited-effort"],
87
+ designWaiver: options["allow-no-design-model"],
88
+ });
89
+ emit(JSON.stringify(state));
90
+ return state;
91
+ }
92
+ if (command === "replan") {
93
+ const state = await operations.replanGoal({ root, tasks: parseTasks(options.tasks ?? positional.slice(0)) });
94
+ emit(JSON.stringify(state));
95
+ return state;
96
+ }
97
+ if (command === "claim") {
98
+ const state = await operations.claimGoal({ root, sessionId: options.session });
99
+ emit(JSON.stringify(state));
100
+ return state;
101
+ }
102
+ if (command === "assign") {
103
+ const state = await operations.assignTask({
104
+ root,
105
+ taskId: options.task ?? positional[0],
106
+ agentId: options.agent ?? positional[1] ?? null,
107
+ effort: options.effort,
108
+ effortSource: options["effort-source"],
109
+ route: options.route,
110
+ effortWaiver: options["allow-inherited-effort"],
111
+ kind: options.kind,
112
+ model: options.model,
113
+ modelSource: options["model-source"],
114
+ designWaiver: options["allow-no-design-model"],
115
+ effectiveModel: options["effective-model"],
116
+ modelHost: options.host,
117
+ });
118
+ const task = state.tasks.find((item) => item.id === String(options.task ?? positional[0]).trim());
119
+ if (task?.route === "heavy" && task.assignedAgentId && (!task.effort || task.effortSource === "not-applied")) {
120
+ stderr(`Pota warning: Heavy task ${task.id} is assigned without an applied effort (effort ${task.effort ?? "none"}, source ${task.effortSource ?? "none"}); dispatch with the plan's effort and record --effort <level> --effort-source host-option.`);
121
+ }
122
+ // A worker-reported ID that does not contain the passed alias hints at a silent fallback; warn, never block.
123
+ if (task?.model && task.effectiveModel && task.effectiveModel !== "unreported" && !task.effectiveModel.toLowerCase().includes(task.model.toLowerCase())) {
124
+ stderr(`Pota warning: Task ${task.id} effective model ${task.effectiveModel} does not match the passed model ${task.model}; check the dispatch result.`);
125
+ }
126
+ emit(JSON.stringify(state));
127
+ return state;
128
+ }
129
+ if (command === "receipt") {
130
+ const state = await operations.recordReceipt({
131
+ root,
132
+ taskId: options.task ?? positional[0],
133
+ kind: options.kind,
134
+ scope: options.scope,
135
+ command: options.command,
136
+ exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
137
+ timestamp: options.timestamp,
138
+ goalId: options.goalId ?? options["goal-id"],
139
+ planId: options.plan,
140
+ });
141
+ emit(JSON.stringify(state));
142
+ return state;
143
+ }
144
+ if (command === "verify") {
145
+ const result = await operations.verifyAndRecord({
146
+ root,
147
+ taskId: options.task ?? positional[0],
148
+ kind: splitValues(options.kind),
149
+ scope: options.scope,
150
+ watchPaths: splitValues(options.watch),
151
+ argv: passthrough,
152
+ timeoutMs: options["timeout-ms"] === undefined
153
+ ? 600_000
154
+ : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
155
+ });
156
+ const summaries = result.receipts.map((receipt) => ({
157
+ id: receipt.id,
158
+ scope: receipt.scope,
159
+ kind: receipt.kind,
160
+ exitCode: receipt.exitCode,
161
+ outputDigest: receipt.outputDigest,
162
+ workspaceFingerprint: receipt.workspaceFingerprint,
163
+ }));
164
+ emit(JSON.stringify(summaries.length === 1 ? summaries[0] : summaries));
165
+ return result.state;
166
+ }
167
+ if (command === "finish") {
168
+ const state = await operations.finishGoal({
169
+ root,
170
+ onTerminal,
171
+ watchPaths: splitValues(options.watch),
172
+ argv: passthrough,
173
+ timeoutMs: options["timeout-ms"] === undefined
174
+ ? 600_000
175
+ : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
176
+ });
177
+ emit(JSON.stringify({ id: state.goal.id, status: state.goal.status }));
178
+ return state;
179
+ }
180
+ if (command === "ready") {
181
+ const state = await operations.readGoalState({ root });
182
+ const ids = (status) => (state ? state.tasks.filter((task) => task.status === status).map((task) => task.id) : []);
183
+ const summary = { active: ids("active"), ready: operations.readyTasks(state).map((task) => task.id), blocked: ids("blocked") };
184
+ stdout(JSON.stringify(summary));
185
+ return summary;
186
+ }
187
+ if (command === "stats") {
188
+ if (options.limit !== undefined && !options.history) throw new Error("--limit requires --history");
189
+ if (options.history) {
190
+ const history = await operations.goalHistoryStats({ root, limit: options.limit });
191
+ stdout(JSON.stringify(history));
192
+ return history;
193
+ }
194
+ const stats = operations.goalStats(await operations.readGoalState({ root }), root);
195
+ stdout(JSON.stringify(stats));
196
+ return stats;
197
+ }
198
+ if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
199
+ const state = await operations.transitionGoal({
200
+ root,
201
+ status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
202
+ blocker: options.reason,
203
+ onTerminal,
204
+ });
205
+ emit(JSON.stringify(state));
206
+ return state;
207
+ }
208
+ throw new Error(unknownCommandMessage(command));
209
+ }
210
+
211
+ function unknownCommandMessage(command) {
212
+ return `unknown goal command: ${command} (run pota goal --help)`;
213
+ }
214
+
215
+ function parseArgs(args, command) {
216
+ const options = {};
217
+ const positional = [];
218
+ const passthrough = [];
219
+ const booleanOptions = new Set(["quiet", "history"]);
220
+ const allowed = allowedOptions(command);
221
+ const seenCanonicalOptions = new Set();
222
+ for (let index = 0; index < args.length; index += 1) {
223
+ const value = args[index];
224
+ if (value === "--") {
225
+ passthrough.push(...args.slice(index + 1));
226
+ break;
227
+ }
228
+ if (!value.startsWith("--")) {
229
+ positional.push(value);
230
+ continue;
231
+ }
232
+ const option = value.slice(2);
233
+ const equalsIndex = option.indexOf("=");
234
+ const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
235
+ const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
236
+ if (!allowed.has(key)) throw new Error(`Unknown option for ${command}: --${key}`);
237
+ const canonicalKey = canonicalOptionKey(key);
238
+ if (seenCanonicalOptions.has(canonicalKey)) throw new Error(`--${canonicalKey} may only be provided once`);
239
+ seenCanonicalOptions.add(canonicalKey);
240
+ if (inline !== undefined) {
241
+ if (!booleanOptions.has(key) && inline.length === 0) throw new Error(`--${key} requires a value`);
242
+ if (booleanOptions.has(key) && !["true", "false"].includes(inline)) throw new Error(`--${key} must be true or false`);
243
+ options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
244
+ } else if (booleanOptions.has(key)) {
245
+ options[key] = true;
246
+ } else {
247
+ const next = args[index + 1];
248
+ if (next === undefined || next === "--" || next.startsWith("--")) throw new Error(`--${key} requires a value`);
249
+ options[key] = next;
250
+ index += 1;
251
+ }
252
+ }
253
+ return { options, positional, passthrough };
254
+ }
255
+
256
+ function canonicalOptionKey(key) {
257
+ return {
258
+ exitCode: "exit-code",
259
+ exit: "exit-code",
260
+ goalId: "goal-id",
261
+ evidencePolicy: "evidence-policy",
262
+ }[key] ?? key;
263
+ }
264
+
265
+ function allowedOptions(command) {
266
+ const common = ["root", "quiet"];
267
+ const byCommand = {
268
+ start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
269
+ status: [],
270
+ task: ["task", "status", "reason", "allow-inherited-effort", "allow-no-design-model"],
271
+ replan: ["tasks"],
272
+ claim: ["session"],
273
+ assign: ["task", "agent", "effort", "effort-source", "route", "allow-inherited-effort", "kind", "model", "model-source", "allow-no-design-model", "effective-model", "host"],
274
+ receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
275
+ verify: ["task", "kind", "scope", "watch", "timeout-ms"],
276
+ finish: ["watch", "timeout-ms"],
277
+ stats: ["history", "limit"],
278
+ ready: [],
279
+ pause: [],
280
+ resume: [],
281
+ block: ["reason"],
282
+ cancel: [],
283
+ complete: [],
284
+ help: [],
285
+ };
286
+ return new Set([...common, ...(byCommand[command] ?? [])]);
287
+ }
288
+
289
+ function parseTasks(value) {
290
+ if (Array.isArray(value)) return value;
291
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error("Provide tasks with --tasks JSON or positional task titles");
292
+ try {
293
+ const parsed = JSON.parse(value);
294
+ if (!Array.isArray(parsed)) throw new Error("--tasks JSON must be an array");
295
+ return parsed;
296
+ } catch (error) {
297
+ if (error instanceof SyntaxError) return value.split("|").map((item) => item.trim()).filter(Boolean);
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ function firstLine(error) {
303
+ return String(error?.message ?? error).split(/\r?\n/)[0];
304
+ }
305
+
306
+ function splitValues(value) {
307
+ if (!value) return [];
308
+ return String(value).split("|").map((item) => item.trim()).filter(Boolean);
309
+ }
310
+
311
+ function numberOption(value) {
312
+ if (value === undefined) return undefined;
313
+ const result = Number(value);
314
+ if (!Number.isInteger(result)) throw new Error("exitCode must be an integer");
315
+ return result;
316
+ }
317
+
318
+ function positiveIntegerOption(value, label) {
319
+ const result = Number(value);
320
+ if (!Number.isSafeInteger(result) || result <= 0 || result > 3_600_000) {
321
+ throw new Error(`${label} must be an integer between 1 and 3600000`);
322
+ }
323
+ return result;
324
+ }
325
+
326
+ function taskStatusFromAction(value) {
327
+ return {
328
+ start: "active",
329
+ activate: "active",
330
+ active: "active",
331
+ complete: "completed",
332
+ completed: "completed",
333
+ block: "blocked",
334
+ blocked: "blocked",
335
+ pending: "pending",
336
+ }[value] ?? null;
337
+ }
@@ -0,0 +1,186 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readFileSync, readlinkSync, readdirSync } from "node:fs";
4
+ import { join, relative, resolve, sep } from "node:path";
5
+
6
+ export function normalizeVerificationArgv(argv) {
7
+ if (!Array.isArray(argv) || argv.length === 0) throw new Error("verification command must follow -- as argv");
8
+ return argv.map((item, index) => {
9
+ if (typeof item !== "string") throw new Error(`verification argv[${index}] must be a string`);
10
+ if (index === 0 && item.trim().length === 0) throw new Error("verification executable must be a non-empty string");
11
+ return item;
12
+ });
13
+ }
14
+
15
+ export function normalizeWatchPaths(root, watchPaths) {
16
+ if (!Array.isArray(watchPaths)) throw new Error("watchPaths must be an array");
17
+ const workspaceRoot = resolve(root);
18
+ return [...new Set(watchPaths.map((item, index) => {
19
+ const source = requiredText(item, `watchPaths[${index}]`);
20
+ const absolutePath = resolve(workspaceRoot, source);
21
+ const relativePath = relative(workspaceRoot, absolutePath);
22
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
23
+ throw new Error(`watch path must remain inside the workspace: ${source}`);
24
+ }
25
+ const normalizedPath = relativePath === "" ? "." : relativePath.split(sep).join("/");
26
+ if (isInternalStatePath(normalizedPath)) throw new Error(`watch path cannot target internal Pota or Git state: ${source}`);
27
+ return normalizedPath;
28
+ }))];
29
+ }
30
+
31
+ export function workspaceFingerprint(root = process.cwd(), watchPaths = []) {
32
+ const workspaceRoot = resolve(root);
33
+ const normalizedWatchPaths = normalizeWatchPaths(workspaceRoot, watchPaths);
34
+ const gitPaths = normalizedWatchPaths.length === 0 ? listGitWorkspacePaths(workspaceRoot) : null;
35
+ const paths = normalizedWatchPaths.length > 0
36
+ ? listFilesystemPaths(workspaceRoot, normalizedWatchPaths)
37
+ : (gitPaths ?? listFilesystemPaths(workspaceRoot, normalizedWatchPaths));
38
+ const hash = createHash("sha256");
39
+ for (const path of [...new Set(paths)].sort()) hashWorkspacePath(hash, workspaceRoot, path);
40
+ return `sha256:${hash.digest("hex")}`;
41
+ }
42
+
43
+ export function runVerificationEvidence({
44
+ root,
45
+ argv,
46
+ watchPaths,
47
+ runner = spawnSync,
48
+ timeoutMs,
49
+ }) {
50
+ const initialFingerprint = workspaceFingerprint(root, watchPaths);
51
+ const result = runner(argv[0], argv.slice(1), {
52
+ cwd: resolve(root),
53
+ encoding: "utf8",
54
+ maxBuffer: 16 * 1024 * 1024,
55
+ shell: false,
56
+ timeout: validTimeout(timeoutMs),
57
+ });
58
+ if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
59
+ if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
60
+ const exitCode = Number.isInteger(result.status) ? result.status : 1;
61
+ const output = combinedOutput(result.stdout, result.stderr);
62
+ if (exitCode !== 0) {
63
+ const detail = boundedText(output, 2000);
64
+ throw new Error(`Verification failed with exit ${exitCode}${detail ? `:\n${detail}` : ""}`);
65
+ }
66
+
67
+ const fingerprint = workspaceFingerprint(root, watchPaths);
68
+ if (fingerprint !== initialFingerprint) throw new Error("Watched workspace changed during verification; evidence was not recorded");
69
+ return {
70
+ argv,
71
+ command: argv.map((value) => JSON.stringify(value)).join(" "),
72
+ outputDigest: sha256(output),
73
+ summary: successSummary(output),
74
+ workspaceFingerprint: fingerprint,
75
+ watchPaths,
76
+ };
77
+ }
78
+
79
+ function listGitWorkspacePaths(root) {
80
+ const result = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
81
+ encoding: "utf8",
82
+ maxBuffer: 16 * 1024 * 1024,
83
+ });
84
+ if (result.status !== 0 || result.error) return null;
85
+ return result.stdout.split("\0").filter(Boolean)
86
+ .filter((path) => !isInternalStatePath(path) && !isPotaRecordPath(path));
87
+ }
88
+
89
+ // Pota records evidence in these artifacts after verification; including them in the default fingerprint would
90
+ // make every recorded receipt stale. Pass --watch explicitly when a claim is about these files.
91
+ function isPotaRecordPath(path) {
92
+ return path === "PLAN.md" || path === "MEMORY.md" || path.startsWith("docs/plan/") || path.startsWith("docs/memory/");
93
+ }
94
+
95
+ function listFilesystemPaths(root, watchPaths) {
96
+ const paths = [];
97
+ const visit = (relativePath) => {
98
+ if (isInternalStatePath(relativePath)) return;
99
+ const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
100
+ let stat;
101
+ try {
102
+ stat = lstatSync(absolutePath);
103
+ } catch (error) {
104
+ if (error?.code === "ENOENT") {
105
+ paths.push(relativePath);
106
+ return;
107
+ }
108
+ throw error;
109
+ }
110
+ if (!stat.isDirectory()) {
111
+ paths.push(relativePath);
112
+ return;
113
+ }
114
+ const entries = readdirSync(absolutePath).sort();
115
+ if (entries.length === 0) paths.push(relativePath);
116
+ for (const entry of entries) {
117
+ const child = relativePath === "." ? entry : `${relativePath}/${entry}`;
118
+ visit(child);
119
+ }
120
+ };
121
+ for (const path of watchPaths.length > 0 ? watchPaths : ["."]) visit(path);
122
+ return paths;
123
+ }
124
+
125
+ function hashWorkspacePath(hash, root, relativePath) {
126
+ const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
127
+ hash.update(`${relativePath}\0`);
128
+ let stat;
129
+ try {
130
+ stat = lstatSync(absolutePath);
131
+ } catch (error) {
132
+ if (error?.code === "ENOENT") {
133
+ hash.update("missing\0");
134
+ return;
135
+ }
136
+ throw error;
137
+ }
138
+ hash.update(`${stat.mode}\0`);
139
+ if (stat.isSymbolicLink()) {
140
+ hash.update(`symlink\0${readlinkSync(absolutePath)}\0`);
141
+ } else if (stat.isFile()) {
142
+ hash.update("file\0");
143
+ hash.update(readFileSync(absolutePath));
144
+ hash.update("\0");
145
+ } else if (stat.isDirectory()) {
146
+ hash.update("directory\0");
147
+ } else {
148
+ hash.update("other\0");
149
+ }
150
+ }
151
+
152
+ function isInternalStatePath(path) {
153
+ const firstSegment = path.split("/")[0];
154
+ return firstSegment === ".git" || firstSegment === ".pota";
155
+ }
156
+
157
+ function requiredText(value, label) {
158
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
159
+ return value.trim();
160
+ }
161
+
162
+ function combinedOutput(stdout, stderr) {
163
+ return [stdout, stderr].filter((value) => typeof value === "string" && value.length > 0).join("\n").trim();
164
+ }
165
+
166
+ function boundedText(value, maxLength) {
167
+ const text = String(value ?? "").trim();
168
+ if (text.length <= maxLength) return text;
169
+ return `…${text.slice(-(maxLength - 1))}`;
170
+ }
171
+
172
+ function successSummary(output) {
173
+ const text = String(output ?? "");
174
+ const outputBytes = Buffer.byteLength(text);
175
+ const outputLines = text.length === 0 ? 0 : text.split(/\r?\n/).length;
176
+ return `verification passed; outputBytes=${outputBytes}; outputLines=${outputLines}`;
177
+ }
178
+
179
+ function sha256(value) {
180
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
181
+ }
182
+
183
+ function validTimeout(value) {
184
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error("timeoutMs must be a positive integer");
185
+ return value;
186
+ }