@xzzpig/pi-openspec-x 0.1.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/index.ts +1 -0
- package/package.json +55 -0
- package/src/agent-dir.ts +48 -0
- package/src/agent-prompts.ts +115 -0
- package/src/agents.ts +262 -0
- package/src/approval-gate.ts +139 -0
- package/src/cli.ts +442 -0
- package/src/command-util.ts +33 -0
- package/src/config.ts +89 -0
- package/src/dependencies.ts +445 -0
- package/src/discover.ts +112 -0
- package/src/dispatch-gate.ts +257 -0
- package/src/final-gate.ts +158 -0
- package/src/flow-entries.ts +258 -0
- package/src/flow-recovery.ts +227 -0
- package/src/goal-base.ts +339 -0
- package/src/implement-command.ts +1151 -0
- package/src/index.ts +173 -0
- package/src/lifecycle.ts +156 -0
- package/src/mode.ts +285 -0
- package/src/objective.ts +246 -0
- package/src/plan-command.ts +285 -0
- package/src/plan-flow.ts +133 -0
- package/src/plan-gate.ts +279 -0
- package/src/plan-review.ts +452 -0
- package/src/progress-observer.ts +78 -0
- package/src/progress-projection.ts +118 -0
- package/src/report-tools.ts +317 -0
- package/src/review-scope.ts +172 -0
- package/src/rollback-command.ts +241 -0
- package/src/sandbox-profiles.ts +73 -0
- package/src/skills.ts +386 -0
- package/src/static-conflict.ts +89 -0
- package/src/task-sync.ts +226 -0
- package/src/tasks-md.ts +136 -0
- package/src/worker-dispatch.ts +152 -0
package/src/task-sync.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks.md ⇄ goal task tree reconciliation (design D10; openspec change
|
|
3
|
+
* add-pi-openspec-x, task 6.3).
|
|
4
|
+
*
|
|
5
|
+
* `tasks.md` is the openspec contract and the authoritative task source; the
|
|
6
|
+
* goal task tree is the execution base's state. Synchronization is
|
|
7
|
+
* ONE-DIRECTIONAL: the assembler mirrors tasks.md through `set_goal_tasks`,
|
|
8
|
+
* the main agent ticks tasks.md after review and follows with
|
|
9
|
+
* `update_goal_task`, and a goal-tree-only change (a manual edit in the
|
|
10
|
+
* dashboard) never flows back into tasks.md.
|
|
11
|
+
*
|
|
12
|
+
* This module is the pure reconciliation core: it compares the parsed
|
|
13
|
+
* tasks.md against the current goal tree, reports every divergence with
|
|
14
|
+
* tasks.md as the winner, and produces the two payloads the caller needs —
|
|
15
|
+
* the authoritative flat mirror for `set_goal_tasks` and the ordered
|
|
16
|
+
* `update_goal_task` follow-ups that align task status.
|
|
17
|
+
*
|
|
18
|
+
* Two goal states cannot be reconciled automatically because goal-x forbids
|
|
19
|
+
* them: a completed task is immutable, so a goal task that is `complete` while
|
|
20
|
+
* tasks.md says it is not done cannot be reopened. That divergence is surfaced
|
|
21
|
+
* as a blocking warning instead of a silent half-sync.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
assembleOpsxObjective,
|
|
25
|
+
type FlatGoalTaskInput,
|
|
26
|
+
type OpsxBoundaries,
|
|
27
|
+
} from "./objective.ts";
|
|
28
|
+
import { goalStatusForTask, parseTasksMarkdown } from "./tasks-md.ts";
|
|
29
|
+
|
|
30
|
+
/** Minimal structural view of goal-x's GoalTask (no runtime import). */
|
|
31
|
+
export interface GoalTaskLike {
|
|
32
|
+
id: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
/** pending | complete | skipped (goal-x has no distinct "start" status). */
|
|
35
|
+
status?: string;
|
|
36
|
+
subtasks?: GoalTaskLike[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One ordered `update_goal_task` follow-up. */
|
|
40
|
+
export interface TaskSyncUpdate {
|
|
41
|
+
taskId: string;
|
|
42
|
+
status: "pending" | "complete";
|
|
43
|
+
/** True when completing a contracted task needs evidence. */
|
|
44
|
+
requiresEvidence: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TaskSyncPlan {
|
|
48
|
+
/** True when ids, titles and done status all agree. */
|
|
49
|
+
consistent: boolean;
|
|
50
|
+
/** The authoritative flat mirror to pass to `set_goal_tasks`. */
|
|
51
|
+
mirror: FlatGoalTaskInput[];
|
|
52
|
+
/** Ordered follow-ups to align the goal tree's status with tasks.md. */
|
|
53
|
+
updates: TaskSyncUpdate[];
|
|
54
|
+
/** tasks.md ids missing from the goal tree (the mirror adds them). */
|
|
55
|
+
missingGoalIds: string[];
|
|
56
|
+
/** Goal-tree ids with no tasks.md counterpart (the mirror removes them). */
|
|
57
|
+
goalOnlyIds: string[];
|
|
58
|
+
/** Same id, different title (the mirror rewrites the title). */
|
|
59
|
+
titleMismatches: string[];
|
|
60
|
+
/** Same id, different done state (an update aligns it, or a warning blocks). */
|
|
61
|
+
statusMismatches: string[];
|
|
62
|
+
/** Divergences that cannot be reconciled automatically. */
|
|
63
|
+
blockingIds: string[];
|
|
64
|
+
/** Human-readable warnings; empty when consistent. */
|
|
65
|
+
warnings: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Flatten a goal task tree into roots-first document order. */
|
|
69
|
+
export function flattenGoalTasks(
|
|
70
|
+
tasks: readonly GoalTaskLike[] | undefined,
|
|
71
|
+
): GoalTaskLike[] {
|
|
72
|
+
const flat: GoalTaskLike[] = [];
|
|
73
|
+
const walk = (list: readonly GoalTaskLike[]): void => {
|
|
74
|
+
for (const task of list) {
|
|
75
|
+
flat.push(task);
|
|
76
|
+
if (task.subtasks && task.subtasks.length > 0) walk(task.subtasks);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
if (tasks) walk(tasks);
|
|
80
|
+
return flat;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reconcile tasks.md against the current goal task tree. tasks.md always wins:
|
|
85
|
+
* a goal-only id is removed by the mirror, a missing id is added, a title
|
|
86
|
+
* mismatch is rewritten, and a status mismatch is corrected by the emitted
|
|
87
|
+
* updates (unless goal-x forbids it, which is reported as blocking).
|
|
88
|
+
*/
|
|
89
|
+
export function planTaskSync(input: {
|
|
90
|
+
tasksMarkdown: string | undefined;
|
|
91
|
+
goalTasks: readonly GoalTaskLike[] | undefined;
|
|
92
|
+
}): TaskSyncPlan {
|
|
93
|
+
const tasks = input.tasksMarkdown
|
|
94
|
+
? parseTasksMarkdown(input.tasksMarkdown)
|
|
95
|
+
: [];
|
|
96
|
+
const goalFlat = flattenGoalTasks(input.goalTasks);
|
|
97
|
+
const goalById = new Map(goalFlat.map((task) => [task.id, task]));
|
|
98
|
+
const taskIds = new Set(tasks.map((task) => task.id));
|
|
99
|
+
|
|
100
|
+
const mirror = tasks.map((task) => ({
|
|
101
|
+
id: task.id,
|
|
102
|
+
title: task.title,
|
|
103
|
+
...(task.verificationContract
|
|
104
|
+
? { verification_contract: task.verificationContract }
|
|
105
|
+
: {}),
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const missingGoalIds: string[] = [];
|
|
109
|
+
const titleMismatches: string[] = [];
|
|
110
|
+
const statusMismatches: string[] = [];
|
|
111
|
+
const blockingIds: string[] = [];
|
|
112
|
+
const updates: TaskSyncUpdate[] = [];
|
|
113
|
+
|
|
114
|
+
for (const task of tasks) {
|
|
115
|
+
const goalTask = goalById.get(task.id);
|
|
116
|
+
if (!goalTask) {
|
|
117
|
+
missingGoalIds.push(task.id);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (goalTask.title !== undefined && goalTask.title !== task.title) {
|
|
121
|
+
titleMismatches.push(task.id);
|
|
122
|
+
}
|
|
123
|
+
const expected = goalStatusForTask(task);
|
|
124
|
+
const actual = goalTask.status ?? "pending";
|
|
125
|
+
if (actual === expected) continue;
|
|
126
|
+
statusMismatches.push(task.id);
|
|
127
|
+
if (actual === "complete") {
|
|
128
|
+
// goal-x: completed tasks are immutable, so tasks.md cannot win here.
|
|
129
|
+
blockingIds.push(task.id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (actual === "skipped" && expected === "complete") {
|
|
133
|
+
// skipped cannot complete directly; reopen, then complete.
|
|
134
|
+
updates.push({
|
|
135
|
+
taskId: task.id,
|
|
136
|
+
status: "pending",
|
|
137
|
+
requiresEvidence: false,
|
|
138
|
+
});
|
|
139
|
+
updates.push({
|
|
140
|
+
taskId: task.id,
|
|
141
|
+
status: "complete",
|
|
142
|
+
requiresEvidence: Boolean(task.verificationContract),
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
updates.push({
|
|
147
|
+
taskId: task.id,
|
|
148
|
+
status: expected,
|
|
149
|
+
requiresEvidence:
|
|
150
|
+
expected === "complete" && Boolean(task.verificationContract),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const goalOnlyIds = goalFlat
|
|
155
|
+
.filter((task) => !taskIds.has(task.id))
|
|
156
|
+
.map((task) => task.id);
|
|
157
|
+
|
|
158
|
+
const warnings: string[] = [];
|
|
159
|
+
if (missingGoalIds.length > 0) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
`tasks.md has ${missingGoalIds.length} task(s) missing from the goal tree (${missingGoalIds.join(", ")}); tasks.md is authoritative, so re-mirroring adds them.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (goalOnlyIds.length > 0) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
`The goal tree has ${goalOnlyIds.length} task(s) that tasks.md does not (${goalOnlyIds.join(", ")}); goal-tree-only edits never flow back, so re-mirroring removes them.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (titleMismatches.length > 0) {
|
|
170
|
+
warnings.push(
|
|
171
|
+
`Task title(s) differ from tasks.md (${titleMismatches.join(", ")}); tasks.md wins and re-mirroring rewrites them.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (statusMismatches.length > 0) {
|
|
175
|
+
warnings.push(
|
|
176
|
+
`Task status differs from tasks.md (${statusMismatches.join(", ")}); tasks.md wins and update_goal_task follows to align it.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (blockingIds.length > 0) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`Task(s) ${blockingIds.join(", ")} are complete in the goal tree but not done in tasks.md; goal-x never reopens a completed task, so this cannot be auto-reconciled — fix tasks.md or start a fresh goal.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
consistent:
|
|
187
|
+
missingGoalIds.length === 0 &&
|
|
188
|
+
goalOnlyIds.length === 0 &&
|
|
189
|
+
titleMismatches.length === 0 &&
|
|
190
|
+
statusMismatches.length === 0,
|
|
191
|
+
mirror,
|
|
192
|
+
updates,
|
|
193
|
+
missingGoalIds,
|
|
194
|
+
goalOnlyIds,
|
|
195
|
+
titleMismatches,
|
|
196
|
+
statusMismatches,
|
|
197
|
+
blockingIds,
|
|
198
|
+
warnings,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Assemble the objective and derive the authoritative mirror in one step. The
|
|
204
|
+
* task-count gate stays in the assembler (a change over the `set_goal_tasks`
|
|
205
|
+
* limit is refused there, never mirrored partially).
|
|
206
|
+
*/
|
|
207
|
+
export function assembleAndPlanSync(input: {
|
|
208
|
+
changeId: string;
|
|
209
|
+
tasksMarkdown: string | undefined;
|
|
210
|
+
boundaries: OpsxBoundaries;
|
|
211
|
+
goalTasks: readonly GoalTaskLike[] | undefined;
|
|
212
|
+
}): {
|
|
213
|
+
assembly: ReturnType<typeof assembleOpsxObjective>;
|
|
214
|
+
sync: TaskSyncPlan;
|
|
215
|
+
} {
|
|
216
|
+
const assembly = assembleOpsxObjective({
|
|
217
|
+
changeId: input.changeId,
|
|
218
|
+
tasksMarkdown: input.tasksMarkdown,
|
|
219
|
+
boundaries: input.boundaries,
|
|
220
|
+
});
|
|
221
|
+
const sync = planTaskSync({
|
|
222
|
+
tasksMarkdown: input.tasksMarkdown,
|
|
223
|
+
goalTasks: input.goalTasks,
|
|
224
|
+
});
|
|
225
|
+
return { assembly, sync };
|
|
226
|
+
}
|
package/src/tasks-md.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for openspec's `tasks.md` tracking format (design D10; openspec
|
|
3
|
+
* change add-pi-openspec-x, task 6.2).
|
|
4
|
+
*
|
|
5
|
+
* openspec's apply phase tracks progress by parsing checkbox lines, and its
|
|
6
|
+
* instruction is explicit about the rule: a box whose content trims to `x`
|
|
7
|
+
* (any case, any spacing) is done; every other box marker (`- [~]`, `- [-]`,
|
|
8
|
+
* an empty `- []`, ...) reads as unfinished; a line with no checkbox is not
|
|
9
|
+
* tracked at all. This parser implements exactly that rule, so the plugin's
|
|
10
|
+
* mirror of the task list can never disagree with `openspec apply` about what
|
|
11
|
+
* is done.
|
|
12
|
+
*
|
|
13
|
+
* The authoritative source is `tasks.md`: this module only reads it. Nothing
|
|
14
|
+
* here writes, and no goal-x type leaks in — the goal task tree is built by
|
|
15
|
+
* ./objective.ts from the parsed tasks.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One tracked task parsed out of tasks.md. */
|
|
19
|
+
export interface OpsxTask {
|
|
20
|
+
/** The `X.Y` identifier openspec puts in front of the description. */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Task description with the id and any explicit verification suffix removed. */
|
|
23
|
+
title: string;
|
|
24
|
+
/** True when the checkbox content trims to `x` (openspec's done rule). */
|
|
25
|
+
done: boolean;
|
|
26
|
+
/** Nearest `## ...` group heading, when the task sits under one. */
|
|
27
|
+
section?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The verification requirement, when the description carries an explicit
|
|
30
|
+
* `验证:` / `Verification:` marker. openspec asks authors to state the
|
|
31
|
+
* verification inside the description; when it is marked off, the goal task
|
|
32
|
+
* carries it as its verification contract so the completion gate can demand
|
|
33
|
+
* evidence.
|
|
34
|
+
*/
|
|
35
|
+
verificationContract?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A checkbox list item. The `(?:\s+(.*))?` tail is deliberate: a markdown
|
|
40
|
+
* link (`- [text](url)`) has no whitespace after `]`, so it never parses as a
|
|
41
|
+
* task.
|
|
42
|
+
*/
|
|
43
|
+
const CHECKBOX_RE = /^(\s*)[-*]\s+\[([^\]]*)\](?:\s+(.*))?\s*$/;
|
|
44
|
+
|
|
45
|
+
/** A group heading: `## 1. Setup`, `### 2.3 ...` (any depth). */
|
|
46
|
+
const GROUP_HEADING_RE = /^#{1,6}\s+(.*\S)\s*$/;
|
|
47
|
+
|
|
48
|
+
/** The `X.Y` id prefix openspec writes: `1.1`, `2.3.4`. */
|
|
49
|
+
const ID_PREFIX_RE = /^(\d+(?:\.\d+)*)[.)]?\s+(.*)$/;
|
|
50
|
+
|
|
51
|
+
/** Explicit verification marker (Chinese or English, colon required). */
|
|
52
|
+
const VERIFICATION_MARKER_RE = /(?:验证|Verification|Verify)\s*[::]/i;
|
|
53
|
+
|
|
54
|
+
function splitVerification(text: string): {
|
|
55
|
+
title: string;
|
|
56
|
+
verificationContract?: string;
|
|
57
|
+
} {
|
|
58
|
+
const match = VERIFICATION_MARKER_RE.exec(text);
|
|
59
|
+
if (!match || match.index === 0) return { title: text.trim() };
|
|
60
|
+
const title = text
|
|
61
|
+
.slice(0, match.index)
|
|
62
|
+
.replace(/[;;]\s*$/, "")
|
|
63
|
+
.trim();
|
|
64
|
+
const contract = text.slice(match.index + match[0].length).trim();
|
|
65
|
+
if (!title || !contract) return { title: text.trim() };
|
|
66
|
+
return { title, verificationContract: contract };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseTaskDescription(
|
|
70
|
+
description: string,
|
|
71
|
+
index: number,
|
|
72
|
+
): { id: string; title: string; verificationContract?: string } {
|
|
73
|
+
// The id prefix is extracted first: the verification marker may follow it
|
|
74
|
+
// directly (`1.1 验证:...`), which would otherwise hide the id.
|
|
75
|
+
const idMatch = ID_PREFIX_RE.exec(description);
|
|
76
|
+
const id = idMatch ? idMatch[1]! : `task-${index + 1}`;
|
|
77
|
+
const rest = (idMatch ? idMatch[2]! : description).trim();
|
|
78
|
+
const { title, verificationContract } = splitVerification(rest);
|
|
79
|
+
return { id, title, verificationContract };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse tasks.md into the ordered tracked task list. Lines that are not
|
|
84
|
+
* checkboxes (prose, headings, blank lines) are ignored, matching openspec's
|
|
85
|
+
* "a line with no checkbox is not tracked at all" rule. Checkboxes with an
|
|
86
|
+
* empty description are skipped too: they track nothing a goal task could
|
|
87
|
+
* mirror.
|
|
88
|
+
*/
|
|
89
|
+
export function parseTasksMarkdown(markdown: string): OpsxTask[] {
|
|
90
|
+
const tasks: OpsxTask[] = [];
|
|
91
|
+
let section: string | undefined;
|
|
92
|
+
for (const raw of markdown.replace(/\r\n?/g, "\n").split("\n")) {
|
|
93
|
+
const heading = GROUP_HEADING_RE.exec(raw);
|
|
94
|
+
if (heading) {
|
|
95
|
+
section = heading[1]!.trim();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const checkbox = CHECKBOX_RE.exec(raw);
|
|
99
|
+
if (!checkbox) continue;
|
|
100
|
+
const description = (checkbox[3] ?? "").trim();
|
|
101
|
+
if (!description) continue;
|
|
102
|
+
const parsed = parseTaskDescription(description, tasks.length);
|
|
103
|
+
tasks.push({
|
|
104
|
+
id: parsed.id,
|
|
105
|
+
title: parsed.title,
|
|
106
|
+
done: checkbox[2]!.trim().toLowerCase() === "x",
|
|
107
|
+
...(section ? { section } : {}),
|
|
108
|
+
...(parsed.verificationContract
|
|
109
|
+
? { verificationContract: parsed.verificationContract }
|
|
110
|
+
: {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return tasks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* openspec's done rule as a goal task status: the mirror uses the binary
|
|
118
|
+
* pending/complete mapping only (design D10; no `start`/`skipped` middle
|
|
119
|
+
* states ever cross the boundary).
|
|
120
|
+
*/
|
|
121
|
+
export function goalStatusForTask(
|
|
122
|
+
task: Pick<OpsxTask, "done">,
|
|
123
|
+
): "pending" | "complete" {
|
|
124
|
+
return task.done ? "complete" : "pending";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Ids of every parsed task, in order; used for duplicate detection. */
|
|
128
|
+
export function duplicateTaskIds(tasks: readonly OpsxTask[]): string[] {
|
|
129
|
+
const seen = new Set<string>();
|
|
130
|
+
const duplicates = new Set<string>();
|
|
131
|
+
for (const task of tasks) {
|
|
132
|
+
if (seen.has(task.id)) duplicates.add(task.id);
|
|
133
|
+
else seen.add(task.id);
|
|
134
|
+
}
|
|
135
|
+
return [...duplicates];
|
|
136
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker dispatch contract and post-dispatch review gate (design D10; openspec
|
|
3
|
+
* change add-pi-openspec-x, task 8.2).
|
|
4
|
+
*
|
|
5
|
+
* Agent mode implements every task through a full-write `opsx-worker`
|
|
6
|
+
* dispatch. Two deterministic pieces make that safe:
|
|
7
|
+
*
|
|
8
|
+
* 1. the six-section dispatch contract (Task, Expected output, Tools, Must do,
|
|
9
|
+
* Must not do, Context) — plan files stay read-only for the worker, and
|
|
10
|
+
* ticking tasks.md stays with the main agent;
|
|
11
|
+
* 2. the tick gate: a task may be ticked only after the worker's
|
|
12
|
+
* `report_work` is reconciled, file by file, with the diff the dispatch
|
|
13
|
+
* actually produced, and the stated verification actually ran and passed.
|
|
14
|
+
* An unreviewed or unverified dispatch leaves the task unticked, and a
|
|
15
|
+
* report that claims files nobody changed (or omits files that changed) is
|
|
16
|
+
* a finding, not a completion.
|
|
17
|
+
*
|
|
18
|
+
* The gate returns a decision; the caller performs the tasks.md write and the
|
|
19
|
+
* goal-task sync (task 6.3). Nothing here writes files.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural view of the opsx-worker `report_work` payload (the schema lives
|
|
24
|
+
* in report-tools.ts; only the fields the gate reconciles are named here).
|
|
25
|
+
*/
|
|
26
|
+
export interface WorkReportLike {
|
|
27
|
+
taskId: string;
|
|
28
|
+
changedFiles: string[];
|
|
29
|
+
claims: string;
|
|
30
|
+
selfVerification: { command?: string; result?: string };
|
|
31
|
+
/** Structured verdict; the gate trusts it over the result text. */
|
|
32
|
+
verificationPassed?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The standard prohibitions every worker dispatch carries. The single source
|
|
37
|
+
* the agent objective's prose reuses: plan files stay read-only for the
|
|
38
|
+
* worker, ticking tasks.md stays with the main agent, and drive-by changes
|
|
39
|
+
* are out.
|
|
40
|
+
*/
|
|
41
|
+
export const STANDARD_WORKER_MUST_NOT_DO = [
|
|
42
|
+
"Do not modify anything under openspec/ (tasks.md, proposal.md, specs/, design.md); plan files are read-only for you.",
|
|
43
|
+
"Do not tick tasks.md or any checkbox; the main agent ticks after reviewing your diff.",
|
|
44
|
+
"Do not touch files outside the task scope or make drive-by changes.",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** The review facts the main agent gathered after a dispatch. */
|
|
48
|
+
export interface DispatchReview {
|
|
49
|
+
/** Repository-relative files the dispatch actually changed (window delta). */
|
|
50
|
+
observedChangedFiles: string[];
|
|
51
|
+
/** Whether the stated verification ran, and whether it passed. */
|
|
52
|
+
verification: { ran: boolean; passed: boolean; evidence?: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TaskTickDecision =
|
|
56
|
+
| { kind: "tick"; taskId: string; evidence: string }
|
|
57
|
+
| { kind: "hold"; taskId: string; reason: string };
|
|
58
|
+
|
|
59
|
+
function normalize(filePath: string): string {
|
|
60
|
+
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decide whether a task may be ticked. Holds (never ticks) when:
|
|
65
|
+
* - no report was submitted;
|
|
66
|
+
* - the report claims files the diff does not contain (phantom work);
|
|
67
|
+
* - the diff contains files the report omitted (unclaimed work);
|
|
68
|
+
* - the verification did not run or did not pass.
|
|
69
|
+
*/
|
|
70
|
+
export function decideTaskTick(
|
|
71
|
+
report: WorkReportLike | undefined,
|
|
72
|
+
review: DispatchReview,
|
|
73
|
+
): TaskTickDecision {
|
|
74
|
+
const taskId = report?.taskId ?? "<unknown>";
|
|
75
|
+
if (!report) {
|
|
76
|
+
return {
|
|
77
|
+
kind: "hold",
|
|
78
|
+
taskId,
|
|
79
|
+
reason:
|
|
80
|
+
"No report_work was submitted; review the dispatch output before ticking.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const claimed = new Set(report.changedFiles.map(normalize));
|
|
85
|
+
const observed = new Set(review.observedChangedFiles.map(normalize));
|
|
86
|
+
const phantom = [...claimed].filter((file) => !observed.has(file));
|
|
87
|
+
const unclaimed = [...observed].filter((file) => !claimed.has(file));
|
|
88
|
+
if (phantom.length > 0) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "hold",
|
|
91
|
+
taskId,
|
|
92
|
+
reason: `The report claims file(s) the dispatch did not change: ${phantom.join(", ")}. Reconcile before ticking.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (unclaimed.length > 0) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "hold",
|
|
98
|
+
taskId,
|
|
99
|
+
reason: `The dispatch changed file(s) the report omitted: ${unclaimed.join(", ")}. Review and correct the report before ticking.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!review.verification.ran) {
|
|
104
|
+
return {
|
|
105
|
+
kind: "hold",
|
|
106
|
+
taskId,
|
|
107
|
+
reason:
|
|
108
|
+
"The task's verification has not been run; tick only after it passes.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!review.verification.passed) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "hold",
|
|
114
|
+
taskId,
|
|
115
|
+
reason: `The task's verification failed${review.verification.evidence ? `: ${review.verification.evidence}` : ""}. Fix and re-verify before ticking.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
kind: "tick",
|
|
121
|
+
taskId,
|
|
122
|
+
evidence:
|
|
123
|
+
review.verification.evidence ??
|
|
124
|
+
report.selfVerification.result ??
|
|
125
|
+
"verification passed",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A bounded dispatch trace used to detect a spinning goal: a dispatch that
|
|
131
|
+
* changed nothing and reported nothing is a stall, not progress.
|
|
132
|
+
*/
|
|
133
|
+
export interface DispatchTrace {
|
|
134
|
+
taskId: string;
|
|
135
|
+
changedFiles: number;
|
|
136
|
+
ticked: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function detectStalledDispatch(
|
|
140
|
+
traces: readonly DispatchTrace[],
|
|
141
|
+
threshold = 2,
|
|
142
|
+
): { stalled: boolean; taskIds: string[] } {
|
|
143
|
+
const counts = new Map<string, number>();
|
|
144
|
+
for (const trace of traces) {
|
|
145
|
+
if (trace.ticked || trace.changedFiles > 0) continue;
|
|
146
|
+
counts.set(trace.taskId, (counts.get(trace.taskId) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const taskIds = [...counts.entries()]
|
|
149
|
+
.filter(([, count]) => count >= threshold)
|
|
150
|
+
.map(([taskId]) => taskId);
|
|
151
|
+
return { stalled: taskIds.length > 0, taskIds };
|
|
152
|
+
}
|