@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.
- package/.agents/workflows/pota-doctor.md +16 -0
- package/.agents/workflows/pota-install.md +15 -0
- package/.agents/workflows/pota-memory.md +17 -0
- package/.agents/workflows/pota-plan.md +22 -0
- package/.agents/workflows/pota-review.md +16 -0
- package/.agents/workflows/pota-toolcheck.md +9 -0
- package/.agents/workflows/pota-uninstall.md +9 -0
- package/.agents/workflows/pota-upgrade.md +15 -0
- package/.agents/workflows/pota-work.md +24 -0
- package/.claude/agents/explorer.md +7 -0
- package/.claude/agents/worker.md +7 -0
- package/.claude/commands/pota-doctor.md +5 -0
- package/.claude/commands/pota-install.md +9 -0
- package/.claude/commands/pota-memory.md +5 -0
- package/.claude/commands/pota-plan.md +5 -0
- package/.claude/commands/pota-review.md +5 -0
- package/.claude/commands/pota-toolcheck.md +7 -0
- package/.claude/commands/pota-uninstall.md +5 -0
- package/.claude/commands/pota-upgrade.md +9 -0
- package/.claude/commands/pota-work.md +7 -0
- package/.codex-plugin/plugin.json +33 -0
- package/LICENSE +21 -0
- package/README.de.md +166 -0
- package/README.es.md +166 -0
- package/README.fr.md +166 -0
- package/README.ja.md +166 -0
- package/README.ko.md +184 -0
- package/README.md +191 -2
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/agents/explorer.md +36 -0
- package/agents/worker.md +45 -0
- package/commands/pota-doctor.md +28 -0
- package/commands/pota-install.md +29 -0
- package/commands/pota-memory.md +17 -0
- package/commands/pota-plan.md +22 -0
- package/commands/pota-review.md +16 -0
- package/commands/pota-toolcheck.md +17 -0
- package/commands/pota-uninstall.md +27 -0
- package/commands/pota-upgrade.md +26 -0
- package/commands/pota-work.md +24 -0
- package/docs/model-guidance/README.md +67 -0
- package/docs/model-guidance/claude.md +49 -0
- package/docs/model-guidance/codex.md +68 -0
- package/hooks/hooks.json +65 -0
- package/package.json +47 -4
- package/scripts/pota +14 -0
- package/scripts/pota-auto-upgrade.mjs +342 -0
- package/scripts/pota-cleanup.mjs +194 -0
- package/scripts/pota-goal-cli.mjs +337 -0
- package/scripts/pota-goal-evidence.mjs +186 -0
- package/scripts/pota-goal-history.mjs +316 -0
- package/scripts/pota-goal-operations-evidence.mjs +213 -0
- package/scripts/pota-goal-operations-mutations.mjs +97 -0
- package/scripts/pota-goal-operations.mjs +281 -0
- package/scripts/pota-goal-policy.mjs +62 -0
- package/scripts/pota-goal-state-validation.mjs +361 -0
- package/scripts/pota-goal-state.mjs +136 -0
- package/scripts/pota-goal.mjs +69 -0
- package/scripts/pota-index.mjs +1052 -0
- package/scripts/pota-lifecycle-claude.mjs +173 -0
- package/scripts/pota-lifecycle-commands.mjs +418 -0
- package/scripts/pota-lifecycle-config.mjs +153 -0
- package/scripts/pota-lifecycle-hosts.mjs +98 -0
- package/scripts/pota-lifecycle-integrity.mjs +272 -0
- package/scripts/pota-lifecycle-layout.mjs +52 -0
- package/scripts/pota-lifecycle-lock.mjs +205 -0
- package/scripts/pota-lifecycle-mirror.mjs +83 -0
- package/scripts/pota-lifecycle-staging.mjs +156 -0
- package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
- package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/pota-lifecycle-transaction.mjs +12 -0
- package/scripts/pota-lifecycle.mjs +244 -0
- package/scripts/pota-paths.mjs +190 -0
- package/scripts/pota-safe-fs.mjs +277 -0
- package/scripts/pota-state-context.mjs +281 -0
- package/scripts/pota-state-hook.mjs +26 -0
- package/scripts/pota-state-init.mjs +80 -0
- package/scripts/pota-state.mjs +245 -0
- package/scripts/pota-toolcheck-detect.mjs +229 -0
- package/scripts/pota-toolcheck-runtime.mjs +178 -0
- package/scripts/pota-toolcheck.mjs +221 -0
- package/scripts/pota-update-state.mjs +219 -0
- package/scripts/pota.mjs +200 -0
- package/skills/pota-memory/SKILL.md +52 -0
- package/skills/pota-plan/SKILL.md +66 -0
- package/skills/pota-plan/delegation.md +65 -0
- package/skills/pota-plan/heavy-path.md +28 -0
- package/skills/pota-plan/planning-rules.md +78 -0
- package/skills/pota-review/SKILL.md +56 -0
- package/skills/pota-work/SKILL.md +71 -0
- package/skills/pota-work/delegation.md +65 -0
- package/skills/pota-work/heavy-path.md +18 -0
- package/skills/pota-work/recovery.md +29 -0
- package/skills/pota-work/verification.md +79 -0
- package/templates/MEMORY.md +12 -0
- package/templates/PLAN.md +9 -0
- package/templates/domain-memory.md +22 -0
- package/templates/work-plan.md +177 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { lstat, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { potaHome, goalStateDirectory, repositoryId } from "./pota-paths.mjs";
|
|
6
|
+
import { assertNoSymlinkComponents, safeWriteFile } from "./pota-safe-fs.mjs";
|
|
7
|
+
|
|
8
|
+
export const GOAL_HISTORY_SCHEMA_VERSION = 1;
|
|
9
|
+
export const GOAL_HISTORY_MAX_RECORDS = 200;
|
|
10
|
+
export const GOAL_HISTORY_LIMIT_MAX = 200;
|
|
11
|
+
const TITLE_MAX_LENGTH = 120;
|
|
12
|
+
const PLAN_FILE_MAX_BYTES = 512 * 1024;
|
|
13
|
+
const RECENT_MAX = 5;
|
|
14
|
+
const TREND_MIN_COMPLETED = 4;
|
|
15
|
+
const historyStatuses = new Set(["completed", "cancelled"]);
|
|
16
|
+
const memoryPromotionValues = ["note", "none", "missing", "unknown"];
|
|
17
|
+
const memoryPromotionLine = /^\s*(?:[-*]\s*)?Memory promotion\s*:\s*(.*)$/gim;
|
|
18
|
+
|
|
19
|
+
export function goalHistoryPath(root = process.cwd()) {
|
|
20
|
+
return join(goalStateDirectory(root), "history.jsonl");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function memoryPromotionReminder(planId) {
|
|
24
|
+
return `Pota memory: plan ${planId} has no "Memory promotion:" line. If this goal produced reusable repository knowledge, write it to docs/memory/<domain>.md and run pota memory index; otherwise record "Memory promotion: none: <reason>" in the plan.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Classifies the plan's memory promotion decision. Anything that is not a regular repository file of at most
|
|
28
|
+
// 512 KiB (an id such as plan-status, a symlink, a directory, a path outside root) is "unknown"; never throws.
|
|
29
|
+
export function memoryPromotionStatus(root, planId) {
|
|
30
|
+
try {
|
|
31
|
+
const workspaceRoot = resolve(root);
|
|
32
|
+
const target = resolve(workspaceRoot, String(planId));
|
|
33
|
+
const relation = relative(workspaceRoot, target);
|
|
34
|
+
if (relation === "" || relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation)) return "unknown";
|
|
35
|
+
let current = workspaceRoot;
|
|
36
|
+
for (const component of relation.split(sep)) {
|
|
37
|
+
current = join(current, component);
|
|
38
|
+
if (lstatSync(current).isSymbolicLink()) return "unknown";
|
|
39
|
+
}
|
|
40
|
+
const stat = lstatSync(target);
|
|
41
|
+
if (!stat.isFile() || stat.size > PLAN_FILE_MAX_BYTES) return "unknown";
|
|
42
|
+
const text = readFileSync(target, "utf8");
|
|
43
|
+
for (const match of text.matchAll(memoryPromotionLine)) {
|
|
44
|
+
const value = match[1].trim();
|
|
45
|
+
if (value.length === 0 || /^<.*>$/.test(value)) continue;
|
|
46
|
+
return /^(?:none|없음)/i.test(value) ? "none" : "note";
|
|
47
|
+
}
|
|
48
|
+
return "missing";
|
|
49
|
+
} catch {
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Builds the schemaVersion 1 summary of a terminal goal. It carries ids and counts only, never absolute paths.
|
|
55
|
+
export function buildGoalHistoryRecord(state, { root = process.cwd(), recordedBy, endedAt = state.goal.updatedAt } = {}) {
|
|
56
|
+
const { goal, tasks, receipts } = state;
|
|
57
|
+
const createdMs = Date.parse(goal.createdAt);
|
|
58
|
+
const endedMs = Date.parse(endedAt);
|
|
59
|
+
const durationMs = Number.isFinite(createdMs) && Number.isFinite(endedMs) ? Math.max(0, endedMs - createdMs) : 0;
|
|
60
|
+
const byKind = {};
|
|
61
|
+
const latest = new Set();
|
|
62
|
+
let reruns = 0;
|
|
63
|
+
// Same rerun definition as goalStats: a later receipt for an already-seen scope, task, and kind.
|
|
64
|
+
for (const receipt of receipts) {
|
|
65
|
+
byKind[receipt.kind] = (byKind[receipt.kind] ?? 0) + 1;
|
|
66
|
+
const key = `${receipt.scope}\0${receipt.taskId ?? ""}\0${receipt.kind}`;
|
|
67
|
+
if (latest.has(key)) reruns += 1;
|
|
68
|
+
latest.add(key);
|
|
69
|
+
}
|
|
70
|
+
const models = {};
|
|
71
|
+
const efforts = {};
|
|
72
|
+
for (const task of tasks) {
|
|
73
|
+
if (task.model) models[task.model] = (models[task.model] ?? 0) + 1;
|
|
74
|
+
if (task.effort) efforts[task.effort] = (efforts[task.effort] ?? 0) + 1;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
78
|
+
goalId: goal.id,
|
|
79
|
+
planId: goal.planId,
|
|
80
|
+
title: Array.from(goal.title).slice(0, TITLE_MAX_LENGTH).join(""),
|
|
81
|
+
status: goal.status,
|
|
82
|
+
worktreeId: goal.worktreeId ?? null,
|
|
83
|
+
createdAt: goal.createdAt,
|
|
84
|
+
endedAt,
|
|
85
|
+
durationMs,
|
|
86
|
+
revision: state.revision,
|
|
87
|
+
evidencePolicy: goal.evidencePolicy,
|
|
88
|
+
tasks: {
|
|
89
|
+
total: tasks.length,
|
|
90
|
+
completed: tasks.filter((task) => task.status === "completed").length,
|
|
91
|
+
heavy: tasks.filter((task) => task.route === "heavy").length,
|
|
92
|
+
light: tasks.filter((task) => task.route === "light").length,
|
|
93
|
+
delegated: tasks.filter((task) => task.assignedAgentId).length,
|
|
94
|
+
design: tasks.filter((task) => task.kind === "design").length,
|
|
95
|
+
},
|
|
96
|
+
receipts: {
|
|
97
|
+
total: receipts.length,
|
|
98
|
+
tool: receipts.filter((receipt) => receipt.source === "tool").length,
|
|
99
|
+
reruns,
|
|
100
|
+
byKind,
|
|
101
|
+
},
|
|
102
|
+
waivers: {
|
|
103
|
+
effort: tasks.filter((task) => task.effortWaiver).length,
|
|
104
|
+
design: tasks.filter((task) => task.designWaiver).length,
|
|
105
|
+
},
|
|
106
|
+
models,
|
|
107
|
+
efforts,
|
|
108
|
+
memoryPromotion: memoryPromotionStatus(root, goal.planId),
|
|
109
|
+
recordedBy,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Records a terminal goal while the caller holds the goal state lock. History is best effort: it never throws, and
|
|
114
|
+
// the caller reports a returned error instead of blocking the transition.
|
|
115
|
+
export async function recordGoalHistoryUnlocked(root, state, options) {
|
|
116
|
+
let record = null;
|
|
117
|
+
try {
|
|
118
|
+
record = buildGoalHistoryRecord(state, { ...options, root });
|
|
119
|
+
await writeGoalHistoryRecordUnlocked(root, record);
|
|
120
|
+
return { record, error: null };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { record, error };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Keeps valid lines only, replaces a record with the same goalId, appends, and keeps the newest 200 records.
|
|
127
|
+
// The state lock serializes writers per worktree; temporary file + rename keeps readers on a whole file.
|
|
128
|
+
export async function writeGoalHistoryRecordUnlocked(root, record) {
|
|
129
|
+
const path = goalHistoryPath(root);
|
|
130
|
+
await assertNoSymlinkComponents(potaHome(), path);
|
|
131
|
+
const { records } = await readHistoryFile(path);
|
|
132
|
+
const kept = records.filter((item) => item.goalId !== record.goalId);
|
|
133
|
+
kept.push(record);
|
|
134
|
+
const text = kept.slice(-GOAL_HISTORY_MAX_RECORDS).map((item) => JSON.stringify(item)).join("\n");
|
|
135
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
136
|
+
try {
|
|
137
|
+
await safeWriteFile(potaHome(), temporaryPath, `${text}\n`, "utf8");
|
|
138
|
+
await rename(temporaryPath, path);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readHistoryFile(path) {
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await readFile(path, "utf8");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error?.code === "ENOENT") return { records: [], skippedLines: 0 };
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const records = [];
|
|
154
|
+
let skippedLines = 0;
|
|
155
|
+
for (const line of text.split("\n")) {
|
|
156
|
+
if (line.trim().length === 0) continue;
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(line);
|
|
160
|
+
} catch {
|
|
161
|
+
value = null;
|
|
162
|
+
}
|
|
163
|
+
if (isHistoryRecord(value)) records.push(value);
|
|
164
|
+
else skippedLines += 1;
|
|
165
|
+
}
|
|
166
|
+
return { records, skippedLines };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isHistoryRecord(value) {
|
|
170
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
171
|
+
&& value.schemaVersion === GOAL_HISTORY_SCHEMA_VERSION
|
|
172
|
+
&& typeof value.goalId === "string" && value.goalId.length > 0
|
|
173
|
+
&& historyStatuses.has(value.status)
|
|
174
|
+
&& typeof value.endedAt === "string" && Number.isFinite(Date.parse(value.endedAt));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reads every worktree history of this repository without taking locks or creating directories.
|
|
178
|
+
async function readRepositoryHistory(root) {
|
|
179
|
+
const worktreesDirectory = join(potaHome(), "projects", repositoryId(root), "worktrees");
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
await assertNoSymlinkComponents(potaHome(), worktreesDirectory);
|
|
183
|
+
entries = await readdir(worktreesDirectory, { withFileTypes: true });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return { records: [], skippedLines: 0 };
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const byGoal = new Map();
|
|
189
|
+
let skippedLines = 0;
|
|
190
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
191
|
+
const path = join(worktreesDirectory, entry.name, "history.jsonl");
|
|
192
|
+
try {
|
|
193
|
+
if (!(await lstat(path)).isFile()) continue;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === "ENOENT") continue;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const file = await readHistoryFile(path);
|
|
199
|
+
skippedLines += file.skippedLines;
|
|
200
|
+
for (const record of file.records) {
|
|
201
|
+
const existing = byGoal.get(record.goalId);
|
|
202
|
+
if (!existing || Date.parse(record.endedAt) > Date.parse(existing.endedAt)) byGoal.set(record.goalId, record);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { records: [...byGoal.values()], skippedLines };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function validHistoryLimit(value) {
|
|
209
|
+
if (value === undefined || value === null) return null;
|
|
210
|
+
const text = String(value);
|
|
211
|
+
const result = Number(text);
|
|
212
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(result) || result < 1 || result > GOAL_HISTORY_LIMIT_MAX) {
|
|
213
|
+
throw new Error(`--limit must be an integer between 1 and ${GOAL_HISTORY_LIMIT_MAX}`);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Repository-wide summary across goals. Medians, p90, rates, and trend use completed goals only; limit keeps the
|
|
219
|
+
// newest N records by endedAt.
|
|
220
|
+
export async function goalHistoryStats({ root = process.cwd(), limit } = {}) {
|
|
221
|
+
const normalizedLimit = validHistoryLimit(limit);
|
|
222
|
+
const history = await readRepositoryHistory(root);
|
|
223
|
+
const ordered = history.records.sort((a, b) => Date.parse(a.endedAt) - Date.parse(b.endedAt) || a.goalId.localeCompare(b.goalId));
|
|
224
|
+
const records = normalizedLimit === null ? ordered : ordered.slice(-normalizedLimit);
|
|
225
|
+
if (records.length === 0) {
|
|
226
|
+
return {
|
|
227
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
228
|
+
scope: "repository",
|
|
229
|
+
goals: 0,
|
|
230
|
+
...(history.skippedLines > 0 ? { skippedLines: history.skippedLines } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const completed = records.filter((record) => record.status === "completed");
|
|
234
|
+
const memoryPromotion = Object.fromEntries(memoryPromotionValues.map((value) => [value, 0]));
|
|
235
|
+
for (const record of records) {
|
|
236
|
+
const value = memoryPromotionValues.includes(record.memoryPromotion) ? record.memoryPromotion : "unknown";
|
|
237
|
+
memoryPromotion[value] += 1;
|
|
238
|
+
}
|
|
239
|
+
const durations = completed.map((record) => count(record.durationMs)).sort((a, b) => a - b);
|
|
240
|
+
const receiptTotals = completed.map((record) => count(record.receipts?.total));
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
243
|
+
scope: "repository",
|
|
244
|
+
goals: records.length,
|
|
245
|
+
completed: completed.length,
|
|
246
|
+
cancelled: records.length - completed.length,
|
|
247
|
+
skippedLines: history.skippedLines,
|
|
248
|
+
window: { from: records[0].endedAt, to: records.at(-1).endedAt },
|
|
249
|
+
durationMs: { median: median(durations), p90: durations.length === 0 ? 0 : durations[Math.ceil(0.9 * durations.length) - 1] },
|
|
250
|
+
tasks: {
|
|
251
|
+
total: sum(completed, (record) => record.tasks?.total),
|
|
252
|
+
median: median(completed.map((record) => count(record.tasks?.total))),
|
|
253
|
+
heavy: sum(completed, (record) => record.tasks?.heavy),
|
|
254
|
+
light: sum(completed, (record) => record.tasks?.light),
|
|
255
|
+
delegated: sum(completed, (record) => record.tasks?.delegated),
|
|
256
|
+
},
|
|
257
|
+
receipts: {
|
|
258
|
+
total: sum(completed, (record) => record.receipts?.total),
|
|
259
|
+
tool: sum(completed, (record) => record.receipts?.tool),
|
|
260
|
+
reruns: sum(completed, (record) => record.receipts?.reruns),
|
|
261
|
+
rerunRate: rerunRate(completed),
|
|
262
|
+
median: median(receiptTotals),
|
|
263
|
+
},
|
|
264
|
+
waivers: {
|
|
265
|
+
effort: sum(records, (record) => record.waivers?.effort),
|
|
266
|
+
design: sum(records, (record) => record.waivers?.design),
|
|
267
|
+
},
|
|
268
|
+
memoryPromotion,
|
|
269
|
+
trend: trend(completed),
|
|
270
|
+
recent: records.slice(-RECENT_MAX).reverse().map((record) => ({
|
|
271
|
+
goalId: record.goalId,
|
|
272
|
+
planId: record.planId,
|
|
273
|
+
status: record.status,
|
|
274
|
+
endedAt: record.endedAt,
|
|
275
|
+
durationMs: count(record.durationMs),
|
|
276
|
+
tasks: count(record.tasks?.total),
|
|
277
|
+
receipts: count(record.receipts?.total),
|
|
278
|
+
reruns: count(record.receipts?.reruns),
|
|
279
|
+
memoryPromotion: record.memoryPromotion,
|
|
280
|
+
})),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Completed goals in endedAt order split in half; an odd count gives newer the extra goal.
|
|
285
|
+
function trend(completed) {
|
|
286
|
+
if (completed.length < TREND_MIN_COMPLETED) return null;
|
|
287
|
+
const middle = Math.floor(completed.length / 2);
|
|
288
|
+
const summarize = (records) => ({
|
|
289
|
+
goals: records.length,
|
|
290
|
+
durationMsMedian: median(records.map((record) => count(record.durationMs))),
|
|
291
|
+
receiptsMedian: median(records.map((record) => count(record.receipts?.total))),
|
|
292
|
+
rerunRate: rerunRate(records),
|
|
293
|
+
});
|
|
294
|
+
return { older: summarize(completed.slice(0, middle)), newer: summarize(completed.slice(middle)) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rerunRate(records) {
|
|
298
|
+
const total = sum(records, (record) => record.receipts?.total);
|
|
299
|
+
const reruns = sum(records, (record) => record.receipts?.reruns);
|
|
300
|
+
return Number((reruns / Math.max(1, total)).toFixed(3));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function median(values) {
|
|
304
|
+
if (values.length === 0) return 0;
|
|
305
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
306
|
+
const middle = Math.floor(sorted.length / 2);
|
|
307
|
+
return sorted.length % 2 === 1 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sum(records, pick) {
|
|
311
|
+
return records.reduce((total, record) => total + count(pick(record)), 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function count(value) {
|
|
315
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
316
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { normalizeVerificationArgv, normalizeWatchPaths, runVerificationEvidence, workspaceFingerprint } from "./pota-goal-evidence.mjs";
|
|
3
|
+
import { readGoalState } from "./pota-goal-state.mjs";
|
|
4
|
+
import {
|
|
5
|
+
normalizeToolEvidence, pathContains, requiredText, timestamp, validReceiptCommand, validReceiptScope,
|
|
6
|
+
validReceiptSource, validTimestamp,
|
|
7
|
+
} from "./pota-goal-state-validation.mjs";
|
|
8
|
+
import { assertGoalMutable, isReceiptFresh, mutateGoal, taskById } from "./pota-goal-operations-mutations.mjs";
|
|
9
|
+
import { setTaskStatus, transitionGoal } from "./pota-goal-operations.mjs";
|
|
10
|
+
|
|
11
|
+
export async function recordReceipt(options = {}) {
|
|
12
|
+
if (options.source !== undefined && options.source !== "declared") throw new Error("Only goal verify can create tool evidence");
|
|
13
|
+
return recordEvidence({ ...options, source: "declared" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function recordEvidence({
|
|
17
|
+
root = process.cwd(), taskId, kind, scope = "task", command, exitCode,
|
|
18
|
+
timestamp: receiptTimestamp, goalId, planId, source, argv, outputDigest,
|
|
19
|
+
summary, workspaceFingerprint: fingerprint, watchPaths = [], expectedIdentity,
|
|
20
|
+
expectedFingerprint,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const normalizedScope = validReceiptScope(scope);
|
|
23
|
+
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
24
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
25
|
+
const normalizedCommand = validReceiptCommand(command);
|
|
26
|
+
const normalizedSource = validReceiptSource(source);
|
|
27
|
+
if (exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
28
|
+
const normalizedTimestamp = validTimestamp(receiptTimestamp ?? timestamp());
|
|
29
|
+
const toolFields = normalizedSource === "tool" ? normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) : {};
|
|
30
|
+
return mutateGoal(root, (state) => {
|
|
31
|
+
assertGoalMutable(state);
|
|
32
|
+
const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
|
|
33
|
+
if (expectedIdentity && (state.goal.id !== expectedIdentity.goalId || state.goal.planId !== expectedIdentity.planId)) {
|
|
34
|
+
throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
35
|
+
}
|
|
36
|
+
if (expectedIdentity && normalizedScope === "task" && (
|
|
37
|
+
task?.id !== expectedIdentity.taskId
|
|
38
|
+
|| task?.status !== expectedIdentity.taskStatus
|
|
39
|
+
|| task?.assignedAgentId !== expectedIdentity.taskAgentId
|
|
40
|
+
)) throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
41
|
+
if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
|
|
42
|
+
throw new Error("Watched workspace changed during verification; evidence was not recorded");
|
|
43
|
+
}
|
|
44
|
+
if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
|
|
45
|
+
if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
|
|
46
|
+
const receipt = {
|
|
47
|
+
id: `receipt-${randomUUID()}`,
|
|
48
|
+
scope: normalizedScope,
|
|
49
|
+
kind: normalizedKind,
|
|
50
|
+
...(task ? { taskId: task.id } : {}),
|
|
51
|
+
goalId: state.goal.id,
|
|
52
|
+
planId: state.goal.planId,
|
|
53
|
+
command: normalizedCommand,
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
timestamp: normalizedTimestamp,
|
|
56
|
+
source: normalizedSource,
|
|
57
|
+
...toolFields,
|
|
58
|
+
};
|
|
59
|
+
state.receipts.push(receipt);
|
|
60
|
+
if (task) task.updatedAt = timestamp();
|
|
61
|
+
state.goal.updatedAt = timestamp();
|
|
62
|
+
return state;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function verifyAndRecord({
|
|
67
|
+
root = process.cwd(), taskId, kind, scope = "task", argv, watchPaths = [],
|
|
68
|
+
runner, timeoutMs = 600_000,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const normalizedArgv = normalizeVerificationArgv(argv);
|
|
71
|
+
const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
|
|
72
|
+
const normalizedScope = validReceiptScope(scope);
|
|
73
|
+
const normalizedKinds = normalizeKinds(kind);
|
|
74
|
+
const initialState = await readGoalState({ root });
|
|
75
|
+
if (!initialState) throw new Error("No Pota goal state exists; start a goal first");
|
|
76
|
+
assertGoalMutable(initialState);
|
|
77
|
+
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
78
|
+
const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
|
|
79
|
+
if (initialTask && !["active", "completed"].includes(initialTask.status)) {
|
|
80
|
+
throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
|
|
81
|
+
}
|
|
82
|
+
assertParallelWatch(initialState, initialTask, normalizedWatchPaths);
|
|
83
|
+
const identity = {
|
|
84
|
+
goalId: initialState.goal.id,
|
|
85
|
+
planId: initialState.goal.planId,
|
|
86
|
+
taskId: normalizedTaskId,
|
|
87
|
+
taskStatus: initialTask?.status,
|
|
88
|
+
taskAgentId: initialTask?.assignedAgentId,
|
|
89
|
+
};
|
|
90
|
+
const evidence = runVerificationEvidence({
|
|
91
|
+
root,
|
|
92
|
+
argv: normalizedArgv,
|
|
93
|
+
watchPaths: normalizedWatchPaths,
|
|
94
|
+
runner,
|
|
95
|
+
timeoutMs,
|
|
96
|
+
});
|
|
97
|
+
const postState = await readGoalState({ root });
|
|
98
|
+
if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
|
|
99
|
+
throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
100
|
+
}
|
|
101
|
+
if (normalizedScope === "task") {
|
|
102
|
+
const postTask = taskById(postState, normalizedTaskId);
|
|
103
|
+
if (postTask.status !== identity.taskStatus || postTask.assignedAgentId !== identity.taskAgentId) {
|
|
104
|
+
throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// One verification run may prove several evidence kinds; record one receipt per kind without rerunning it.
|
|
108
|
+
const receipts = [];
|
|
109
|
+
let state;
|
|
110
|
+
for (const normalizedKind of normalizedKinds) {
|
|
111
|
+
state = await recordEvidence({
|
|
112
|
+
root,
|
|
113
|
+
taskId: normalizedTaskId,
|
|
114
|
+
kind: normalizedKind,
|
|
115
|
+
scope: normalizedScope,
|
|
116
|
+
command: evidence.command,
|
|
117
|
+
exitCode: 0,
|
|
118
|
+
source: "tool",
|
|
119
|
+
argv: evidence.argv,
|
|
120
|
+
outputDigest: evidence.outputDigest,
|
|
121
|
+
summary: evidence.summary,
|
|
122
|
+
workspaceFingerprint: evidence.workspaceFingerprint,
|
|
123
|
+
watchPaths: evidence.watchPaths,
|
|
124
|
+
goalId: identity.goalId,
|
|
125
|
+
planId: identity.planId,
|
|
126
|
+
expectedIdentity: identity,
|
|
127
|
+
expectedFingerprint: evidence.workspaceFingerprint,
|
|
128
|
+
});
|
|
129
|
+
receipts.push(state.receipts.at(-1));
|
|
130
|
+
}
|
|
131
|
+
return { state, evidence: receipts.at(-1), receipts };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Completes the active task, proves every goal-level evidence kind with one run, and completes the goal.
|
|
135
|
+
export async function finishGoal({
|
|
136
|
+
root = process.cwd(), argv = [], watchPaths = [], runner, timeoutMs = 600_000, onTerminal,
|
|
137
|
+
} = {}) {
|
|
138
|
+
const initialState = await readGoalState({ root });
|
|
139
|
+
if (!initialState) throw new Error("No Pota goal state exists; start a goal first");
|
|
140
|
+
assertGoalMutable(initialState);
|
|
141
|
+
const active = initialState.tasks.find((task) => task.status === "active");
|
|
142
|
+
const unfinished = initialState.tasks.filter((task) => task.status !== "completed" && task !== active);
|
|
143
|
+
if (unfinished.length > 0) {
|
|
144
|
+
throw new Error(`Goal cannot be finished while other tasks remain: ${unfinished.map((task) => task.id).join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
const goalKinds = initialState.goal.requiredEvidence;
|
|
147
|
+
if (goalKinds.length > 0) normalizeVerificationArgv(argv);
|
|
148
|
+
if (active) await setTaskStatus({ root, taskId: active.id, status: "completed" });
|
|
149
|
+
if (goalKinds.length > 0) {
|
|
150
|
+
await verifyAndRecord({ root, scope: "goal", kind: goalKinds, argv, watchPaths, runner, timeoutMs });
|
|
151
|
+
}
|
|
152
|
+
return transitionGoal({ root, status: "completed", onTerminal });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Overhead measurement: how many verifications ran, how many were reruns, and which latest receipts are stale.
|
|
156
|
+
export function goalStats(state, root = process.cwd()) {
|
|
157
|
+
if (!state) return null;
|
|
158
|
+
const byKind = {};
|
|
159
|
+
const latest = new Map();
|
|
160
|
+
let reruns = 0;
|
|
161
|
+
for (const receipt of state.receipts) {
|
|
162
|
+
byKind[receipt.kind] = (byKind[receipt.kind] ?? 0) + 1;
|
|
163
|
+
const key = `${receipt.scope}\0${receipt.taskId ?? ""}\0${receipt.kind}`;
|
|
164
|
+
if (latest.has(key)) reruns += 1;
|
|
165
|
+
latest.set(key, receipt);
|
|
166
|
+
}
|
|
167
|
+
const stale = [...latest.values()]
|
|
168
|
+
.filter((receipt) => receipt.source === "tool" && !isReceiptFresh(root, receipt))
|
|
169
|
+
.map((receipt) => ({ scope: receipt.scope, ...(receipt.taskId ? { taskId: receipt.taskId } : {}), kind: receipt.kind }));
|
|
170
|
+
return {
|
|
171
|
+
goalId: state.goal.id,
|
|
172
|
+
status: state.goal.status,
|
|
173
|
+
receipts: state.receipts.length,
|
|
174
|
+
toolReceipts: state.receipts.filter((receipt) => receipt.source === "tool").length,
|
|
175
|
+
byKind,
|
|
176
|
+
reruns,
|
|
177
|
+
stale,
|
|
178
|
+
tasks: state.tasks.map((task) => ({
|
|
179
|
+
id: task.id,
|
|
180
|
+
status: task.status,
|
|
181
|
+
route: task.route ?? null,
|
|
182
|
+
effort: task.effort ?? null,
|
|
183
|
+
effortSource: task.effortSource ?? null,
|
|
184
|
+
...(task.effortWaiver ? { effortWaiver: task.effortWaiver } : {}),
|
|
185
|
+
...(task.kind ? { kind: task.kind } : {}),
|
|
186
|
+
...(task.model ? { model: task.model } : {}),
|
|
187
|
+
...(task.modelSource ? { modelSource: task.modelSource } : {}),
|
|
188
|
+
...(task.designWaiver ? { designWaiver: task.designWaiver } : {}),
|
|
189
|
+
...(task.modelHost ? { modelHost: task.modelHost } : {}),
|
|
190
|
+
...(task.effectiveModel ? { effectiveModel: task.effectiveModel } : {}),
|
|
191
|
+
})),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// While other tasks run in parallel, an unscoped fingerprint would include their edits. Require a watch set,
|
|
196
|
+
// and keep it inside the task's declared writeScope so sibling edits cannot stale or fail this evidence.
|
|
197
|
+
function assertParallelWatch(state, task, watchPaths) {
|
|
198
|
+
const othersActive = state.tasks.filter((item) => item.status === "active" && item.id !== task?.id);
|
|
199
|
+
if (task ? othersActive.length === 0 : othersActive.length < 2) return;
|
|
200
|
+
if (watchPaths.length === 0) {
|
|
201
|
+
throw new Error(`Other tasks are active (${othersActive.map((item) => item.id).join(", ")}); pass --watch with this task's write scope`);
|
|
202
|
+
}
|
|
203
|
+
if (task?.writeScope) {
|
|
204
|
+
const outside = watchPaths.filter((path) => !task.writeScope.some((scope) => pathContains(scope, path)));
|
|
205
|
+
if (outside.length > 0) throw new Error(`--watch must stay inside task ${task.id} writeScope while other tasks are active: ${outside.join(", ")}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeKinds(kind) {
|
|
210
|
+
const kinds = (Array.isArray(kind) ? kind : [kind]).map((item) => requiredText(item, "evidence kind"));
|
|
211
|
+
if (kinds.length === 0) throw new Error("evidence kind is required");
|
|
212
|
+
return [...new Set(kinds)];
|
|
213
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { workspaceFingerprint } from "./pota-goal-evidence.mjs";
|
|
2
|
+
import { readGoalStateUnlocked, withGoalStateLock, writeGoalStateUnlocked } from "./pota-goal-state.mjs";
|
|
3
|
+
import { terminalGoalStatuses } from "./pota-goal-state-validation.mjs";
|
|
4
|
+
|
|
5
|
+
export async function mutateGoal(root, mutate) {
|
|
6
|
+
return withGoalStateLock(root, async () => {
|
|
7
|
+
const state = await readGoalStateUnlocked(root);
|
|
8
|
+
if (!state) throw new Error("No Pota goal state exists; start a goal first");
|
|
9
|
+
const next = await mutate(structuredClone(state));
|
|
10
|
+
next.revision += 1;
|
|
11
|
+
await writeGoalStateUnlocked({ root, state: next });
|
|
12
|
+
return next;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ensureTaskCanComplete(state, task, root) {
|
|
17
|
+
for (const kind of task.requiredEvidence) ensureCurrentEvidence({ state, root, scope: "task", task, kind });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ensureGoalCanComplete(state, root) {
|
|
21
|
+
const incomplete = state.tasks.filter((task) => task.status !== "completed");
|
|
22
|
+
if (incomplete.length > 0) throw new Error(`Goal cannot be completed while tasks remain: ${incomplete.map((task) => task.id).join(", ")}`);
|
|
23
|
+
const goalReceipts = state.goal.requiredEvidence.map((kind) => ensureCurrentEvidence({ state, root, scope: "goal", kind }));
|
|
24
|
+
// Task freshness was enforced when each task completed. Fresh goal-level evidence recorded after every task
|
|
25
|
+
// receipt supersedes it, so later edits do not force reruns of every earlier task's checks.
|
|
26
|
+
const lastTaskReceipt = state.receipts.findLastIndex((receipt) => receipt.scope === "task");
|
|
27
|
+
const superseded = goalReceipts.length > 0
|
|
28
|
+
&& goalReceipts.every((receipt) => state.receipts.indexOf(receipt) > lastTaskReceipt);
|
|
29
|
+
if (superseded) {
|
|
30
|
+
for (const task of state.tasks) ensureTaskEvidenceExists(state, task);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureTaskEvidenceExists(state, task) {
|
|
37
|
+
for (const kind of task.requiredEvidence) {
|
|
38
|
+
const recorded = state.receipts.some((receipt) => (
|
|
39
|
+
receipt.scope === "task" && receipt.taskId === task.id && receipt.kind === kind
|
|
40
|
+
&& (state.goal.evidencePolicy === "declared" || receipt.source === "tool")
|
|
41
|
+
));
|
|
42
|
+
if (!recorded) throw new Error(`Task ${task.id} cannot be completed without tool-bound evidence record: ${kind}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureCurrentEvidence({ state, root, scope, task, kind }) {
|
|
47
|
+
const candidates = state.receipts.filter((receipt) => (
|
|
48
|
+
receipt.scope === scope && receipt.kind === kind && (scope === "goal" || receipt.taskId === task.id)
|
|
49
|
+
));
|
|
50
|
+
const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
|
|
51
|
+
if (state.goal.evidencePolicy === "declared") {
|
|
52
|
+
if (candidates.length === 0) throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
|
|
53
|
+
return candidates.at(-1);
|
|
54
|
+
}
|
|
55
|
+
const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
|
|
56
|
+
if (toolEvidence.length === 0) throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
|
|
57
|
+
for (const receipt of [...toolEvidence].reverse()) {
|
|
58
|
+
if (isReceiptFresh(root, receipt)) return receipt;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`${subject} has stale evidence record: ${kind}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isReceiptFresh(root, receipt) {
|
|
64
|
+
return receipt.source === "tool" && workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readyTasks(state) {
|
|
68
|
+
if (!state) return [];
|
|
69
|
+
const completed = new Set(state.tasks.filter((task) => task.status === "completed").map((task) => task.id));
|
|
70
|
+
return state.tasks.filter((task) => task.status === "pending" && task.dependsOn.every((id) => completed.has(id)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Keeps the goal moving after a task leaves "active": when nothing is active, activate the first ready task in
|
|
74
|
+
// list order (the sequential behavior); when nothing is active or ready while work remains, block the goal.
|
|
75
|
+
export function advanceGoal(state, preferredBlocker = null, { block = true } = {}) {
|
|
76
|
+
if (state.tasks.some((task) => task.status === "active")) return;
|
|
77
|
+
const next = readyTasks(state)[0];
|
|
78
|
+
if (next) {
|
|
79
|
+
next.status = "active";
|
|
80
|
+
next.updatedAt = new Date().toISOString();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!block || state.tasks.every((task) => task.status === "completed")) return;
|
|
84
|
+
state.goal.status = "blocked";
|
|
85
|
+
state.goal.blocker = preferredBlocker ?? state.tasks.find((task) => task.status === "blocked")?.blocker
|
|
86
|
+
?? "No task is ready: remaining tasks wait on unfinished dependencies";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function assertGoalMutable(state) {
|
|
90
|
+
if (terminalGoalStatuses.has(state.goal.status)) throw new Error(`Cannot change a ${state.goal.status} goal`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function taskById(state, taskId) {
|
|
94
|
+
const task = state.tasks.find((item) => item.id === taskId);
|
|
95
|
+
if (!task) throw new Error(`Unknown task: ${taskId}`);
|
|
96
|
+
return task;
|
|
97
|
+
}
|