opencode-codeops 2.1.1 → 2.2.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,160 @@
1
+ /**
2
+ * Type declarations for the CodeOps RPC helper.
3
+ *
4
+ * The helper is plain JavaScript so `node --test` can exercise it directly;
5
+ * these declarations give the TypeScript plugin entries typed access. The
6
+ * definition carries the status method, the progress method, and the
7
+ * updated/cleared events.
8
+ */
9
+
10
+ import type {
11
+ CodeOpsProgressRuntime,
12
+ CodeOpsRunClearedSchema,
13
+ CodeOpsRunState,
14
+ CodeOpsRunStateSchema,
15
+ } from "./codeops-progress.mjs"
16
+
17
+ /** JSON-Schema shape of the empty input accepted by the `status` method. */
18
+ export type CodeOpsRpcStatusInput = {
19
+ readonly type: "object"
20
+ readonly additionalProperties: false
21
+ }
22
+
23
+ /** JSON-Schema shape of the payload the `status` method returns. */
24
+ export type CodeOpsRpcStatusOutput = {
25
+ readonly type: "object"
26
+ readonly additionalProperties: false
27
+ readonly required: readonly ["pluginVersion", "openCodeVersion", "directory"]
28
+ readonly properties: {
29
+ readonly pluginVersion: { readonly type: "string" }
30
+ readonly openCodeVersion: { readonly type: "string" }
31
+ readonly directory: { readonly type: "string" }
32
+ }
33
+ }
34
+
35
+ /** The status payload after runtime validation. */
36
+ export interface CodeOpsStatus {
37
+ readonly pluginVersion: string
38
+ readonly openCodeVersion: string
39
+ readonly directory: string
40
+ }
41
+
42
+ /** JSON-Schema shape of the empty input accepted by the `progress` method. */
43
+ export type CodeOpsRpcProgressInput = {
44
+ readonly type: "object"
45
+ readonly additionalProperties: false
46
+ }
47
+
48
+ /** JSON-Schema shape of the `progress` method's payload: a snapshot or null. */
49
+ export type CodeOpsRpcProgressOutput = {
50
+ readonly anyOf: readonly [CodeOpsRunStateSchema, { readonly type: "null" }]
51
+ }
52
+
53
+ /** The `updated` event of the codeops RPC: the full run snapshot. */
54
+ export interface CodeOpsRpcUpdatedEvent {
55
+ readonly schema: CodeOpsRunStateSchema
56
+ }
57
+
58
+ /** The `cleared` event of the codeops RPC: the cleared run's identity. */
59
+ export interface CodeOpsRpcClearedEvent {
60
+ readonly schema: CodeOpsRunClearedSchema
61
+ }
62
+
63
+ /** The portable RPC definition shared by the server plugin and the TUI entry. */
64
+ export interface CodeOpsRpcDefinition {
65
+ readonly id: "codeops"
66
+ readonly methods: {
67
+ readonly status: {
68
+ readonly input: CodeOpsRpcStatusInput
69
+ readonly output: CodeOpsRpcStatusOutput
70
+ }
71
+ readonly progress: {
72
+ readonly input: CodeOpsRpcProgressInput
73
+ readonly output: CodeOpsRpcProgressOutput
74
+ }
75
+ }
76
+ readonly events: {
77
+ readonly updated: CodeOpsRpcUpdatedEvent
78
+ readonly cleared: CodeOpsRpcClearedEvent
79
+ }
80
+ }
81
+
82
+ /** The shared definition object; plain data with no runtime imports. */
83
+ export declare const CodeOpsRpc: CodeOpsRpcDefinition
84
+
85
+ /** Options accepted by {@link registerCodeOpsRpc}. */
86
+ export interface RegisterCodeOpsRpcOptions {
87
+ /** Version of the installed package, reported by the status method. */
88
+ pluginVersion?: string
89
+ /** The run-state runtime the progress method serves and binds emission from. */
90
+ runtime?: CodeOpsProgressRuntime
91
+ }
92
+
93
+ /**
94
+ * Feature-detect `ctx.rpc.register`, register the codeops RPC when present,
95
+ * and report success. Never throws.
96
+ */
97
+ export declare function registerCodeOpsRpc(
98
+ ctx: unknown,
99
+ options?: RegisterCodeOpsRpcOptions
100
+ ): Promise<boolean>
101
+
102
+ /** Validate an unknown RPC payload as a complete CodeOps status record. */
103
+ export declare function isCodeOpsStatus(value: unknown): value is CodeOpsStatus
104
+
105
+ /** The location a status call is scoped to. */
106
+ export interface CodeOpsLocation {
107
+ readonly directory: string
108
+ readonly workspaceID?: string
109
+ }
110
+
111
+ /** Call options forwarded to the status call. */
112
+ export interface CodeOpsCallOptions {
113
+ readonly location?: CodeOpsLocation
114
+ }
115
+
116
+ /** Structural client shape {@link requestStatus} consumes. */
117
+ export interface CodeOpsStatusClient {
118
+ readonly rpc: (definition: CodeOpsRpcDefinition) => {
119
+ readonly status: (
120
+ input: Record<string, never>,
121
+ options?: CodeOpsCallOptions
122
+ ) => Promise<unknown>
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Request the raw status payload from a host client, converting every
128
+ * transport failure into `undefined`.
129
+ *
130
+ * Pass the calling session's location in `options`: the host resolves RPC
131
+ * calls against it, and a call without one can miss the plugin's registration
132
+ * when the session's project differs from the host's default location.
133
+ */
134
+ export declare function requestStatus(
135
+ client: CodeOpsStatusClient,
136
+ options?: CodeOpsCallOptions
137
+ ): Promise<unknown>
138
+
139
+ /** Structural client shape {@link requestProgress} consumes. */
140
+ export interface CodeOpsProgressClient {
141
+ readonly rpc: (definition: CodeOpsRpcDefinition) => {
142
+ readonly progress: (
143
+ input: Record<string, never>,
144
+ options?: CodeOpsCallOptions
145
+ ) => Promise<unknown>
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Request the current run snapshot from a host client, converting every
151
+ * transport failure into `null`.
152
+ *
153
+ * Pass the calling session's location in `options`: the host resolves RPC
154
+ * calls against it, and a call without one can miss the plugin's registration
155
+ * when the session's project differs from the host's default location.
156
+ */
157
+ export declare function requestProgress(
158
+ client: CodeOpsProgressClient,
159
+ options?: CodeOpsCallOptions
160
+ ): Promise<CodeOpsRunState | null>
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The CodeOps RPC: definition, registration guard, payload guard, and the
3
+ * progress snapshot helper.
4
+ *
5
+ * The definition is plain data — the SDK's `Rpc.define` performs identity and
6
+ * reserved-name validation only — so this module needs no runtime import of
7
+ * the SDK and `node --test` can exercise it directly. The definition carries
8
+ * the `status` method, the `progress` method that answers with the current run
9
+ * snapshot, and the `updated`/`cleared` events the sidebar follows. The
10
+ * registration guard keeps the surface safe on builds that do not implement
11
+ * custom RPCs: it never throws and reports success with a boolean. When the
12
+ * host returns a registration exposing `events.emit`, the guard binds the
13
+ * runtime's emission through it, so accepted reports push live updates.
14
+ *
15
+ * @module codeops-rpc
16
+ */
17
+
18
+ import { RunClearedSchema, RunStateSchema, isCodeOpsRunState } from "./codeops-progress.mjs"
19
+
20
+ /**
21
+ * The portable `codeops` RPC definition shared by the server plugin and the
22
+ * TUI entry: a `status` method with an empty-object input and a three-string
23
+ * payload, a `progress` method answering with the current run snapshot (or
24
+ * `null`), and the `updated`/`cleared` events that push every accepted change.
25
+ */
26
+ export const CodeOpsRpc = {
27
+ id: "codeops",
28
+ methods: {
29
+ status: {
30
+ input: { type: "object", additionalProperties: false },
31
+ output: {
32
+ type: "object",
33
+ additionalProperties: false,
34
+ required: ["pluginVersion", "openCodeVersion", "directory"],
35
+ properties: {
36
+ pluginVersion: { type: "string" },
37
+ openCodeVersion: { type: "string" },
38
+ directory: { type: "string" },
39
+ },
40
+ },
41
+ },
42
+ progress: {
43
+ input: { type: "object", additionalProperties: false },
44
+ output: { anyOf: [RunStateSchema, { type: "null" }] },
45
+ },
46
+ },
47
+ events: {
48
+ updated: { schema: RunStateSchema },
49
+ cleared: { schema: RunClearedSchema },
50
+ },
51
+ }
52
+
53
+ /**
54
+ * Coerce an optional host value to the string form the payload requires.
55
+ *
56
+ * @param value - Value reported by the host context.
57
+ * @returns The value as a string, or an empty string when it is absent.
58
+ */
59
+ function asText(value) {
60
+ return value === undefined || value === null ? "" : String(value)
61
+ }
62
+
63
+ /**
64
+ * Register the codeops RPC on a plugin context, feature-detected and
65
+ * never-throwing.
66
+ *
67
+ * When a runtime is supplied, the `progress` method answers with its current
68
+ * snapshot and — if the returned registration exposes `events.emit` — the
69
+ * runtime binds its emission through that function, so every accepted report
70
+ * or clear pushes a live event.
71
+ *
72
+ * @param ctx - Server plugin context; only `rpc.register`, `app.version`, and
73
+ * `location.directory` are read.
74
+ * @param {{ pluginVersion?: string, runtime?: object }} [options] -
75
+ * Registration options.
76
+ * @returns `true` when the registration call completed, `false` when the host
77
+ * lacks the RPC API or the registration failed. Never throws.
78
+ */
79
+ export async function registerCodeOpsRpc(ctx, { pluginVersion, runtime } = {}) {
80
+ if (typeof ctx?.rpc?.register !== "function") return false
81
+ try {
82
+ const registration = await ctx.rpc.register(CodeOpsRpc, {
83
+ async status() {
84
+ return {
85
+ pluginVersion: asText(pluginVersion),
86
+ openCodeVersion: asText(ctx?.app?.version),
87
+ directory: asText(ctx?.location?.directory),
88
+ }
89
+ },
90
+ // The host adapter requires handlers to return promises; a synchronous
91
+ // return fails the call. async even though nothing is awaited.
92
+ async progress() {
93
+ return runtime?.snapshot() ?? null
94
+ },
95
+ })
96
+ if (
97
+ runtime &&
98
+ typeof runtime.bindEmit === "function" &&
99
+ typeof registration?.events?.emit === "function"
100
+ ) {
101
+ runtime.bindEmit(registration.events.emit)
102
+ }
103
+ return true
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Validate an unknown RPC payload as a complete CodeOps status record.
111
+ *
112
+ * @param value - Value received from the RPC.
113
+ * @returns `true` only for a plain object carrying exactly the three string
114
+ * fields of the contract.
115
+ */
116
+ export function isCodeOpsStatus(value) {
117
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false
118
+ if (Object.keys(value).length !== 3) return false
119
+ return (
120
+ typeof value.pluginVersion === "string" &&
121
+ typeof value.openCodeVersion === "string" &&
122
+ typeof value.directory === "string"
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Request the codeops status payload from a host client, converting every
128
+ * transport failure into `undefined`.
129
+ *
130
+ * A caller can therefore render from the result without a `catch`: a host
131
+ * without the RPC, a server plugin that failed to register, and a failed call
132
+ * all collapse into the same "no status" outcome. The payload is returned as
133
+ * received; narrow it with {@link isCodeOpsStatus} before use.
134
+ *
135
+ * Pass the calling session's location in `options`. The host resolves RPC
136
+ * calls against a location, and a call without one can miss the plugin's
137
+ * registration — the default location does not necessarily match a project
138
+ * opened through a path argument.
139
+ *
140
+ * @param client - Host client exposing `rpc(definition).status({}, options)`.
141
+ * @param {{ location?: { directory?: string, workspaceID?: string } }} [options]
142
+ * - Call options forwarded to the status call.
143
+ * @returns The raw status payload, or `undefined` when the call fails.
144
+ */
145
+ export async function requestStatus(client, options) {
146
+ try {
147
+ return await client.rpc(CodeOpsRpc).status({}, options)
148
+ } catch {
149
+ return undefined
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Request the current run snapshot from a host client, converting every
155
+ * failure into `null`.
156
+ *
157
+ * Pass the calling session's location in `options`, for the same reason as
158
+ * {@link requestStatus}: the host resolves RPC calls against a location, and
159
+ * a call without one can miss the plugin's registration. The payload is
160
+ * validated before it is returned, so an invalid payload, a "no run" answer,
161
+ * and any transport failure all collapse into `null` and the caller can
162
+ * render without a `catch`.
163
+ *
164
+ * @param client - Host client exposing `rpc(definition).progress({}, options)`.
165
+ * @param {{ location?: { directory?: string, workspaceID?: string } }} [options]
166
+ * - Call options forwarded to the progress call.
167
+ * @returns The validated snapshot, or `null` when no run is active or the
168
+ * call failed. Never throws.
169
+ */
170
+ export async function requestProgress(client, options) {
171
+ try {
172
+ const payload = await client.rpc(CodeOpsRpc).progress({}, options)
173
+ return isCodeOpsRunState(payload) ? payload : null
174
+ } catch {
175
+ return null
176
+ }
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
@@ -10,7 +10,8 @@
10
10
  "main": "./plugin/index.ts",
11
11
  "exports": {
12
12
  ".": "./plugin/index.ts",
13
- "./server": "./plugin/index.ts"
13
+ "./server": "./plugin/index.ts",
14
+ "./tui": "./plugin/tui.tsx"
14
15
  },
15
16
  "bin": {
16
17
  "opencode-codeops": "bin/index.mjs"
@@ -24,6 +25,8 @@
24
25
  },
25
26
  "files": [
26
27
  "plugin/",
28
+ "!plugin/*.test.mjs",
29
+ "!plugin/*.spec.test.mjs",
27
30
  "skills/",
28
31
  "_shared/",
29
32
  "standards/",
@@ -68,7 +71,26 @@
68
71
  "@opencode/plugin": "^2.0.24"
69
72
  },
70
73
  "devDependencies": {
74
+ "@opentui/core": "0.5.17",
75
+ "@opentui/solid": "0.5.17",
71
76
  "@types/node": "^22.20.2",
77
+ "solid-js": "1.9.12",
72
78
  "typescript": "^5.0.0"
79
+ },
80
+ "peerDependencies": {
81
+ "@opentui/core": ">=0.5.14",
82
+ "@opentui/solid": ">=0.5.14",
83
+ "solid-js": ">=1.9.0"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@opentui/core": {
87
+ "optional": true
88
+ },
89
+ "@opentui/solid": {
90
+ "optional": true
91
+ },
92
+ "solid-js": {
93
+ "optional": true
94
+ }
73
95
  }
74
96
  }
package/plugin/index.ts CHANGED
@@ -9,6 +9,11 @@ import {
9
9
  cleanStaleTmpDirs,
10
10
  ensureSessionTmpDir,
11
11
  } from "../bin/lib/tmp-hygiene.mjs"
12
+ import {
13
+ createCodeOpsProgress,
14
+ registerCodeOpsProgressTool,
15
+ } from "../bin/lib/codeops-progress.mjs"
16
+ import { registerCodeOpsRpc } from "../bin/lib/codeops-rpc.mjs"
12
17
  import {
13
18
  appendEffortTrace,
14
19
  applyEffort,
@@ -223,6 +228,11 @@ export default Plugin.define({
223
228
  const warnedEffortLevels = new Set<string>()
224
229
  const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
225
230
 
231
+ // The live run state is memory-only, for the lifetime of this plugin
232
+ // instance: it is never persisted and clears when the reporting session
233
+ // ends, so it can never outlive the run it describes.
234
+ const progress = createCodeOpsProgress()
235
+
226
236
  // Sweep scratch directories abandoned by earlier interrupted runs, once.
227
237
  // Cleanup is best effort: a failure must never block a session.
228
238
  try {
@@ -234,8 +244,9 @@ export default Plugin.define({
234
244
  warnOnVersionSkew(directory)
235
245
 
236
246
  // ---------------------------------------------------------------------
237
- // Session lifecycle: drop captured markers when a session is deleted, so
238
- // a long-lived runtime does not remember ended sessions.
247
+ // Session lifecycle: drop captured markers and the session's live run
248
+ // when a session is deleted, so a long-lived runtime does not remember
249
+ // ended sessions.
239
250
  // ---------------------------------------------------------------------
240
251
  const controller = new AbortController()
241
252
  void (async () => {
@@ -243,6 +254,7 @@ export default Plugin.define({
243
254
  for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
244
255
  if (event.type === "session.deleted") {
245
256
  effortMarkers.delete(event.data.sessionID)
257
+ progress.clearSession(event.data.sessionID)
246
258
  }
247
259
  }
248
260
  } catch {
@@ -384,6 +396,47 @@ export default Plugin.define({
384
396
  }
385
397
  })
386
398
 
399
+ // ---------------------------------------------------------------------
400
+ // CodeOps RPC: expose the plugin identity and the live run snapshot for
401
+ // the sidebar. Registration is feature-detected and never blocks the
402
+ // plugin: builds without custom RPCs keep working unchanged, and the
403
+ // sidebar simply stays hidden. When the host hands back an event emitter,
404
+ // the registration binds it into the runtime, so accepted reports and
405
+ // clears push live events to connected clients.
406
+ // ---------------------------------------------------------------------
407
+ try {
408
+ const registered = await registerCodeOpsRpc(ctx, {
409
+ pluginVersion: packageVersion,
410
+ runtime: progress,
411
+ })
412
+ if (!registered) {
413
+ warnContentFree(
414
+ "The codeops status RPC is unavailable in this OpenCode build; " +
415
+ "the sidebar status stays hidden."
416
+ )
417
+ }
418
+ } catch {
419
+ warnContentFree("Could not register the codeops status RPC.")
420
+ }
421
+
422
+ // ---------------------------------------------------------------------
423
+ // Progress tool: let the agent report live task progress for the sidebar.
424
+ // The tool registers behind feature-detection; on every failure the
425
+ // plugin, the agent loop, and the Markdown execution plan keep working
426
+ // exactly as before.
427
+ // ---------------------------------------------------------------------
428
+ try {
429
+ const registered = await registerCodeOpsProgressTool(ctx, progress)
430
+ if (!registered) {
431
+ warnContentFree(
432
+ "The codeops progress tool is unavailable in this OpenCode build; " +
433
+ "the sidebar stays hidden."
434
+ )
435
+ }
436
+ } catch {
437
+ warnContentFree("Could not register the codeops progress tool.")
438
+ }
439
+
387
440
  // ---------------------------------------------------------------------
388
441
  // Plugin cleanup: stop listening for events. The runtime's scratch
389
442
  // directory is deliberately left in place — other sessions may still be
package/plugin/tui.tsx ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The CodeOps TUI entry: the live run-progress view in the session sidebar.
3
+ *
4
+ * The view is deliberately honest: it shows only reported values from the
5
+ * server's run state, always with an as-of time, and it renders nothing when
6
+ * there is no run, when the host lacks the events API, or when every call
7
+ * fails. There are no timers — the text recomputes whenever the component
8
+ * re-renders — and no file access; all display and merge logic lives in the
9
+ * shared helper module so it can be tested under plain Node.
10
+ *
11
+ * @module tui
12
+ */
13
+
14
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
15
+ import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
16
+
17
+ import {
18
+ acceptRunCleared,
19
+ acceptRunUpdate,
20
+ describeRun,
21
+ mergeRunState,
22
+ } from "../bin/lib/codeops-progress.mjs"
23
+ import type { CodeOpsRunState } from "../bin/lib/codeops-progress.mjs"
24
+ import { CodeOpsRpc, requestProgress } from "../bin/lib/codeops-rpc.mjs"
25
+
26
+ /**
27
+ * The live run-progress view.
28
+ *
29
+ * Resolves the session's location once, subscribes to the `updated` and
30
+ * `cleared` events before requesting an initial snapshot, and merges both
31
+ * delivery paths monotonically. A clear records its server-stamped
32
+ * `clearedAt`; both merge paths ignore deliveries at or before it, so a late
33
+ * snapshot or update can never resurrect a cleared run. Every failure path is
34
+ * silent: a host without the events API still renders from the snapshot, and
35
+ * an invalid or missing snapshot renders nothing.
36
+ *
37
+ * @returns The three display lines, or nothing when no run is known.
38
+ */
39
+ function CodeOpsProgress() {
40
+ const context = usePlugin()
41
+ const location = context.location ?? context.data.location.default()
42
+ const directory = location?.directory ?? ""
43
+ const [run, setRun] = createSignal<CodeOpsRunState | null>(null)
44
+ const [clearedAt, setClearedAt] = createSignal(0)
45
+
46
+ // Subscribe BEFORE the snapshot: events are live-only, so a snapshot
47
+ // fetched first could miss an update arriving in between. Guarded: a host
48
+ // without the events API still renders from the snapshot below.
49
+ const stops: (() => void)[] = []
50
+ try {
51
+ const events = context.client.rpc(CodeOpsRpc).events
52
+ const stopUpdated = events.on("updated", (event) => {
53
+ const snapshot = acceptRunUpdate(event, directory)
54
+ if (snapshot && snapshot.updatedAt > clearedAt()) {
55
+ setRun((current) => mergeRunState(current, snapshot))
56
+ }
57
+ })
58
+ stops.push(stopUpdated)
59
+ const stopCleared = events.on("cleared", (event) => {
60
+ const cleared = acceptRunCleared(event, directory)
61
+ if (!cleared) return
62
+ // Record the clear time for every accepted clear, before the identity
63
+ // gate: a clear that arrives before the initial snapshot must still
64
+ // suppress that snapshot, or a cleared run could reappear.
65
+ setClearedAt(cleared.clearedAt)
66
+ const current = run()
67
+ if (current && cleared.plan === current.plan && cleared.sessionID === current.sessionID) {
68
+ setRun(null)
69
+ }
70
+ })
71
+ stops.push(stopCleared)
72
+ } catch {
73
+ // Events unavailable: the snapshot-only view below still renders.
74
+ }
75
+ onCleanup(() => stops.forEach((stop) => stop()))
76
+
77
+ // Initial snapshot; null or a failure means "nothing known". The clear
78
+ // guard applies here too: a snapshot at or before the last clear is ignored.
79
+ const [initial] = createResource(async () => requestProgress(context.client, { location }))
80
+ createEffect(() => {
81
+ const snapshot = initial()
82
+ if (snapshot && snapshot.updatedAt > clearedAt()) {
83
+ setRun((current) => mergeRunState(current, snapshot))
84
+ }
85
+ })
86
+
87
+ return (
88
+ <Show when={run()}>
89
+ {(snapshot) => {
90
+ const view = () => describeRun(snapshot(), Date.now())
91
+ return (
92
+ <>
93
+ {view().lines.map((line) => (
94
+ <text>{line}</text>
95
+ ))}
96
+ </>
97
+ )
98
+ }}
99
+ </Show>
100
+ )
101
+ }
102
+
103
+ /**
104
+ * The opencode-codeops TUI plugin: claims the sidebar content slot with the
105
+ * live run view. The host disposes the claim when the plugin unloads.
106
+ */
107
+ export default Plugin.define({
108
+ id: "opencode-codeops-tui",
109
+ setup(context) {
110
+ return context.ui.slot({ append: "sidebar.content", render: () => <CodeOpsProgress /> })
111
+ },
112
+ })
@@ -13,6 +13,7 @@ import re
13
13
  import sys
14
14
  from dataclasses import asdict, dataclass
15
15
  from pathlib import Path
16
+ from typing import Iterator
16
17
 
17
18
 
18
19
  IMPLEMENTS_RE = re.compile(r"^>[ \t]*\*\*Implements\*\*:[ \t]*(.+?)[ \t]*$", re.MULTILINE)
@@ -21,7 +22,9 @@ TARGET_RE = re.compile(
21
22
  r"(?:RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)"
22
23
  r"(?![A-Za-z0-9_-])"
23
24
  )
24
- TASK_RE = re.compile(r"^-\s*\[([ xX~!])\]\s+(.+?)\s*$", re.MULTILINE)
25
+ TASK_RE = re.compile(r"^-\s*\[([ xX~!])\]\s+(.+?)\s*$")
26
+ FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
27
+ TASK_ID_RE = re.compile(r"^\s*(?:\*\*)?([0-9]+\.[0-9]+\.[0-9]+|T-[0-9]+\.[0-9]+)(?=[\s*—–-]|$)")
25
28
  BLOCKED_REASON_RE = re.compile(r"(?:blocked|reason)\s*:\s*\S", re.IGNORECASE)
26
29
  _PROGRESS_WIDTH = 10
27
30
  _PROGRESS_FILLED = "█"
@@ -67,9 +70,70 @@ def parse_implements(index_text: str) -> tuple[str, ...]:
67
70
  return tuple(dict.fromkeys(TARGET_RE.findall(match.group(1))))
68
71
 
69
72
 
73
+ def _task_lines(execution_text: str) -> Iterator[tuple[str, str, str]]:
74
+ """Yield ``(marker, text, task_id)`` for every real execution-task line.
75
+
76
+ A line qualifies when it carries one of the four checkbox markers outside a
77
+ fenced code block and its text begins with a task id. Fences follow the
78
+ Markdown shape: an opening run of three or more backticks or tildes (at
79
+ most three leading spaces, optional info string) hides everything up to a
80
+ closing run of the same character with at least the same length and nothing
81
+ else on the line; an unclosed fence hides the rest of the document. An id
82
+ is ``N.N.N`` or ``T-N.N`` after an optional ``**`` opener and must be
83
+ followed by whitespace, ``*``, a dash, or the end of the line, so
84
+ near-misses such as ``1.1.1.1``, ``T-05.1x``, and ``1.1.1:`` stay ignored.
85
+ Duplicate ids are yielded; the caller decides which occurrence counts.
86
+ """
87
+ fence_char = ""
88
+ fence_length = 0
89
+ for line in execution_text.splitlines():
90
+ line = line.rstrip()
91
+ if fence_char:
92
+ indent = len(line) - len(line.lstrip(" "))
93
+ body = line[indent:] if indent <= 3 else ""
94
+ if len(body) >= fence_length and body == fence_char * len(body):
95
+ fence_char = ""
96
+ continue
97
+ opener = FENCE_OPEN_RE.match(line)
98
+ if opener:
99
+ fence_char = opener.group(1)[0]
100
+ fence_length = len(opener.group(1))
101
+ continue
102
+ task = TASK_RE.match(line)
103
+ task_id = TASK_ID_RE.match(task.group(2)) if task else None
104
+ if task_id is None:
105
+ continue
106
+ yield task.group(1).lower(), task.group(2).strip(), task_id.group(1)
107
+
108
+
70
109
  def parse_tasks(execution_text: str) -> tuple[Task, ...]:
71
- """Parse only the four authoritative execution checklist markers."""
72
- return tuple(Task(marker.lower(), text.strip()) for marker, text in TASK_RE.findall(execution_text))
110
+ """Return the execution tasks a plan document really declares.
111
+
112
+ Fenced examples and checkbox lines without a task id (deliverable and
113
+ acceptance lists, quoted templates) are ignored, and a task id that appears
114
+ more than once counts once, from its first occurrence in document order.
115
+ """
116
+ tasks: list[Task] = []
117
+ seen: set[str] = set()
118
+ for marker, text, task_id in _task_lines(execution_text):
119
+ if task_id in seen:
120
+ continue
121
+ seen.add(task_id)
122
+ tasks.append(Task(marker, text))
123
+ return tuple(tasks)
124
+
125
+
126
+ def has_checkbox_lines(execution_text: str) -> bool:
127
+ """Return whether a document carries at least one checklist line.
128
+
129
+ Presence is deliberately id-agnostic: legacy execution plans may list
130
+ tasks without ids, and migration admission must stay tolerant of them.
131
+ Counting correctness is ``parse_tasks``' job; this predicate only answers
132
+ "does the document look like it declares tasks at all". Fences are not
133
+ stripped here for the same reason — a quoted checklist still proves the
134
+ document format.
135
+ """
136
+ return any(TASK_RE.match(line.rstrip()) for line in execution_text.splitlines())
73
137
 
74
138
 
75
139
  def next_task(tasks: tuple[Task, ...]) -> Task | None: