opencode-codeops 2.1.1 → 2.2.1
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 +87 -0
- package/README.md +11 -0
- package/bin/lib/codeops-progress.d.mts +236 -0
- package/bin/lib/codeops-progress.mjs +564 -0
- package/bin/lib/codeops-rpc.d.mts +160 -0
- package/bin/lib/codeops-rpc.mjs +177 -0
- package/package.json +25 -2
- package/plugin/index.ts +55 -2
- package/plugin/tui.js +113 -0
- package/plugin/tui.tsx +112 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/build-tui.mjs +73 -0
- package/scripts/codeops_plan.py +67 -3
- package/scripts/codeops_plan_migrate.py +17 -3
- package/skills/exec-plan/SKILL.md +5 -0
- package/skills/exec-plan/execution-protocol.md +29 -0
- package/plugin/temp-lifecycle.spec.test.mjs +0 -55
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
|
+
})
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the packaged TUI entry.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode transpiles plugin TSX with its OpenTUI renderer runtime only for
|
|
5
|
+
* files outside `node_modules`. A published package always installs into
|
|
6
|
+
* `node_modules`, so a source `.tsx` entry falls back to the default React
|
|
7
|
+
* JSX transform and fails to load ("Cannot find package 'react'"). Compiling
|
|
8
|
+
* `plugin/tui.tsx` with `@opentui/solid`'s own transform — the same pipeline
|
|
9
|
+
* the host applies to project-local plugins — produces a plain-JavaScript
|
|
10
|
+
* entry whose runtime imports (`@opentui/solid`, `solid-js`) the host
|
|
11
|
+
* rewrites to its bundled renderer.
|
|
12
|
+
*
|
|
13
|
+
* The generated artifact `plugin/tui.js` is committed and shipped as the
|
|
14
|
+
* `./tui` export; `scripts/tui-entry-build.spec.test.mjs` fails when it is
|
|
15
|
+
* stale, so the release verification blocks a stale entry.
|
|
16
|
+
*
|
|
17
|
+
* @module build-tui
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { createRequire } from "node:module"
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
24
|
+
|
|
25
|
+
/** Repository root: this file lives in `<root>/scripts/`. */
|
|
26
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
27
|
+
|
|
28
|
+
/** Source of the TUI entry, authored as TSX. */
|
|
29
|
+
const SOURCE = join(ROOT, "plugin", "tui.tsx")
|
|
30
|
+
|
|
31
|
+
/** Generated JavaScript entry shipped as `./tui`. */
|
|
32
|
+
const TARGET = join(ROOT, "plugin", "tui.js")
|
|
33
|
+
|
|
34
|
+
/** Header marking the artifact as generated. */
|
|
35
|
+
const HEADER =
|
|
36
|
+
"// Generated from plugin/tui.tsx — do not edit; run `npm run build:tui` to regenerate.\n"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load `@opentui/solid`'s Solid JSX transform.
|
|
40
|
+
*
|
|
41
|
+
* The transform module is not part of the package's public exports map, so it
|
|
42
|
+
* is resolved through the package root: resolve the package entry point, then
|
|
43
|
+
* import the transform beside it.
|
|
44
|
+
*
|
|
45
|
+
* @returns The `transformSolidSource(code, options)` function.
|
|
46
|
+
*/
|
|
47
|
+
async function loadTransform() {
|
|
48
|
+
const require = createRequire(join(ROOT, "scripts", "build-tui.mjs"))
|
|
49
|
+
const packageEntry = require.resolve("@opentui/solid")
|
|
50
|
+
const modulePath = join(dirname(packageEntry), "scripts", "solid-transform.js")
|
|
51
|
+
const module = await import(pathToFileURL(modulePath).href)
|
|
52
|
+
return module.transformSolidSource
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compile the current `plugin/tui.tsx` into the shipped entry source.
|
|
57
|
+
*
|
|
58
|
+
* @returns The complete `plugin/tui.js` content, header included.
|
|
59
|
+
*/
|
|
60
|
+
export async function buildTuiSource() {
|
|
61
|
+
const transformSolidSource = await loadTransform()
|
|
62
|
+
const source = readFileSync(SOURCE, "utf8")
|
|
63
|
+
const transformed = await transformSolidSource(source, { filename: SOURCE })
|
|
64
|
+
const code = typeof transformed === "string" ? transformed : transformed.code
|
|
65
|
+
return HEADER + code
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// When run directly (`npm run build:tui`), regenerate the committed artifact.
|
|
69
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
70
|
+
const code = await buildTuiSource()
|
|
71
|
+
writeFileSync(TARGET, code)
|
|
72
|
+
console.log(`built plugin/tui.js (${code.length} chars)`)
|
|
73
|
+
}
|
package/scripts/codeops_plan.py
CHANGED
|
@@ -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*$"
|
|
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
|
-
"""
|
|
72
|
-
|
|
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:
|
|
@@ -17,9 +17,21 @@ from dataclasses import dataclass
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
try:
|
|
20
|
-
from scripts.codeops_plan import
|
|
20
|
+
from scripts.codeops_plan import (
|
|
21
|
+
BLOCKED_REASON_RE,
|
|
22
|
+
IMPLEMENTS_RE,
|
|
23
|
+
has_checkbox_lines,
|
|
24
|
+
parse_implements,
|
|
25
|
+
parse_tasks,
|
|
26
|
+
)
|
|
21
27
|
except ModuleNotFoundError: # Direct execution adds scripts/, not the repository root, to sys.path.
|
|
22
|
-
from codeops_plan import
|
|
28
|
+
from codeops_plan import (
|
|
29
|
+
BLOCKED_REASON_RE,
|
|
30
|
+
IMPLEMENTS_RE,
|
|
31
|
+
has_checkbox_lines,
|
|
32
|
+
parse_implements,
|
|
33
|
+
parse_tasks,
|
|
34
|
+
)
|
|
23
35
|
|
|
24
36
|
|
|
25
37
|
TARGET_ID_RE = re.compile(
|
|
@@ -231,7 +243,9 @@ def inspect_migration(codeops_root: Path) -> Migration:
|
|
|
231
243
|
continue
|
|
232
244
|
execution_text = execution.read_text(encoding="utf-8")
|
|
233
245
|
tasks = parse_tasks(execution_text)
|
|
234
|
-
|
|
246
|
+
# Presence stays checkbox-based (id-agnostic) so legacy id-less
|
|
247
|
+
# checklists keep migrating; only real tasks are checked further.
|
|
248
|
+
if not has_checkbox_lines(execution_text):
|
|
235
249
|
problems.append(f"{execution}: contains no execution tasks")
|
|
236
250
|
for task in tasks:
|
|
237
251
|
if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text):
|
|
@@ -172,6 +172,11 @@ For each task, in order:
|
|
|
172
172
|
the Master Progress Checklist — see the protocol's dual-format detection) and bump the Progress
|
|
173
173
|
counter / Last Updated stamp as soon as implementation finishes (crash-safe), promote it to
|
|
174
174
|
`[x]` only after its verification passes. A task never shows `[x]` with a failing verify.
|
|
175
|
+
|
|
176
|
+
- **Report progress** — when the `codeops_progress` tool is available — at the transition
|
|
177
|
+
points in [execution-protocol.md](execution-protocol.md) §Progress Reporting; a failed or
|
|
178
|
+
missing report never blocks any step.
|
|
179
|
+
|
|
175
180
|
3. **Verify** — run your project's verify command (from the project's AGENTS.md, or detected
|
|
176
181
|
project conventions), output captured per the protocol's **Verify-output capture rule**
|
|
177
182
|
(PASS one-liner; on failure the last 50 log lines + log path). Pass → promote `[~]` → `[x]`;
|
|
@@ -444,6 +444,35 @@ otherwise still `[~]` — with the progress counter and Last Updated stamp curre
|
|
|
444
444
|
|
|
445
445
|
---
|
|
446
446
|
|
|
447
|
+
## Progress Reporting (fail-soft)
|
|
448
|
+
|
|
449
|
+
While a plan executes, the agent may report live progress through the `codeops_progress` tool so
|
|
450
|
+
the sidebar shows the current run. Reporting is an observability aid only: the Markdown execution
|
|
451
|
+
plan remains the single progress source of truth, and a missing tool or a failed call never
|
|
452
|
+
blocks, delays, or changes any verify, commit, or plan update — when the tool is unavailable,
|
|
453
|
+
execution proceeds silently.
|
|
454
|
+
|
|
455
|
+
Report at these moments; `plan` is the plan folder name, and every call carries `plan` and
|
|
456
|
+
`activity` (absent optional fields are carried by the run state):
|
|
457
|
+
|
|
458
|
+
| Moment | Report payload |
|
|
459
|
+
| ------ | -------------- |
|
|
460
|
+
| Run start (start point determined) | `{ plan, phase, task, activity: "implementing" }` |
|
|
461
|
+
| Phase start (subsequent phases) | `{ plan, phase, activity: "starting" }` |
|
|
462
|
+
| Task implemented (after the `[~]` mark) | `{ plan, task, activity: "implementing" }` |
|
|
463
|
+
| Verification (before running verify) | `{ plan, activity: "verifying" }` |
|
|
464
|
+
| Task verified (after `[x]` + the progress-bar display) | `{ plan, verified, total, activity: "implementing" }` — counts from that display |
|
|
465
|
+
| Blocker (after the `[!]` mark) | `{ plan, activity: "blocked", detail: "<short reason>" }` |
|
|
466
|
+
| Delegation (around an executor/reviewer dispatch) | `{ plan, activity: "delegating", detail: "<role>" }` |
|
|
467
|
+
| Review step (post-phase quality loop) | `{ plan, activity: "reviewing" }` |
|
|
468
|
+
| Waiting (before pausing for an explicit user decision) | `{ plan, activity: "waiting" }` |
|
|
469
|
+
| Run done (all tasks complete) | `{ plan, activity: "done" }` |
|
|
470
|
+
|
|
471
|
+
One call per listed moment, no other calls (noise control). A reporting failure or a host without
|
|
472
|
+
the tool never blocks, delays, or changes any step.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
447
476
|
## Step 3: Session Wrap-Up
|
|
448
477
|
|
|
449
478
|
1. Complete the current task before stopping.
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Specification tests for the scratch-directory lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* The plugin exports one scratch directory (`$CODEOPS_TMPDIR`) per plugin
|
|
5
|
-
* runtime, and every session served by that runtime shares it. Deleting the
|
|
6
|
-
* directory when a plugin instance unloads destroys in-flight scratch files of
|
|
7
|
-
* sessions that are still running, so the contract is: the plugin never
|
|
8
|
-
* deletes the shared directory; the age-based sweep reclaims abandoned
|
|
9
|
-
* directories instead.
|
|
10
|
-
*
|
|
11
|
-
* This is the regression oracle for that contract: a failure here means the
|
|
12
|
-
* repository reintroduced early deletion, never that the test is wrong.
|
|
13
|
-
*
|
|
14
|
-
* @module temp-lifecycle.spec.test
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import assert from "node:assert/strict"
|
|
18
|
-
import { readFileSync } from "node:fs"
|
|
19
|
-
import { dirname, join, resolve } from "node:path"
|
|
20
|
-
import { describe, it } from "node:test"
|
|
21
|
-
import { fileURLToPath } from "node:url"
|
|
22
|
-
|
|
23
|
-
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Read a repository file as UTF-8 text.
|
|
27
|
-
* @param {string} relativePath - Path relative to the repository root.
|
|
28
|
-
* @returns {string} The file contents.
|
|
29
|
-
*/
|
|
30
|
-
function read(relativePath) {
|
|
31
|
-
return readFileSync(join(ROOT, relativePath), "utf8")
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("scratch-directory lifecycle", () => {
|
|
35
|
-
it("keeps the plugin free of scratch-directory deletion", () => {
|
|
36
|
-
const plugin = read("plugin/index.ts")
|
|
37
|
-
assert.doesNotMatch(plugin, /removeSessionTmpDir/)
|
|
38
|
-
assert.match(plugin, /cleanStaleTmpDirs\(\)/)
|
|
39
|
-
assert.match(plugin, /controller\.abort\(\)/)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it("keeps the temp hygiene module free of exact-directory deletion", () => {
|
|
43
|
-
assert.doesNotMatch(read("bin/lib/tmp-hygiene.mjs"), /removeSessionTmpDir/)
|
|
44
|
-
assert.doesNotMatch(read("bin/lib/tmp-hygiene.d.mts"), /removeSessionTmpDir/)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it("documents the age-based sweep as the automatic cleanup", () => {
|
|
48
|
-
const hygiene = read("_shared/workspace-hygiene.md")
|
|
49
|
-
assert.doesNotMatch(hygiene, /removes that runtime's\s+directory when the plugin unloads/i)
|
|
50
|
-
assert.match(hygiene, /seven days/i)
|
|
51
|
-
const readme = read("README.md")
|
|
52
|
-
assert.doesNotMatch(readme, /removes the\s+directory when it unloads/i)
|
|
53
|
-
assert.match(readme, /sweeps\s+directories abandoned/i)
|
|
54
|
-
})
|
|
55
|
-
})
|