@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,281 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { potaHome, worktreeId } from "./pota-paths.mjs";
|
|
4
|
+
import { assertNoSymlinkComponents } from "./pota-safe-fs.mjs";
|
|
5
|
+
import { goalStatePath, readGoalState, readGoalStateUnlocked, withGoalStateLock, writeGoalStateUnlocked } from "./pota-goal-state.mjs";
|
|
6
|
+
import { recordGoalHistoryUnlocked } from "./pota-goal-history.mjs";
|
|
7
|
+
import {
|
|
8
|
+
GOAL_STATE_VERSION, PLAN_ID_MAX_LENGTH, STRONGEST_MODEL_BY_HOST, assertRecoveryReferenceBudget, boundedRequiredText,
|
|
9
|
+
assertCanBeActive, assertEffortRecord, assertModelRecord, assertTaskGraph,
|
|
10
|
+
defaultGoalEvidence, normalizeCriteria, normalizeTask, optionalText, requiredText,
|
|
11
|
+
terminalGoalStatuses, timestamp, validEvidencePolicy, validGoalStatus, validModelHost, validTaskKind, validTaskRoute, validTaskStatus, validateState,
|
|
12
|
+
} from "./pota-goal-state-validation.mjs";
|
|
13
|
+
import {
|
|
14
|
+
advanceGoal, assertGoalMutable, ensureGoalCanComplete, ensureTaskCanComplete,
|
|
15
|
+
mutateGoal, taskById,
|
|
16
|
+
} from "./pota-goal-operations-mutations.mjs";
|
|
17
|
+
|
|
18
|
+
const goalTransitions = {
|
|
19
|
+
active: new Set(["paused", "blocked", "cancelled", "completed"]),
|
|
20
|
+
paused: new Set(["active", "blocked", "cancelled"]),
|
|
21
|
+
blocked: new Set(["active", "paused", "cancelled"]),
|
|
22
|
+
cancelled: new Set(),
|
|
23
|
+
completed: new Set(),
|
|
24
|
+
};
|
|
25
|
+
const taskTransitions = {
|
|
26
|
+
pending: new Set(["active", "blocked"]),
|
|
27
|
+
active: new Set(["pending", "blocked", "completed"]),
|
|
28
|
+
blocked: new Set(["pending", "active"]),
|
|
29
|
+
completed: new Set(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
|
|
33
|
+
const normalizedGoal = requiredText(goal, "goal");
|
|
34
|
+
const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
|
|
35
|
+
if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
|
|
36
|
+
return withGoalStateLock(root, async () => {
|
|
37
|
+
let existing = await readGoalStateUnlocked(root);
|
|
38
|
+
const currentWorktreeId = worktreeId(root);
|
|
39
|
+
if (existing && existing.goal.worktreeId !== currentWorktreeId) {
|
|
40
|
+
const path = goalStatePath(root);
|
|
41
|
+
await assertNoSymlinkComponents(potaHome(), path, { allowMissing: false });
|
|
42
|
+
await rm(path);
|
|
43
|
+
existing = null;
|
|
44
|
+
}
|
|
45
|
+
if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
|
|
46
|
+
throw new Error(`An unfinished Pota goal already exists (${existing.goal.id}, ${existing.goal.status}); add the new work to it with goal replan (resume it first if paused or blocked), or cancel or complete it before starting another goal`);
|
|
47
|
+
}
|
|
48
|
+
// A terminal state persisted by an older version is about to be overwritten; history failure never blocks start.
|
|
49
|
+
if (existing) await recordGoalHistoryUnlocked(root, existing, { recordedBy: "start", endedAt: existing.goal.updatedAt });
|
|
50
|
+
const now = timestamp();
|
|
51
|
+
const normalizedTasks = normalizeTaskList(tasks, []);
|
|
52
|
+
assertTaskGraph(normalizedTasks);
|
|
53
|
+
for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
|
|
54
|
+
const state = {
|
|
55
|
+
schemaVersion: GOAL_STATE_VERSION,
|
|
56
|
+
revision: 1,
|
|
57
|
+
goal: {
|
|
58
|
+
id: `goal-${randomUUID()}`,
|
|
59
|
+
title: normalizedGoal,
|
|
60
|
+
planId: normalizedPlanId,
|
|
61
|
+
status: "active",
|
|
62
|
+
completionCriteria: normalizeCriteria(completionCriteria),
|
|
63
|
+
requiredEvidence: normalizeCriteria(requiredEvidence),
|
|
64
|
+
evidencePolicy: validEvidencePolicy(evidencePolicy),
|
|
65
|
+
worktreeId: currentWorktreeId,
|
|
66
|
+
ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
|
|
67
|
+
blocker: null,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
},
|
|
71
|
+
tasks: normalizedTasks,
|
|
72
|
+
receipts: [],
|
|
73
|
+
};
|
|
74
|
+
advanceGoal(state);
|
|
75
|
+
await writeGoalStateUnlocked({ root, state });
|
|
76
|
+
return state;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function reconcileWorktreeState({ root = process.cwd() } = {}) {
|
|
81
|
+
const initial = await readGoalState({ root });
|
|
82
|
+
if (!initial || initial.goal.worktreeId === worktreeId(root)) return { state: initial, removed: false };
|
|
83
|
+
return withGoalStateLock(root, async () => {
|
|
84
|
+
const state = await readGoalStateUnlocked(root);
|
|
85
|
+
if (!state || state.goal.worktreeId === worktreeId(root)) return { state, removed: false };
|
|
86
|
+
const staleWorktreeId = state.goal.worktreeId;
|
|
87
|
+
const path = goalStatePath(root);
|
|
88
|
+
await assertNoSymlinkComponents(potaHome(), path, { allowMissing: false });
|
|
89
|
+
await rm(path);
|
|
90
|
+
return { state: null, removed: true, staleWorktreeId };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// onTerminal(record, { historyError }) runs after a terminal transition succeeds; a history failure never blocks it.
|
|
95
|
+
export async function transitionGoal({ root = process.cwd(), status, blocker, onTerminal = () => {} } = {}) {
|
|
96
|
+
const nextStatus = validGoalStatus(status);
|
|
97
|
+
if (terminalGoalStatuses.has(nextStatus)) {
|
|
98
|
+
const { next, history } = await withGoalStateLock(root, async () => {
|
|
99
|
+
const state = await readGoalStateUnlocked(root);
|
|
100
|
+
if (!state) throw new Error("No Pota goal state exists; start a goal first");
|
|
101
|
+
const next = applyGoalTransition(structuredClone(state), nextStatus, blocker, root);
|
|
102
|
+
next.revision += 1;
|
|
103
|
+
validateState(next);
|
|
104
|
+
// Recorded before deletion; a retry after a crash replaces the record for the same goalId.
|
|
105
|
+
const history = await recordGoalHistoryUnlocked(root, next, { recordedBy: "transition" });
|
|
106
|
+
const path = goalStatePath(root);
|
|
107
|
+
await assertNoSymlinkComponents(potaHome(), path, { allowMissing: false });
|
|
108
|
+
await rm(path);
|
|
109
|
+
return { next, history };
|
|
110
|
+
});
|
|
111
|
+
await onTerminal(history.record, { historyError: history.error });
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
return mutateGoal(root, (state) => applyGoalTransition(state, nextStatus, blocker, root));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applyGoalTransition(state, nextStatus, blocker, root) {
|
|
118
|
+
const currentStatus = state.goal.status;
|
|
119
|
+
if (currentStatus === nextStatus) return state;
|
|
120
|
+
if (!goalTransitions[currentStatus].has(nextStatus)) throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
|
|
121
|
+
if (nextStatus === "completed") ensureGoalCanComplete(state, root);
|
|
122
|
+
if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
|
|
123
|
+
if (nextStatus === "active") state.goal.blocker = null;
|
|
124
|
+
state.goal.status = nextStatus;
|
|
125
|
+
state.goal.updatedAt = timestamp();
|
|
126
|
+
if (nextStatus === "active" && currentStatus === "blocked") advanceGoal(state, null, { block: false });
|
|
127
|
+
return state;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker, effortWaiver, designWaiver } = {}) {
|
|
131
|
+
const nextStatus = validTaskStatus(status);
|
|
132
|
+
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
133
|
+
const normalizedWaiver = optionalText(effortWaiver, "effortWaiver");
|
|
134
|
+
if (normalizedWaiver !== null && nextStatus !== "completed") throw new Error("--allow-inherited-effort applies only when completing a task");
|
|
135
|
+
const normalizedDesignWaiver = optionalText(designWaiver, "designWaiver");
|
|
136
|
+
if (normalizedDesignWaiver !== null && nextStatus !== "completed") throw new Error("--allow-no-design-model applies only when completing a task");
|
|
137
|
+
return mutateGoal(root, (state) => {
|
|
138
|
+
assertGoalMutable(state);
|
|
139
|
+
const task = taskById(state, normalizedTaskId);
|
|
140
|
+
if (task.status === nextStatus) return state;
|
|
141
|
+
if (!taskTransitions[task.status].has(nextStatus)) throw new Error(`Cannot transition task ${task.id} from ${task.status} to ${nextStatus}`);
|
|
142
|
+
if (nextStatus === "active") {
|
|
143
|
+
assertCanBeActive(state.tasks, task, state.tasks.filter((item) => item.id !== task.id && item.status === "active"));
|
|
144
|
+
}
|
|
145
|
+
if (nextStatus === "completed") {
|
|
146
|
+
if (normalizedWaiver !== null) task.effortWaiver = normalizedWaiver;
|
|
147
|
+
if (normalizedDesignWaiver !== null) task.designWaiver = normalizedDesignWaiver;
|
|
148
|
+
ensureHeavyEffortApplied(task);
|
|
149
|
+
ensureHeavyDesignRecorded(state, task);
|
|
150
|
+
ensureTaskCanComplete(state, task, root);
|
|
151
|
+
}
|
|
152
|
+
if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
|
|
153
|
+
if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
|
|
154
|
+
task.status = nextStatus;
|
|
155
|
+
task.updatedAt = timestamp();
|
|
156
|
+
if (nextStatus === "completed" || nextStatus === "blocked") advanceGoal(state, nextStatus === "blocked" ? task.blocker : null);
|
|
157
|
+
state.goal.updatedAt = timestamp();
|
|
158
|
+
return state;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function replanGoal({ root = process.cwd(), tasks } = {}) {
|
|
163
|
+
if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("replan tasks must contain at least one incomplete task");
|
|
164
|
+
return mutateGoal(root, (state) => {
|
|
165
|
+
assertGoalMutable(state);
|
|
166
|
+
if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
|
|
167
|
+
const completedTasks = state.tasks.filter((task) => task.status === "completed");
|
|
168
|
+
const completedIds = new Set(completedTasks.map((task) => task.id));
|
|
169
|
+
const replacementTasks = normalizeTaskList(
|
|
170
|
+
tasks.map((task) => (typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined })),
|
|
171
|
+
completedTasks,
|
|
172
|
+
);
|
|
173
|
+
for (const task of replacementTasks) {
|
|
174
|
+
if (completedIds.has(task.id)) throw new Error(`Replanned task conflicts with completed task id: ${task.id}`);
|
|
175
|
+
}
|
|
176
|
+
state.tasks = [...completedTasks, ...replacementTasks];
|
|
177
|
+
assertTaskGraph(state.tasks);
|
|
178
|
+
state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
|
|
179
|
+
state.goal.blocker = null;
|
|
180
|
+
state.goal.updatedAt = timestamp();
|
|
181
|
+
advanceGoal(state);
|
|
182
|
+
return state;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Normalizes tasks as pending; an omitted dependsOn follows the previous task across completed + new tasks.
|
|
187
|
+
function normalizeTaskList(tasks, precedingTasks) {
|
|
188
|
+
let previousId = precedingTasks.at(-1)?.id ?? null;
|
|
189
|
+
const normalized = tasks.map((task, index) => {
|
|
190
|
+
const result = normalizeTask(task, index, "pending", previousId);
|
|
191
|
+
previousId = result.id;
|
|
192
|
+
return result;
|
|
193
|
+
});
|
|
194
|
+
return normalized;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function claimGoal({ root = process.cwd(), sessionId } = {}) {
|
|
198
|
+
const normalizedSessionId = requiredText(sessionId, "sessionId");
|
|
199
|
+
return mutateGoal(root, (state) => {
|
|
200
|
+
assertGoalMutable(state);
|
|
201
|
+
state.goal.ownerSessionId = optionalText(normalizedSessionId, "ownerSessionId");
|
|
202
|
+
state.goal.updatedAt = timestamp();
|
|
203
|
+
return state;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A Heavy task delegated to a worker must record the effort the host actually applied; a missing, not-applied, or
|
|
208
|
+
// inherited effort needs an explicit waiver reason. Unassigned (main-agent) and Light tasks are not checked.
|
|
209
|
+
function ensureHeavyEffortApplied(task) {
|
|
210
|
+
if (task.route !== "heavy" || !task.assignedAgentId || task.effortWaiver) return;
|
|
211
|
+
const source = task.effortSource ?? null;
|
|
212
|
+
if (task.effort && source !== "not-applied" && source !== "inherited") return;
|
|
213
|
+
throw new Error(`Heavy task ${task.id} assigned to ${task.assignedAgentId} has no applied effort (effort ${task.effort ?? "none"}, source ${source ?? "none"}); record goal assign ${task.id} --effort <low|medium|high|xhigh|max> --effort-source host-option, or complete with --allow-inherited-effort <reason>`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A kind "design" task must record the strongest-tier model it was dispatched with (host-option). Any other assigned
|
|
217
|
+
// Heavy task needs a completed design task in the goal. A designWaiver reason bypasses both; Light and unassigned
|
|
218
|
+
// tasks are not checked.
|
|
219
|
+
function ensureHeavyDesignRecorded(state, task) {
|
|
220
|
+
if (task.designWaiver) return;
|
|
221
|
+
if (task.kind === "design") {
|
|
222
|
+
if (!(task.model && task.modelSource === "host-option")) {
|
|
223
|
+
throw new Error(`Design task ${task.id} has no recorded design model (model ${task.model ?? "none"}, source ${task.modelSource ?? "none"}); dispatch it to the strongest tier and record goal assign ${task.id} --model <value> --model-source host-option, or complete with --allow-no-design-model <reason>`);
|
|
224
|
+
}
|
|
225
|
+
// The host is declared, never guessed; without it any supported host's strongest value passes.
|
|
226
|
+
const expected = task.modelHost ? [STRONGEST_MODEL_BY_HOST[task.modelHost]] : Object.values(STRONGEST_MODEL_BY_HOST);
|
|
227
|
+
if (expected.includes(task.model)) return;
|
|
228
|
+
throw new Error(`Design task ${task.id} recorded model ${task.model}, expected ${expected.join(" | ")}${task.modelHost ? ` for host ${task.modelHost}` : ""}; dispatch it to the strongest tier and record goal assign ${task.id} --model <value> --model-source host-option, or complete with --allow-no-design-model <reason>`);
|
|
229
|
+
}
|
|
230
|
+
if (task.route !== "heavy" || !task.assignedAgentId) return;
|
|
231
|
+
if (state.tasks.some((item) => item.kind === "design" && item.status === "completed")) return;
|
|
232
|
+
throw new Error(`Heavy task ${task.id} assigned to ${task.assignedAgentId} has no completed design task; add a kind "design" task with goal replan, complete it with goal assign <design-task> --model <value> --model-source host-option, or complete with --allow-no-design-model <reason>`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Omitted effort/route/waiver options keep stored values, so a reassignment can change only the agent. When effort
|
|
236
|
+
// or effortSource is given, both are replaced together; an omitted source is inferred from the effort.
|
|
237
|
+
export async function assignTask({
|
|
238
|
+
root = process.cwd(), taskId, agentId = null, effort, effortSource, route, effortWaiver, kind, model, modelSource, designWaiver, effectiveModel, modelHost,
|
|
239
|
+
} = {}) {
|
|
240
|
+
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
241
|
+
const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
|
|
242
|
+
const effortGiven = effort !== undefined || effortSource !== undefined;
|
|
243
|
+
const normalizedEffort = effort ?? null;
|
|
244
|
+
const normalizedSource = effortSource ?? (normalizedEffort === null ? null : normalizedEffort === "inherited" ? "inherited" : "host-option");
|
|
245
|
+
if (effortGiven) {
|
|
246
|
+
if (normalizedEffort === null && normalizedSource !== "not-applied") throw new Error("--effort-source requires --effort unless it is not-applied");
|
|
247
|
+
assertEffortRecord(normalizedEffort, normalizedSource, "assign");
|
|
248
|
+
}
|
|
249
|
+
const modelGiven = model !== undefined || modelSource !== undefined;
|
|
250
|
+
const normalizedModel = model === undefined || model === null ? null : requiredText(model, "model");
|
|
251
|
+
const normalizedModelSource = modelSource ?? (normalizedModel === null ? null : normalizedModel === "inherited" ? "inherited" : "host-option");
|
|
252
|
+
if (modelGiven) assertModelRecord(normalizedModel, normalizedModelSource, "assign");
|
|
253
|
+
const normalizedKind = kind === undefined ? undefined : validTaskKind(kind);
|
|
254
|
+
const normalizedDesignWaiver = designWaiver === undefined ? undefined : requiredText(designWaiver, "designWaiver");
|
|
255
|
+
const normalizedEffectiveModel = effectiveModel === undefined ? undefined : requiredText(effectiveModel, "effectiveModel");
|
|
256
|
+
const normalizedModelHost = modelHost === undefined ? undefined : validModelHost(modelHost);
|
|
257
|
+
const normalizedRoute = route === undefined ? undefined : validTaskRoute(route);
|
|
258
|
+
const normalizedWaiver = effortWaiver === undefined ? undefined : requiredText(effortWaiver, "effortWaiver");
|
|
259
|
+
return mutateGoal(root, (state) => {
|
|
260
|
+
assertGoalMutable(state);
|
|
261
|
+
const task = taskById(state, normalizedTaskId);
|
|
262
|
+
task.assignedAgentId = normalizedAgentId;
|
|
263
|
+
if (effortGiven) {
|
|
264
|
+
task.effort = normalizedEffort;
|
|
265
|
+
task.effortSource = normalizedSource;
|
|
266
|
+
}
|
|
267
|
+
if (modelGiven) {
|
|
268
|
+
task.model = normalizedModel;
|
|
269
|
+
task.modelSource = normalizedModelSource;
|
|
270
|
+
}
|
|
271
|
+
if (normalizedKind !== undefined) task.kind = normalizedKind;
|
|
272
|
+
if (normalizedDesignWaiver !== undefined) task.designWaiver = normalizedDesignWaiver;
|
|
273
|
+
if (normalizedEffectiveModel !== undefined) task.effectiveModel = normalizedEffectiveModel;
|
|
274
|
+
if (normalizedModelHost !== undefined) task.modelHost = normalizedModelHost;
|
|
275
|
+
if (normalizedRoute !== undefined) task.route = normalizedRoute;
|
|
276
|
+
if (normalizedWaiver !== undefined) task.effortWaiver = normalizedWaiver;
|
|
277
|
+
task.updatedAt = timestamp();
|
|
278
|
+
state.goal.updatedAt = timestamp();
|
|
279
|
+
return state;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readyTasks } from "./pota-goal-operations-mutations.mjs";
|
|
2
|
+
|
|
3
|
+
export function currentTask(state) {
|
|
4
|
+
if (!state) return null;
|
|
5
|
+
return state.tasks.find((task) => task.status === "active")
|
|
6
|
+
?? readyTasks(state)[0]
|
|
7
|
+
?? state.tasks.find((task) => task.status === "pending")
|
|
8
|
+
?? state.tasks.find((task) => task.status === "blocked")
|
|
9
|
+
?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isGoalOwnedBySession(state, sessionId) {
|
|
13
|
+
if (!state?.goal?.ownerSessionId) return true;
|
|
14
|
+
// A payload without a session id cannot prove another owner; treating it as foreign would silently halt the goal.
|
|
15
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return true;
|
|
16
|
+
return state.goal.ownerSessionId === sessionId;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function evaluateStop(state, { subagent = false, agentId } = {}) {
|
|
20
|
+
if (!state || state.goal.status !== "active") return { block: false };
|
|
21
|
+
const task = currentTask(state);
|
|
22
|
+
if (!task) {
|
|
23
|
+
return {
|
|
24
|
+
block: true,
|
|
25
|
+
reason: `Pota goal "${state.goal.title}" has no incomplete task, but is still active. Verify completion criteria and explicitly mark the goal complete.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (subagent) {
|
|
29
|
+
// Parallel workers each own a task; gate on the stopping agent's own unfinished task, not the first active one.
|
|
30
|
+
const assigned = !agentId ? null
|
|
31
|
+
: state.tasks.find((item) => item.status === "active" && item.assignedAgentId === agentId)
|
|
32
|
+
?? (task.assignedAgentId === agentId ? task : null);
|
|
33
|
+
if (!assigned) return { block: false };
|
|
34
|
+
return {
|
|
35
|
+
block: true,
|
|
36
|
+
reason: `Pota task ${assigned.id} (${assigned.title}) is assigned to this subagent and is ${assigned.status}. Continue only this assigned task, record a successful evidence record, then hand off.${effortWarning([assigned])}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const active = state.tasks.filter((item) => item.status === "active");
|
|
40
|
+
if (active.length > 1) {
|
|
41
|
+
const list = active.map((item) => `${item.id} (${clip(item.title, 40)})`).join(", ");
|
|
42
|
+
return {
|
|
43
|
+
block: true,
|
|
44
|
+
reason: `Pota goal "${state.goal.title}" is active with parallel tasks ${list}. Collect or continue each one; record successful evidence before marking a task complete.${effortWarning(active)}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
block: true,
|
|
49
|
+
reason: `Pota goal "${state.goal.title}" is active. Continue current task ${task.id} (${task.title}); its status is ${task.status}. Record a successful evidence record before marking it complete.${effortWarning(active)}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Delegated work must record the effort the host applied; flag assigned tasks whose effort source is still empty.
|
|
54
|
+
function effortWarning(tasks) {
|
|
55
|
+
const ids = tasks.filter((item) => item.assignedAgentId && !item.effortSource).map((item) => item.id);
|
|
56
|
+
return ids.length === 0 ? "" : ` Effort unrecorded for assigned task ${ids.join(", ")}: record goal assign --effort/--effort-source.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clip(value, maxLength) {
|
|
60
|
+
const text = String(value ?? "");
|
|
61
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
62
|
+
}
|