@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.
@@ -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
+ }
@@ -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
+ }