@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,361 @@
1
+ import { normalizeVerificationArgv } from "./pota-goal-evidence.mjs";
2
+
3
+ export const GOAL_STATE_VERSION = 3;
4
+ export const MAX_ACTIVE_TASKS = 4;
5
+ export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
6
+ export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
7
+ export const PLAN_ID_MAX_LENGTH = 128;
8
+ export const TASK_ID_MAX_LENGTH = 64;
9
+ export const RECOVERY_REFERENCE_MAX_LENGTH = 160;
10
+ export const GOAL_STATE_CONTRACT = Object.freeze({
11
+ completionCriteria: Object.freeze({ behavior: "advisory", enforced: false }),
12
+ terminal: Object.freeze({
13
+ statuses: Object.freeze(["cancelled", "completed"]),
14
+ persistence: "deleted",
15
+ readAfterTransition: null,
16
+ transitionResult: "terminal-snapshot",
17
+ }),
18
+ });
19
+ export const terminalGoalStatuses = new Set(["cancelled", "completed"]);
20
+ export const defaultTaskEvidence = ["moduleAcceptance", "surfaceIntegration"];
21
+ export const defaultGoalEvidence = ["finalReview"];
22
+
23
+ const evidencePolicies = new Set(["declared", "tool-bound"]);
24
+ export const TASK_ROUTES = new Set(["light", "heavy"]);
25
+ export const CONCRETE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
26
+ export const TASK_EFFORTS = new Set([...CONCRETE_EFFORTS, "inherited"]);
27
+ export const EFFORT_SOURCES = new Set(["host-option", "inherited", "not-applied"]);
28
+ export const TASK_KINDS = new Set(["design"]);
29
+ // The host's strongest tier is the value passed at dispatch; mirrors the strongest column in skills/pota-plan/delegation.md.
30
+ export const STRONGEST_MODEL_BY_HOST = Object.freeze({ "claude-code": "fable", codex: "gpt-6-astra", antigravity: "pro" });
31
+
32
+ export function validateState(value) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Pota goal state must be an object");
34
+ if (value.schemaVersion !== GOAL_STATE_VERSION) throw new Error(`Unsupported Pota goal state schema version: ${value.schemaVersion}`);
35
+ if (!Number.isSafeInteger(value.revision) || value.revision < 1) throw new Error("Pota goal state revision must be a positive integer");
36
+ if (!value.goal || typeof value.goal !== "object") throw new Error("Pota goal state is missing goal");
37
+ const goal = value.goal;
38
+ requiredText(goal.id, "goal.id");
39
+ requiredText(goal.title, "goal.title");
40
+ const planId = boundedRequiredText(goal.planId, "goal.planId", PLAN_ID_MAX_LENGTH);
41
+ validGoalStatus(goal.status);
42
+ validEvidencePolicy(goal.evidencePolicy);
43
+ validTimestamp(goal.createdAt);
44
+ validTimestamp(goal.updatedAt);
45
+ if (!Array.isArray(goal.completionCriteria)) throw new Error("goal.completionCriteria must be an array");
46
+ if (!Array.isArray(goal.requiredEvidence) || goal.requiredEvidence.length === 0) throw new Error("goal.requiredEvidence must be a non-empty array");
47
+ goal.requiredEvidence.forEach((kind, index) => requiredText(kind, `goal.requiredEvidence[${index}]`));
48
+ if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
49
+ if (goal.worktreeId !== null && goal.worktreeId !== undefined) requiredText(goal.worktreeId, "goal.worktreeId");
50
+ if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
51
+ if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Pota goal state must contain tasks");
52
+ if (!Array.isArray(value.receipts)) throw new Error("Pota goal state evidence records (receipts) must be an array");
53
+ const ids = new Set();
54
+ for (const task of value.tasks) {
55
+ if (!task || typeof task !== "object") throw new Error("Each task must be an object");
56
+ const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
57
+ assertRecoveryReferenceBudget(planId, taskId);
58
+ if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
59
+ ids.add(task.id);
60
+ requiredText(task.title, "task.title");
61
+ validTaskStatus(task.status);
62
+ if (!Array.isArray(task.dependsOn)) throw new Error(`task ${task.id} dependsOn must be an array`);
63
+ if (task.writeScope !== null && !Array.isArray(task.writeScope)) throw new Error(`task ${task.id} writeScope must be an array or null`);
64
+ if (task.assignedAgentId !== null && task.assignedAgentId !== undefined) requiredText(task.assignedAgentId, "task.assignedAgentId");
65
+ if (!Array.isArray(task.requiredEvidence) || task.requiredEvidence.length === 0) throw new Error(`task ${task.id} requiredEvidence must be a non-empty array`);
66
+ task.requiredEvidence.forEach((kind, index) => requiredText(kind, `task ${task.id} requiredEvidence[${index}]`));
67
+ if (task.blocker !== null && task.blocker !== undefined) requiredText(task.blocker, `task ${task.id} blocker`);
68
+ // Optional since v3 states predate them; absent means unrecorded.
69
+ if (task.route !== null && task.route !== undefined) validTaskRoute(task.route, `task ${task.id} route`);
70
+ assertEffortRecord(task.effort ?? null, task.effortSource ?? null, `task ${task.id}`);
71
+ if (task.effortWaiver !== null && task.effortWaiver !== undefined) requiredText(task.effortWaiver, `task ${task.id} effortWaiver`);
72
+ if (task.kind !== null && task.kind !== undefined) validTaskKind(task.kind, `task ${task.id} kind`);
73
+ assertModelRecord(task.model ?? null, task.modelSource ?? null, `task ${task.id}`);
74
+ if (task.designWaiver !== null && task.designWaiver !== undefined) requiredText(task.designWaiver, `task ${task.id} designWaiver`);
75
+ if (task.modelHost !== null && task.modelHost !== undefined) validModelHost(task.modelHost, `task ${task.id} modelHost`);
76
+ if (task.effectiveModel !== null && task.effectiveModel !== undefined) requiredText(task.effectiveModel, `task ${task.id} effectiveModel`);
77
+ validTimestamp(task.createdAt);
78
+ validTimestamp(task.updatedAt);
79
+ }
80
+ assertTaskGraph(value.tasks);
81
+ const active = value.tasks.filter((task) => task.status === "active");
82
+ for (const task of active) assertCanRunAlongside(task, active.filter((other) => other !== task));
83
+ for (const receipt of value.receipts) validateReceipt(receipt, value);
84
+ return value;
85
+ }
86
+
87
+ function validateReceipt(receipt, state) {
88
+ if (!receipt || typeof receipt !== "object") throw new Error("Each evidence record must be an object");
89
+ requiredText(receipt.id, "evidence.id");
90
+ const scope = validReceiptScope(receipt.scope);
91
+ requiredText(receipt.kind, "evidence.kind");
92
+ if (scope === "task") {
93
+ requiredText(receipt.taskId, "evidence.taskId");
94
+ if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Evidence record references unknown task: ${receipt.taskId}`);
95
+ } else if (receipt.taskId !== undefined && receipt.taskId !== null) {
96
+ throw new Error("goal-scope evidence record must not contain taskId");
97
+ }
98
+ if (receipt.goalId !== state.goal.id) throw new Error("evidence goalId does not match goal");
99
+ if (receipt.planId !== state.goal.planId) throw new Error("evidence planId does not match goal");
100
+ validReceiptCommand(receipt.command);
101
+ if (receipt.exitCode !== 0) throw new Error("evidence exitCode must be 0");
102
+ validTimestamp(receipt.timestamp);
103
+ if (validReceiptSource(receipt.source) === "tool") {
104
+ normalizeToolEvidence({ argv: receipt.argv, outputDigest: receipt.outputDigest, summary: receipt.summary, fingerprint: receipt.workspaceFingerprint, watchPaths: receipt.watchPaths });
105
+ }
106
+ }
107
+
108
+ // dependsOn omitted means "after the previous task in list order" so existing sequential plans keep their order;
109
+ // an explicit [] means no dependency.
110
+ export function normalizeTask(task, index, defaultStatus, previousTaskId = null) {
111
+ const source = typeof task === "string" ? { title: task } : task;
112
+ if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
113
+ const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
114
+ const title = requiredText(source.title, `tasks[${index}].title`);
115
+ return {
116
+ id,
117
+ title,
118
+ status: source.status === undefined ? defaultStatus : validTaskStatus(source.status),
119
+ assignedAgentId: source.assignedAgentId === undefined || source.assignedAgentId === null ? null : requiredText(source.assignedAgentId, `tasks[${index}].assignedAgentId`),
120
+ requiredEvidence: normalizeCriteria(source.requiredEvidence ?? defaultTaskEvidence),
121
+ dependsOn: source.dependsOn === undefined
122
+ ? (previousTaskId === null ? [] : [previousTaskId])
123
+ : normalizeDependsOn(source.dependsOn, `tasks[${index}].dependsOn`),
124
+ writeScope: source.writeScope === undefined || source.writeScope === null
125
+ ? null
126
+ : normalizeWriteScope(source.writeScope, `tasks[${index}].writeScope`),
127
+ route: source.route === undefined || source.route === null ? null : validTaskRoute(source.route, `tasks[${index}].route`),
128
+ effort: null,
129
+ effortSource: null,
130
+ effortWaiver: null,
131
+ kind: source.kind === undefined || source.kind === null ? null : validTaskKind(source.kind, `tasks[${index}].kind`),
132
+ model: null,
133
+ modelSource: null,
134
+ designWaiver: null,
135
+ modelHost: null,
136
+ effectiveModel: null,
137
+ blocker: null,
138
+ createdAt: timestamp(),
139
+ updatedAt: timestamp(),
140
+ };
141
+ }
142
+
143
+ export function validTaskRoute(value, label = "route") {
144
+ if (!TASK_ROUTES.has(value)) throw new Error(`${label} must be one of: ${[...TASK_ROUTES].join(", ")}`);
145
+ return value;
146
+ }
147
+
148
+ export function validModelHost(value, label = "modelHost") {
149
+ if (!Object.hasOwn(STRONGEST_MODEL_BY_HOST, value)) throw new Error(`${label} must be one of: ${Object.keys(STRONGEST_MODEL_BY_HOST).join(", ")}`);
150
+ return value;
151
+ }
152
+
153
+ export function validTaskKind(value, label = "kind") {
154
+ if (!TASK_KINDS.has(value)) throw new Error(`${label} must be one of: ${[...TASK_KINDS].join(", ")}`);
155
+ return value;
156
+ }
157
+
158
+ // A model is a non-empty host-specific string; source "host-option" needs a model, and a model needs a source.
159
+ export function assertModelRecord(model, source, label = "task") {
160
+ if (model !== null) requiredText(model, `${label} model`);
161
+ if (source !== null && !EFFORT_SOURCES.has(source)) throw new Error(`${label} modelSource must be one of: ${[...EFFORT_SOURCES].join(", ")}`);
162
+ if (source === "host-option" && model === null) throw new Error(`${label} modelSource host-option requires a model`);
163
+ if (source === "inherited" && model === null) throw new Error(`${label} modelSource inherited requires a model`);
164
+ if (model !== null && source === null) throw new Error(`${label} model requires a modelSource`);
165
+ }
166
+
167
+ // effort "inherited" pairs only with source "inherited"; source "host-option" needs a concrete level; a source
168
+ // without an effort is only meaningful as "not-applied".
169
+ export function assertEffortRecord(effort, source, label = "task") {
170
+ if (effort !== null && !TASK_EFFORTS.has(effort)) throw new Error(`${label} effort must be one of: ${[...TASK_EFFORTS].join(", ")}`);
171
+ if (source !== null && !EFFORT_SOURCES.has(source)) throw new Error(`${label} effortSource must be one of: ${[...EFFORT_SOURCES].join(", ")}`);
172
+ if (effort === "inherited" && source !== "inherited") throw new Error(`${label} effort inherited requires effortSource inherited`);
173
+ if (source === "host-option" && !CONCRETE_EFFORTS.has(effort)) throw new Error(`${label} effortSource host-option requires a concrete effort (${[...CONCRETE_EFFORTS].join(", ")})`);
174
+ if (source === "inherited" && effort === null) throw new Error(`${label} effortSource inherited requires an effort`);
175
+ if (effort !== null && source === null) throw new Error(`${label} effort requires an effortSource`);
176
+ }
177
+
178
+ export function migrateState(value) {
179
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
180
+ let state = value;
181
+ if (state.schemaVersion === 1) {
182
+ state = {
183
+ ...state,
184
+ schemaVersion: 2,
185
+ goal: { ...state.goal, evidencePolicy: state.goal?.evidencePolicy ?? "tool-bound" },
186
+ receipts: Array.isArray(state.receipts) ? state.receipts.map((receipt) => ({ ...receipt, source: "declared" })) : state.receipts,
187
+ };
188
+ }
189
+ if (state.schemaVersion === 2) {
190
+ // v2 tasks ran strictly in list order; make that order explicit as previous-task dependencies.
191
+ state = {
192
+ ...state,
193
+ schemaVersion: 3,
194
+ tasks: Array.isArray(state.tasks)
195
+ ? state.tasks.map((task, index) => ({
196
+ ...task,
197
+ dependsOn: index === 0 ? [] : [state.tasks[index - 1]?.id],
198
+ writeScope: null,
199
+ }))
200
+ : state.tasks,
201
+ };
202
+ }
203
+ return state;
204
+ }
205
+
206
+ export function normalizeDependsOn(value, label) {
207
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array of task ids`);
208
+ return [...new Set(value.map((item, index) => boundedRequiredText(item, `${label}[${index}]`, TASK_ID_MAX_LENGTH)))];
209
+ }
210
+
211
+ export function normalizeWriteScope(value, label) {
212
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} writeScope must be a non-empty array of repository paths`);
213
+ return [...new Set(value.map((item, index) => {
214
+ const path = requiredText(item, `${label}[${index}]`).replace(/\\/g, "/").replace(/\/{2,}/g, "/")
215
+ .replace(/^(\.\/)+/, "").replace(/\/+$/, "");
216
+ if (/[*?[\]{}]/.test(path)) throw new Error(`${label} writeScope must list literal paths, not globs: ${item}`);
217
+ if (path === "" || path === "." || path.startsWith("/") || /^[a-zA-Z]:/.test(path) || path.split("/").includes("..")) {
218
+ throw new Error(`${label} writeScope must stay inside the repository: ${item}`);
219
+ }
220
+ return path;
221
+ }))];
222
+ }
223
+
224
+ // Segment-wise prefix match, case-insensitive so case-insensitive filesystems cannot hide an overlap.
225
+ export function writeScopesOverlap(left, right) {
226
+ return left.some((a) => right.some((b) => pathContains(a, b) || pathContains(b, a)));
227
+ }
228
+
229
+ export function pathContains(scope, path) {
230
+ const outer = scope.toLowerCase();
231
+ const inner = path.toLowerCase();
232
+ return inner === outer || inner.startsWith(`${outer}/`);
233
+ }
234
+
235
+ export function assertTaskGraph(tasks) {
236
+ const byId = new Map();
237
+ for (const task of tasks) {
238
+ if (byId.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
239
+ byId.set(task.id, task);
240
+ }
241
+ for (const task of tasks) {
242
+ for (const dependency of task.dependsOn) {
243
+ if (dependency === task.id) throw new Error(`Task ${task.id} cannot depend on itself`);
244
+ if (!byId.has(dependency)) throw new Error(`Task ${task.id} depends on unknown task: ${dependency}`);
245
+ }
246
+ }
247
+ const visiting = new Set();
248
+ const done = new Set();
249
+ const visit = (task, path) => {
250
+ if (done.has(task.id)) return;
251
+ if (visiting.has(task.id)) throw new Error(`Task dependency cycle: ${[...path, task.id].join(" -> ")}`);
252
+ visiting.add(task.id);
253
+ for (const dependency of task.dependsOn) visit(byId.get(dependency), [...path, task.id]);
254
+ visiting.delete(task.id);
255
+ done.add(task.id);
256
+ };
257
+ for (const task of tasks) visit(task, []);
258
+ }
259
+
260
+ // Throws unless task may be active next to otherActive: dependencies completed, under the cap, and disjoint
261
+ // writeScopes. A task without writeScope may only run alone.
262
+ export function assertCanBeActive(tasks, task, otherActive) {
263
+ const waiting = task.dependsOn.filter((id) => tasks.find((item) => item.id === id)?.status !== "completed");
264
+ if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for: ${waiting.join(", ")}`);
265
+ assertCanRunAlongside(task, otherActive);
266
+ }
267
+
268
+ function assertCanRunAlongside(task, otherActive) {
269
+ if (otherActive.length === 0) return;
270
+ if (otherActive.length + 1 > MAX_ACTIVE_TASKS) throw new Error(`Only at most ${MAX_ACTIVE_TASKS} active tasks are allowed`);
271
+ if (task.writeScope === null) throw new Error(`Task ${task.id} without writeScope cannot run alongside active task ${otherActive[0].id}`);
272
+ for (const other of otherActive) {
273
+ if (other.writeScope === null) throw new Error(`Task ${task.id} cannot run alongside active task ${other.id} without writeScope`);
274
+ if (writeScopesOverlap(task.writeScope, other.writeScope)) throw new Error(`Task ${task.id} writeScope overlaps active task ${other.id}`);
275
+ }
276
+ }
277
+
278
+ export function requiredText(value, label) {
279
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
280
+ return value.trim();
281
+ }
282
+
283
+ export function boundedRequiredText(value, label, maxLength) {
284
+ const text = requiredText(value, label);
285
+ if (value !== text) throw new Error(`${label} must not have leading or trailing whitespace`);
286
+ if (text.length > maxLength) throw new Error(`${label} must be at most ${maxLength} characters`);
287
+ return text;
288
+ }
289
+
290
+ export function assertRecoveryReferenceBudget(planId, taskId) {
291
+ if (planId.length + taskId.length > RECOVERY_REFERENCE_MAX_LENGTH) throw new Error(`planId and taskId must total at most ${RECOVERY_REFERENCE_MAX_LENGTH} characters for exact recovery context`);
292
+ }
293
+
294
+ export function validGoalStatus(value) {
295
+ if (!GOAL_STATUSES.has(value)) throw new Error(`Invalid goal status: ${value}`);
296
+ return value;
297
+ }
298
+
299
+ export function validEvidencePolicy(value) {
300
+ if (!evidencePolicies.has(value)) throw new Error(`Invalid evidence policy: ${value}`);
301
+ return value;
302
+ }
303
+
304
+ export function validTaskStatus(value) {
305
+ if (!TASK_STATUSES.has(value)) throw new Error(`Invalid task status: ${value}`);
306
+ return value;
307
+ }
308
+
309
+ export function validReceiptScope(value) {
310
+ if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value} (allowed: task, goal)`);
311
+ return value;
312
+ }
313
+
314
+ export function validReceiptSource(value) {
315
+ if (value !== "declared" && value !== "tool") throw new Error(`Invalid evidence source: ${value}`);
316
+ return value;
317
+ }
318
+
319
+ export function validReceiptCommand(value) {
320
+ const command = requiredText(value, "evidence command");
321
+ if (/\b(skip(?:ped)?|todo|tbd|n\/?a|not[ -]?run|not[ -]?applicable|placeholder)\b/i.test(command)) throw new Error("evidence command cannot be skipped, placeholder, or incomplete");
322
+ return command;
323
+ }
324
+
325
+ export function validTimestamp(value) {
326
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) throw new Error("timestamp must be an ISO-8601 date string");
327
+ return value;
328
+ }
329
+
330
+ export function normalizeCriteria(criteria) {
331
+ if (!Array.isArray(criteria)) throw new Error("completionCriteria must be an array");
332
+ return criteria.map((item, index) => requiredText(item, `completionCriteria[${index}]`));
333
+ }
334
+
335
+ export function normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) {
336
+ const normalizedArgv = normalizeVerificationArgv(argv);
337
+ const normalizedOutputDigest = validSha256(outputDigest, "evidence outputDigest");
338
+ const normalizedSummary = requiredText(summary, "evidence summary");
339
+ const normalizedFingerprint = validSha256(fingerprint, "evidence workspaceFingerprint");
340
+ if (!Array.isArray(watchPaths)) throw new Error("evidence watchPaths must be an array");
341
+ return {
342
+ argv: normalizedArgv,
343
+ outputDigest: normalizedOutputDigest,
344
+ summary: normalizedSummary,
345
+ workspaceFingerprint: normalizedFingerprint,
346
+ watchPaths: watchPaths.map((item, index) => requiredText(item, `evidence watchPaths[${index}]`)),
347
+ };
348
+ }
349
+
350
+ function validSha256(value, label) {
351
+ if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`${label} must be a sha256 digest`);
352
+ return value;
353
+ }
354
+
355
+ export function optionalText(value, label) {
356
+ return value === undefined || value === null ? null : requiredText(value, label);
357
+ }
358
+
359
+ export function timestamp() {
360
+ return new Date().toISOString();
361
+ }
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, readFile, rename, rm } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { potaHome, goalStateDirectory } from "./pota-paths.mjs";
5
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./pota-safe-fs.mjs";
6
+ import { migrateState, validateState } from "./pota-goal-state-validation.mjs";
7
+
8
+ export function goalStatePath(root = process.cwd()) {
9
+ return join(goalStateDirectory(root), "state.json");
10
+ }
11
+
12
+ export async function readGoalState({ root = process.cwd() } = {}) {
13
+ const state = await readCurrentGoalState(root);
14
+ if (state) return state;
15
+ // Read-only callers (hooks) must not create user-home state directories when there is nothing to migrate.
16
+ if (!(await legacyGoalStateExists(root))) return null;
17
+ return withGoalStateLock(root, () => readGoalStateUnlocked(root));
18
+ }
19
+
20
+ export function stopGuardPath(root = process.cwd()) {
21
+ return hookGuardPath(root, "stop");
22
+ }
23
+
24
+ export function hookGuardPath(root = process.cwd(), name) {
25
+ return join(goalStateDirectory(root), `${name}-guard.json`);
26
+ }
27
+
28
+ export async function readStopGuard(root = process.cwd()) {
29
+ return readHookGuard(root, "stop");
30
+ }
31
+
32
+ export async function writeStopGuard(root = process.cwd(), value) {
33
+ return writeHookGuard(root, "stop", value);
34
+ }
35
+
36
+ export async function readHookGuard(root = process.cwd(), name) {
37
+ try {
38
+ const path = hookGuardPath(root, name);
39
+ await assertNoSymlinkComponents(potaHome(), path);
40
+ const value = JSON.parse(await readFile(path, "utf8"));
41
+ return value && typeof value === "object" ? value : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function writeHookGuard(root = process.cwd(), name, value) {
48
+ try {
49
+ await safeWriteFile(potaHome(), hookGuardPath(root, name), `${JSON.stringify(value)}\n`, "utf8");
50
+ } catch {
51
+ // Guards only deduplicate hook output; failing to persist one must not break the hook.
52
+ }
53
+ }
54
+
55
+ async function legacyGoalStateExists(root) {
56
+ try {
57
+ await lstat(join(resolve(root), ".pota", "state.json"));
58
+ return true;
59
+ } catch (error) {
60
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return false;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export async function writeGoalState({ root = process.cwd(), state } = {}) {
66
+ return withGoalStateLock(root, () => writeGoalStateUnlocked({ root, state }));
67
+ }
68
+
69
+ export async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
70
+ const validated = validateState(state);
71
+ const path = goalStatePath(root);
72
+ await ensureGoalStateDirectory(root);
73
+ await assertNoSymlinkComponents(potaHome(), path);
74
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
75
+ try {
76
+ await safeWriteFile(potaHome(), temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
77
+ await rename(temporaryPath, path);
78
+ } catch (error) {
79
+ await rm(temporaryPath, { force: true }).catch(() => {});
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ export async function readGoalStateUnlocked(root) {
85
+ return await readCurrentGoalState(root) ?? migrateLegacyGoalState(root);
86
+ }
87
+
88
+ async function readCurrentGoalState(root) {
89
+ try {
90
+ const path = goalStatePath(root);
91
+ await assertNoSymlinkComponents(potaHome(), path);
92
+ return validateState(migrateState(JSON.parse(await readFile(path, "utf8"))));
93
+ } catch (error) {
94
+ if (error?.code === "ENOENT") return null;
95
+ if (error instanceof SyntaxError) throw new Error(`Pota goal state is invalid JSON: ${error.message}`);
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ async function migrateLegacyGoalState(root) {
101
+ const legacyPath = join(resolve(root), ".pota", "state.json");
102
+ try {
103
+ await assertNoSymlinkComponents(root, legacyPath, { allowMissing: false });
104
+ const state = validateState(migrateState(JSON.parse(await readFile(legacyPath, "utf8"))));
105
+ await writeGoalStateUnlocked({ root, state });
106
+ await rm(legacyPath);
107
+ return state;
108
+ } catch (error) {
109
+ if (error?.code === "ENOENT") return null;
110
+ if (error instanceof SyntaxError) throw new Error(`Pota goal state is invalid JSON: ${error.message}`);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ export async function withGoalStateLock(root, operation) {
116
+ await ensureGoalStateDirectory(root);
117
+ return withStateLock(goalStateDirectory(root), operation);
118
+ }
119
+
120
+ async function ensureGoalStateDirectory(root) {
121
+ const home = potaHome();
122
+ let existingAncestor = home;
123
+ while (true) {
124
+ try {
125
+ const stat = await lstat(existingAncestor);
126
+ if (!stat.isDirectory()) throw new Error(`Pota home ancestor is not a directory: ${existingAncestor}`);
127
+ break;
128
+ } catch (error) {
129
+ if (error?.code !== "ENOENT") throw error;
130
+ const parent = dirname(existingAncestor);
131
+ if (parent === existingAncestor) throw error;
132
+ existingAncestor = parent;
133
+ }
134
+ }
135
+ await safeMkdir(existingAncestor, relative(existingAncestor, goalStateDirectory(root)));
136
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { runGoalCli } from "./pota-goal-cli.mjs";
5
+ import { assignTask, claimGoal, replanGoal, setTaskStatus, startGoal, transitionGoal } from "./pota-goal-operations.mjs";
6
+ import { finishGoal, goalStats, recordReceipt, verifyAndRecord } from "./pota-goal-operations-evidence.mjs";
7
+ import { readGoalState } from "./pota-goal-state.mjs";
8
+ import { goalHistoryStats } from "./pota-goal-history.mjs";
9
+ import { readyTasks } from "./pota-goal-operations-mutations.mjs";
10
+ import { defaultGoalEvidence } from "./pota-goal-state-validation.mjs";
11
+
12
+ export { workspaceFingerprint } from "./pota-goal-evidence.mjs";
13
+ export { worktreeId } from "./pota-paths.mjs";
14
+ export {
15
+ GOAL_STATE_CONTRACT,
16
+ GOAL_STATE_VERSION,
17
+ GOAL_STATUSES,
18
+ PLAN_ID_MAX_LENGTH,
19
+ RECOVERY_REFERENCE_MAX_LENGTH,
20
+ TASK_ID_MAX_LENGTH,
21
+ TASK_STATUSES,
22
+ } from "./pota-goal-state-validation.mjs";
23
+ export { goalStatePath, readGoalState, writeGoalState } from "./pota-goal-state.mjs";
24
+ export { GOAL_HISTORY_MAX_RECORDS, goalHistoryPath, goalHistoryStats, memoryPromotionStatus } from "./pota-goal-history.mjs";
25
+ export { assignTask, claimGoal, reconcileWorktreeState, replanGoal, setTaskStatus, startGoal, transitionGoal } from "./pota-goal-operations.mjs";
26
+ export { finishGoal, goalStats, recordReceipt, verifyAndRecord } from "./pota-goal-operations-evidence.mjs";
27
+ export { currentTask, evaluateStop, isGoalOwnedBySession } from "./pota-goal-policy.mjs";
28
+ export { readyTasks } from "./pota-goal-operations-mutations.mjs";
29
+
30
+ export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log, stderr = console.error } = {}) {
31
+ return runGoalCli(args, {
32
+ stdout,
33
+ stderr,
34
+ defaultGoalEvidence,
35
+ operations: {
36
+ assignTask,
37
+ claimGoal,
38
+ finishGoal,
39
+ goalHistoryStats,
40
+ goalStats,
41
+ readGoalState,
42
+ readyTasks,
43
+ recordReceipt,
44
+ replanGoal,
45
+ setTaskStatus,
46
+ startGoal,
47
+ transitionGoal,
48
+ verifyAndRecord,
49
+ },
50
+ });
51
+ }
52
+
53
+ function isCliEntry() {
54
+ if (!process.argv[1]) return false;
55
+ try {
56
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
57
+ } catch {
58
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
59
+ }
60
+ }
61
+
62
+ if (isCliEntry()) {
63
+ try {
64
+ await handleGoalCli();
65
+ } catch (error) {
66
+ console.error(`Pota goal error: ${error.message}`);
67
+ process.exitCode = 1;
68
+ }
69
+ }