coding-agent-harness 1.0.7 → 1.0.8
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 +23 -0
- package/CONTRIBUTING.md +1 -1
- package/dist/build-dist.mjs +13 -0
- package/dist/check-dist-observation.mjs +18 -1
- package/dist/check-no-ts-nocheck.mjs +88 -0
- package/dist/check-type-boundaries.mjs +23 -6
- package/dist/commands/preset-command.mjs +23 -1
- package/dist/commands/task-command.mjs +2 -1
- package/dist/harness.mjs +2 -1
- package/dist/lib/harness-core.mjs +1 -0
- package/dist/lib/preset-engine.mjs +1 -0
- package/dist/lib/preset-registry.mjs +13 -1
- package/dist/lib/preset-runner.mjs +294 -0
- package/dist/lib/task-index.mjs +1 -0
- package/dist/lib/task-lifecycle.mjs +3 -1
- package/dist/lib/task-review-model.mjs +2 -0
- package/dist/lib/task-scanner.mjs +1 -0
- package/dist/lib/task-tombstone-commands.mjs +12 -0
- package/docs-release/README.md +1 -1
- package/package.json +6 -2
- package/presets/release-closeout/checks/check-release-package.mjs +24 -0
- package/presets/release-closeout/preset.yaml +100 -0
- package/presets/release-closeout/scripts/generate-release-package.mjs +210 -0
- package/presets/release-closeout/templates/execution_strategy.append.md +7 -0
- package/presets/release-closeout/templates/findings.seed.md +5 -0
- package/presets/release-closeout/templates/review.seed.md +3 -0
- package/presets/release-closeout/templates/task_plan.append.md +24 -0
- package/references/pull-request-standard.md +2 -2
- package/templates/reference/pull-request-standard.md +2 -2
- package/templates-zh-CN/reference/pull-request-standard.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.8
|
|
4
|
+
|
|
5
|
+
- Preserve the executable bit for the packaged `dist/harness.mjs` npm bin
|
|
6
|
+
entry during dist builds and prepack.
|
|
7
|
+
- Extend release observation checks to fail if the packed or installed
|
|
8
|
+
`harness` bin is not executable.
|
|
9
|
+
- Add `migrate-structure --plan` to the dist and installed-package command
|
|
10
|
+
smoke matrix.
|
|
11
|
+
|
|
12
|
+
## 1.0.7
|
|
13
|
+
|
|
14
|
+
- Generate `harness init --add-npm-scripts` commands with
|
|
15
|
+
`npx --yes coding-agent-harness ...` so target projects can run Harness
|
|
16
|
+
scripts without adding a project dependency or relying on a global install.
|
|
17
|
+
- Isolate the dist observation command matrix from local ignored docs by using
|
|
18
|
+
the minimal target-project fixture for target-facing commands.
|
|
19
|
+
- Preserve the executable bit on the committed `dist/harness.mjs` CLI entry.
|
|
20
|
+
|
|
21
|
+
## 1.0.6
|
|
22
|
+
|
|
23
|
+
- Bump the npm package version after the 1.0.5 publication so the TypeScript
|
|
24
|
+
runtime-source migration can be published again.
|
|
25
|
+
|
|
3
26
|
## 1.0.5
|
|
4
27
|
|
|
5
28
|
- Relicense the public package from MIT to AGPL-3.0-or-later.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -4,7 +4,7 @@ Thanks for helping improve Coding Agent Harness. This repository contains the pu
|
|
|
4
4
|
|
|
5
5
|
## Before You Start
|
|
6
6
|
|
|
7
|
-
- Use Node.js
|
|
7
|
+
- Use Node.js 24 or newer. CI should run on the minimum supported line.
|
|
8
8
|
- Install root dependencies with `npm install` from the repository root.
|
|
9
9
|
- If you change `harness-gui`, also run `npm ci` inside `harness-gui/`.
|
|
10
10
|
- Keep pull requests focused. Separate documentation, CLI/runtime, template, preset, and GUI work when the changes are independent.
|
package/dist/build-dist.mjs
CHANGED
|
@@ -43,6 +43,10 @@ export function buildRuntimeDist({ projectRoot = repoRoot, configPath = path.joi
|
|
|
43
43
|
syncDirectory(buildOutDir, absoluteOutDir);
|
|
44
44
|
fs.rmSync(buildOutDir, { recursive: true, force: true });
|
|
45
45
|
}
|
|
46
|
+
restoreExecutableEntrypoints({
|
|
47
|
+
outDir: absoluteOutDir,
|
|
48
|
+
binRelativePaths: ["harness.mjs"],
|
|
49
|
+
});
|
|
46
50
|
const files = collectFiles(absoluteOutDir).filter((file) => file.endsWith(".mjs")).sort();
|
|
47
51
|
const relativeFiles = files.map((file) => toPosix(path.relative(absoluteOutDir, file)));
|
|
48
52
|
const requiredFiles = [
|
|
@@ -126,6 +130,15 @@ function syncDirectory(sourceDir, targetDir) {
|
|
|
126
130
|
fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
|
|
127
131
|
}
|
|
128
132
|
}
|
|
133
|
+
function restoreExecutableEntrypoints({ outDir, binRelativePaths }) {
|
|
134
|
+
for (const relativePath of binRelativePaths) {
|
|
135
|
+
const file = path.join(outDir, relativePath);
|
|
136
|
+
if (!fs.existsSync(file))
|
|
137
|
+
continue;
|
|
138
|
+
const stat = fs.statSync(file);
|
|
139
|
+
fs.chmodSync(file, stat.mode | 0o755);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
129
142
|
function toPosix(value) {
|
|
130
143
|
return value.split(path.sep).join("/");
|
|
131
144
|
}
|
|
@@ -48,7 +48,10 @@ export function checkDistObservation({ projectRoot = defaultProjectRoot, runPack
|
|
|
48
48
|
failures.push({ code: "pack-dry-run-failed", message: `npm pack dry-run failed\nSTDOUT:\n${pack.stdout}\nSTDERR:\n${pack.stderr}` });
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
|
-
const
|
|
51
|
+
const packedEntries = JSON.parse(pack.stdout)[0].files;
|
|
52
|
+
const packed = packedEntries.map((file) => file.path).sort();
|
|
53
|
+
const packedModeByPath = new Map(packedEntries.map((file) => [file.path, file.mode]));
|
|
54
|
+
const distHarnessMode = packedModeByPath.get("dist/harness.mjs");
|
|
52
55
|
observations.package = {
|
|
53
56
|
entryCount: packed.length,
|
|
54
57
|
hasDistHarness: packed.includes("dist/harness.mjs"),
|
|
@@ -57,11 +60,16 @@ export function checkDistObservation({ projectRoot = defaultProjectRoot, runPack
|
|
|
57
60
|
hasScriptsHarness: packed.includes("scripts/harness.mjs"),
|
|
58
61
|
hasScripts: packed.some((file) => file.startsWith("scripts/")),
|
|
59
62
|
hasTests: packed.some((file) => file.startsWith("tests/")),
|
|
63
|
+
distHarnessMode,
|
|
64
|
+
distHarnessExecutable: typeof distHarnessMode === "number" && Boolean(distHarnessMode & 0o111),
|
|
60
65
|
};
|
|
61
66
|
for (const required of ["dist/harness.mjs", "dist/postinstall.mjs", "dist/check-dist-observation.mjs"]) {
|
|
62
67
|
if (!packed.includes(required))
|
|
63
68
|
failures.push({ code: "packed-file-missing", file: required, message: `package missing ${required}` });
|
|
64
69
|
}
|
|
70
|
+
if (!observations.package.distHarnessExecutable) {
|
|
71
|
+
failures.push({ code: "packed-bin-not-executable", file: "dist/harness.mjs", mode: distHarnessMode, message: "package bin dist/harness.mjs must be executable" });
|
|
72
|
+
}
|
|
65
73
|
if (observations.package.hasScripts)
|
|
66
74
|
failures.push({ code: "package-includes-scripts", message: "package must not include scripts/** after historical shim deletion" });
|
|
67
75
|
if (observations.package.hasTests)
|
|
@@ -133,6 +141,7 @@ function runMatrix(root, failures, commandMatrix) {
|
|
|
133
141
|
{ id: "source-check", args: ["check", "--profile", "source-package", "."] },
|
|
134
142
|
{ id: "target-check", args: ["check", "--profile", "target-project", "examples/minimal-project"] },
|
|
135
143
|
{ id: "migrate-plan", args: ["migrate-plan", "--json", "--limit", "20", "examples/minimal-project"] },
|
|
144
|
+
{ id: "migrate-structure-plan", args: ["migrate-structure", "--plan", "--json", "examples/minimal-project"] },
|
|
136
145
|
{ id: "dashboard", args: ["dashboard", "--out-dir", path.join("tmp", `pr-27-observation-dashboard-${process.pid}`), "examples/minimal-project"] },
|
|
137
146
|
];
|
|
138
147
|
for (const entry of matrix) {
|
|
@@ -211,11 +220,15 @@ function runInstalledPackageSmoke(root, failures, observations) {
|
|
|
211
220
|
if (!pkg)
|
|
212
221
|
return;
|
|
213
222
|
const binTarget = fs.existsSync(bin) ? fs.readlinkSync(bin) : "";
|
|
223
|
+
const installedBinFile = path.join(packageRoot, "dist/harness.mjs");
|
|
224
|
+
const installedBinMode = fs.existsSync(installedBinFile) ? fs.statSync(installedBinFile).mode : undefined;
|
|
214
225
|
observations.installSmoke = {
|
|
215
226
|
nodeVersion,
|
|
216
227
|
tempRoot,
|
|
217
228
|
binTarget,
|
|
218
229
|
bin: pkg.bin?.harness,
|
|
230
|
+
binMode: installedBinMode,
|
|
231
|
+
binExecutable: typeof installedBinMode === "number" && Boolean(installedBinMode & 0o111),
|
|
219
232
|
postinstall: pkg.scripts?.postinstall,
|
|
220
233
|
observeDist: pkg.scripts?.["observe:dist"],
|
|
221
234
|
hasTests: fs.existsSync(path.join(packageRoot, "tests")),
|
|
@@ -229,6 +242,9 @@ function runInstalledPackageSmoke(root, failures, observations) {
|
|
|
229
242
|
if (!binTarget.includes("dist/harness.mjs")) {
|
|
230
243
|
failures.push({ code: "installed-bin-link-not-dist", message: `installed bin link does not target dist/harness.mjs: ${binTarget}` });
|
|
231
244
|
}
|
|
245
|
+
if (!observations.installSmoke.binExecutable) {
|
|
246
|
+
failures.push({ code: "installed-bin-not-executable", file: "dist/harness.mjs", mode: installedBinMode, message: "installed package bin dist/harness.mjs must be executable" });
|
|
247
|
+
}
|
|
232
248
|
for (const relative of ["dist/harness.mjs", "dist/postinstall.mjs", "dist/check-dist-observation.mjs"]) {
|
|
233
249
|
if (!fs.existsSync(path.join(packageRoot, relative)))
|
|
234
250
|
failures.push({ code: "installed-file-missing", file: relative, message: `installed package missing ${relative}` });
|
|
@@ -278,6 +294,7 @@ function runInstalledMatrix(root, runtimeEnv, failures, steps) {
|
|
|
278
294
|
{ id: "installed-source-check", cwd: root, args: ["check", "--profile", "source-package", "."] },
|
|
279
295
|
{ id: "installed-target-check", cwd: root, args: ["check", "--profile", "target-project", "examples/minimal-project"] },
|
|
280
296
|
{ id: "installed-migrate-plan", cwd: root, args: ["migrate-plan", "--json", "--limit", "20", "examples/minimal-project"] },
|
|
297
|
+
{ id: "installed-migrate-structure-plan", cwd: root, args: ["migrate-structure", "--plan", "--json", "examples/minimal-project"] },
|
|
281
298
|
{ id: "installed-dashboard", cwd: root, args: ["dashboard", "--out-dir", path.join("tmp", `pr-27-installed-observation-dashboard-${process.pid}`), "examples/minimal-project"] },
|
|
282
299
|
];
|
|
283
300
|
for (const entry of matrix) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
const sourceRoots = ["scripts", "tests"];
|
|
7
|
+
const tsNocheckPattern = /^\s*\/\/\s*@ts-nocheck\b/;
|
|
8
|
+
export function checkNoTsNocheck({ repoRoot = defaultRepoRoot, allowlistPath = path.join(repoRoot, "scripts/ts-nocheck-allowlist.json"), } = {}) {
|
|
9
|
+
const files = collectMtsFiles(repoRoot);
|
|
10
|
+
const allowlist = readAllowlist(allowlistPath);
|
|
11
|
+
const violations = [];
|
|
12
|
+
const observed = new Set();
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
const absolutePath = path.join(repoRoot, file);
|
|
15
|
+
const lines = fs.readFileSync(absolutePath, "utf8").split(/\r?\n/);
|
|
16
|
+
const lineIndex = lines.findIndex((line) => tsNocheckPattern.test(line));
|
|
17
|
+
if (lineIndex === -1)
|
|
18
|
+
continue;
|
|
19
|
+
observed.add(file);
|
|
20
|
+
if (!allowlist.has(file)) {
|
|
21
|
+
violations.push({
|
|
22
|
+
code: "unlisted-ts-nocheck",
|
|
23
|
+
file,
|
|
24
|
+
line: lineIndex + 1,
|
|
25
|
+
message: `${file}:${lineIndex + 1} has @ts-nocheck but is not in scripts/ts-nocheck-allowlist.json`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const file of allowlist) {
|
|
30
|
+
if (!observed.has(file)) {
|
|
31
|
+
violations.push({
|
|
32
|
+
code: "stale-ts-nocheck-allowlist",
|
|
33
|
+
file,
|
|
34
|
+
message: `${file} is listed in scripts/ts-nocheck-allowlist.json but no longer has @ts-nocheck`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: violations.length === 0, violations };
|
|
39
|
+
}
|
|
40
|
+
function collectMtsFiles(repoRoot) {
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const root of sourceRoots) {
|
|
43
|
+
const absoluteRoot = path.join(repoRoot, root);
|
|
44
|
+
if (!fs.existsSync(absoluteRoot))
|
|
45
|
+
continue;
|
|
46
|
+
walk(absoluteRoot, files, repoRoot);
|
|
47
|
+
}
|
|
48
|
+
return files.sort();
|
|
49
|
+
}
|
|
50
|
+
function walk(current, files, repoRoot) {
|
|
51
|
+
const stat = fs.lstatSync(current);
|
|
52
|
+
if (stat.isSymbolicLink())
|
|
53
|
+
return;
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
const name = path.basename(current);
|
|
56
|
+
if (name === "node_modules" || name === ".worktrees" || name === "tmp")
|
|
57
|
+
return;
|
|
58
|
+
for (const entry of fs.readdirSync(current))
|
|
59
|
+
walk(path.join(current, entry), files, repoRoot);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (stat.isFile() && current.endsWith(".mts")) {
|
|
63
|
+
files.push(path.relative(repoRoot, current).split(path.sep).join("/"));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function readAllowlist(allowlistPath) {
|
|
67
|
+
if (!allowlistPath || !fs.existsSync(allowlistPath))
|
|
68
|
+
return new Set();
|
|
69
|
+
const parsed = JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
|
|
70
|
+
const files = normalizeAllowlistFiles(parsed);
|
|
71
|
+
return new Set(files);
|
|
72
|
+
}
|
|
73
|
+
function normalizeAllowlistFiles(parsed) {
|
|
74
|
+
if (Array.isArray(parsed))
|
|
75
|
+
return parsed.filter((value) => typeof value === "string");
|
|
76
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
77
|
+
return [];
|
|
78
|
+
const files = parsed.files;
|
|
79
|
+
return Array.isArray(files) ? files.filter((value) => typeof value === "string") : [];
|
|
80
|
+
}
|
|
81
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
82
|
+
const result = checkNoTsNocheck();
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
console.error(result.violations.map((violation) => violation.message).join("\n"));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
console.log("No @ts-nocheck gate passed");
|
|
88
|
+
}
|
|
@@ -6,24 +6,27 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
7
|
const sourceRoots = ["scripts", "tests"];
|
|
8
8
|
const importPattern = /\b(import|export)\s+(type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
9
|
-
const tsEscapePattern = /@(ts-ignore|ts-expect-error)\b
|
|
10
|
-
export function checkTypeBoundaries({ repoRoot = defaultRepoRoot } = {}) {
|
|
9
|
+
const tsEscapePattern = /@(ts-ignore|ts-expect-error)\b|\bas\s+unknown\s+as\b|\bRecord\s*<\s*string\s*,\s*any\s*>|(?:^|[^A-Za-z0-9_$])(?:as\s+any|:\s*any\b)/;
|
|
10
|
+
export function checkTypeBoundaries({ repoRoot = defaultRepoRoot, escapeAllowlistPath = path.join(repoRoot, "scripts/type-escape-allowlist.json"), } = {}) {
|
|
11
11
|
const files = collectSourceFiles(repoRoot);
|
|
12
12
|
const violations = [];
|
|
13
|
+
const escapeAllowlist = readEscapeAllowlist(escapeAllowlistPath);
|
|
13
14
|
for (const file of files) {
|
|
14
15
|
const absolutePath = path.join(repoRoot, file);
|
|
15
16
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
16
17
|
const imports = parseImports(content);
|
|
17
|
-
if (file.endsWith(".ts")) {
|
|
18
|
+
if (file.endsWith(".ts") || file.endsWith(".mts")) {
|
|
18
19
|
const lines = content.split(/\r?\n/);
|
|
19
20
|
for (const [index, line] of lines.entries()) {
|
|
20
21
|
if (tsEscapePattern.test(line)) {
|
|
21
|
-
|
|
22
|
+
const violation = {
|
|
22
23
|
code: "ts-escape-hatch",
|
|
23
24
|
file,
|
|
24
25
|
line: index + 1,
|
|
25
26
|
message: `${file}:${index + 1} uses a TypeScript escape hatch that requires review`,
|
|
26
|
-
}
|
|
27
|
+
};
|
|
28
|
+
if (!isEscapeAllowed(escapeAllowlist, violation))
|
|
29
|
+
violations.push(violation);
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
}
|
|
@@ -127,7 +130,21 @@ function hasTypeScriptSourceExtension(filePath) {
|
|
|
127
130
|
return typeof filePath === "string" && /\.(mts|ts)$/.test(filePath);
|
|
128
131
|
}
|
|
129
132
|
function isTypeOnlyTypeScriptImport(file, imported) {
|
|
130
|
-
return file.endsWith(".ts") && imported.kind === "import" && imported.typeOnly;
|
|
133
|
+
return (file.endsWith(".ts") || file.endsWith(".mts")) && imported.kind === "import" && imported.typeOnly;
|
|
134
|
+
}
|
|
135
|
+
function readEscapeAllowlist(allowlistPath) {
|
|
136
|
+
if (!allowlistPath || !fs.existsSync(allowlistPath))
|
|
137
|
+
return new Set();
|
|
138
|
+
const parsed = JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
|
|
139
|
+
const entries = Array.isArray(parsed) ? parsed : parsed.escapes || [];
|
|
140
|
+
return new Set(entries.map((entry) => {
|
|
141
|
+
if (typeof entry === "string")
|
|
142
|
+
return entry;
|
|
143
|
+
return `${entry.file}:${entry.line}:${entry.code || "ts-escape-hatch"}`;
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function isEscapeAllowed(allowlist, violation) {
|
|
147
|
+
return allowlist.has(`${violation.file}:${violation.line}:${violation.code}`) || allowlist.has(`${violation.file}:${violation.line}`);
|
|
131
148
|
}
|
|
132
149
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
133
150
|
const result = checkTypeBoundaries();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
import { checkPresetPackage, inspectPresetPackage, installPresetPackage, listPresetPackages, seedBundledPresets, uninstallPresetPackage, } from "../lib/harness-core.mjs";
|
|
2
|
+
import { checkPresetPackage, inspectPresetPackage, installPresetPackage, listPresetPackages, seedBundledPresets, runPresetEntrypoint, uninstallPresetPackage, } from "../lib/harness-core.mjs";
|
|
3
3
|
export function runPresetCommand({ args, takeFlag, targetArg }) {
|
|
4
4
|
const subcommand = args.shift() || "list";
|
|
5
5
|
const json = takeFlag("--json");
|
|
@@ -80,6 +80,20 @@ export function runPresetCommand({ args, takeFlag, targetArg }) {
|
|
|
80
80
|
else
|
|
81
81
|
console.log(`${result.removed ? "Removed" : "Preset not installed"}: ${result.id}`);
|
|
82
82
|
}
|
|
83
|
+
else if (subcommand === "run") {
|
|
84
|
+
const taskRef = takeOptionFromArgs(args, "--task", "");
|
|
85
|
+
const id = args.shift();
|
|
86
|
+
const entrypoint = args.shift();
|
|
87
|
+
if (!id)
|
|
88
|
+
throw new Error("Missing preset id");
|
|
89
|
+
if (!entrypoint)
|
|
90
|
+
throw new Error("Missing preset entrypoint");
|
|
91
|
+
const result = runPresetEntrypoint(id, entrypoint, { taskRef, targetInput: targetArg(), json });
|
|
92
|
+
if (json)
|
|
93
|
+
console.log(JSON.stringify(result, null, 2));
|
|
94
|
+
else
|
|
95
|
+
console.log(`Preset run ${result.status}: ${result.preset}.${result.entrypoint} (${result.materialized.length} writes)`);
|
|
96
|
+
}
|
|
83
97
|
else {
|
|
84
98
|
throw new Error(`Unknown preset subcommand: ${subcommand}`);
|
|
85
99
|
}
|
|
@@ -89,3 +103,11 @@ export function runPresetCommand({ args, takeFlag, targetArg }) {
|
|
|
89
103
|
process.exit(1);
|
|
90
104
|
}
|
|
91
105
|
}
|
|
106
|
+
function takeOptionFromArgs(args, name, fallback = "") {
|
|
107
|
+
const index = args.indexOf(name);
|
|
108
|
+
if (index < 0)
|
|
109
|
+
return fallback;
|
|
110
|
+
const value = args[index + 1] || fallback;
|
|
111
|
+
args.splice(index, 2);
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
@@ -128,7 +128,8 @@ export function runTaskCommand(command, { args, takeFlag, takeOption, targetArg
|
|
|
128
128
|
const lesson = takeOption("--lesson", "");
|
|
129
129
|
const search = takeOption("--search", "");
|
|
130
130
|
const missingMaterials = takeFlag("--missing-materials");
|
|
131
|
-
const
|
|
131
|
+
const includeArchived = takeFlag("--include-archived");
|
|
132
|
+
const result = listLifecycleTasks(targetArg(), { state, moduleKey, queue, preset, review, lesson, search, missingMaterials, includeArchived });
|
|
132
133
|
if (json) {
|
|
133
134
|
console.log(JSON.stringify(result, null, 2));
|
|
134
135
|
}
|
package/dist/harness.mjs
CHANGED
|
@@ -90,6 +90,7 @@ Usage:
|
|
|
90
90
|
harness preset install <folder|zip|builtin-id> [--project] [--force] [--json] [target]
|
|
91
91
|
harness preset seed [--project] [--force] [--dry-run] [--json] [target]
|
|
92
92
|
harness preset uninstall <id> [--project] [--json] [target]
|
|
93
|
+
harness preset run <id> <plan|scaffold|check> --task <task-id> [--json] [target]
|
|
93
94
|
harness new-task [task-id] [--module key] [--budget simple|standard|complex] [--preset id] [--from-session session.json] [--long-running] [--title title] [--locale zh-CN|en-US] [--dry-run] [target]
|
|
94
95
|
harness task-start <task-id> [--message text] [target]
|
|
95
96
|
harness task-phase <task-id> <phase-id> [--state done] [--completion 100] [--evidence present] [target]
|
|
@@ -100,7 +101,7 @@ Usage:
|
|
|
100
101
|
harness lesson-promote <task-id> <candidate-id> [--dry-run|--apply] [target]
|
|
101
102
|
harness lesson-sediment <task-id> <candidate-id> [--dry-run] [--title title] [target]
|
|
102
103
|
harness task-complete <task-id> [--message text] [target]
|
|
103
|
-
harness task-list [--json] [--state state] [--module key] [--queue queue] [--preset id] [--review status] [--lesson status] [--missing-materials] [--search text] [target]
|
|
104
|
+
harness task-list [--json] [--state state] [--module key] [--queue queue] [--preset id] [--review status] [--lesson status] [--missing-materials] [--include-archived] [--search text] [target]
|
|
104
105
|
harness task-index [--json] [target]
|
|
105
106
|
harness task-supersede <old-task-id> --by <new-task-id> [--reason text] [target]
|
|
106
107
|
harness task-delete <task-id> --soft [--reason text] [target]
|
|
@@ -10,6 +10,7 @@ export * from "./dashboard-workbench.mjs";
|
|
|
10
10
|
export * from "./migration-planner.mjs";
|
|
11
11
|
export * from "./structure-migration.mjs";
|
|
12
12
|
export * from "./preset-registry.mjs";
|
|
13
|
+
export * from "./preset-runner.mjs";
|
|
13
14
|
export * from "./governance-index-generator.mjs";
|
|
14
15
|
export * from "./task-lifecycle.mjs";
|
|
15
16
|
export * from "./task-lesson-sedimentation.mjs";
|
|
@@ -94,6 +94,7 @@ export function buildPresetContext(preset, { target, taskDir, taskId, taskTitle,
|
|
|
94
94
|
taskId,
|
|
95
95
|
targetRoot: target.projectRoot,
|
|
96
96
|
entrypoint: "newTask",
|
|
97
|
+
resolvedInputs,
|
|
97
98
|
});
|
|
98
99
|
const context = {
|
|
99
100
|
kind: evaluatedValues.kind || preset.task?.kind || "general",
|
|
@@ -258,6 +258,9 @@ export function validatePresetPackage(preset) {
|
|
|
258
258
|
if (!preset.writeScopes.some((scope) => scope.path === writeScope)) {
|
|
259
259
|
failures.push(`${name} writes undeclared scope: ${writeScope}`);
|
|
260
260
|
}
|
|
261
|
+
if (name === "newTask" && !newTaskWriteScopeAllowed(writeScope)) {
|
|
262
|
+
failures.push("newTask entrypoint writes must stay under coding-agent-harness/planning/**");
|
|
263
|
+
}
|
|
261
264
|
}
|
|
262
265
|
if (["script", "check"].includes(entrypoint.type)) {
|
|
263
266
|
const entryPath = path.join(preset.directory, entrypoint.command || "");
|
|
@@ -282,7 +285,7 @@ export function validatePresetPackage(preset) {
|
|
|
282
285
|
}
|
|
283
286
|
return { failures, warnings };
|
|
284
287
|
}
|
|
285
|
-
export function buildPresetAudit(preset, { taskId = "", targetRoot = "", entrypoint = "newTask", writeScopes = [] } = {}) {
|
|
288
|
+
export function buildPresetAudit(preset, { taskId = "", targetRoot = "", entrypoint = "newTask", writeScopes = [], resolvedInputs = {} } = {}) {
|
|
286
289
|
const entrypoints = {
|
|
287
290
|
[entrypoint]: preset.entrypoints[entrypoint],
|
|
288
291
|
};
|
|
@@ -294,6 +297,7 @@ export function buildPresetAudit(preset, { taskId = "", targetRoot = "", entrypo
|
|
|
294
297
|
manifestSha256: preset.manifestSha256,
|
|
295
298
|
entrypoints,
|
|
296
299
|
writeScopes: scopes,
|
|
300
|
+
resolvedInputs,
|
|
297
301
|
taskId,
|
|
298
302
|
targetRoot,
|
|
299
303
|
generatedAt: new Date().toISOString(),
|
|
@@ -526,6 +530,14 @@ function validateAuditEvidenceFiles(preset, failures) {
|
|
|
526
530
|
}
|
|
527
531
|
}
|
|
528
532
|
}
|
|
533
|
+
function newTaskWriteScopeAllowed(writeScope) {
|
|
534
|
+
const normalized = toPosix(path.normalize(String(writeScope || "")));
|
|
535
|
+
const legacyPlanningScope = ["docs", "09-PLANNING"].join("/");
|
|
536
|
+
return (normalized === "coding-agent-harness/planning/**" ||
|
|
537
|
+
normalized.startsWith("coding-agent-harness/planning/") ||
|
|
538
|
+
normalized === `${legacyPlanningScope}/**` ||
|
|
539
|
+
normalized.startsWith(`${legacyPlanningScope}/`));
|
|
540
|
+
}
|
|
529
541
|
function validatePresetPackageFile(preset, relativePath, label, failures) {
|
|
530
542
|
const filePath = path.join(preset.directory, relativePath || "");
|
|
531
543
|
if (!isInside(preset.directory, filePath)) {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Generic preset entrypoint runner. Domain logic belongs in preset packages.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { normalizeTarget, readFileSafe, readJsonSafe, sanitizeDeep, toPosix, walkFiles, } from "./core-shared.mjs";
|
|
9
|
+
import { beginGovernanceSync, commitGovernanceSync, releaseGovernanceSync } from "./governance-sync.mjs";
|
|
10
|
+
import { parseTaskMetadata } from "./task-metadata.mjs";
|
|
11
|
+
import { taskIdForDirectory } from "./task-scanner.mjs";
|
|
12
|
+
import { resolveTaskDirectory } from "./task-lifecycle.mjs";
|
|
13
|
+
import { evaluateTemplateValues, assertPresetWriteScope } from "./preset-engine.mjs";
|
|
14
|
+
import { buildPresetAudit, readPresetPackage } from "./preset-registry.mjs";
|
|
15
|
+
const materializationSchemaVersion = "preset-materialization/v1";
|
|
16
|
+
const maxMaterializedFileBytes = 10 * 1024 * 1024;
|
|
17
|
+
const maxMaterializedWrites = 500;
|
|
18
|
+
export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", targetInput = ".", json = false } = {}) {
|
|
19
|
+
const target = normalizeTarget(targetInput);
|
|
20
|
+
const preset = readPresetPackage(presetId, { targetInput });
|
|
21
|
+
const entrypoint = preset.entrypoints?.[entrypointName];
|
|
22
|
+
if (!entrypoint)
|
|
23
|
+
throw new Error(`Preset ${preset.id} does not declare entrypoint: ${entrypointName}`);
|
|
24
|
+
if (!["script", "check"].includes(entrypoint.type))
|
|
25
|
+
throw new Error(`Preset entrypoint ${entrypointName} is not runnable by preset run`);
|
|
26
|
+
if (!taskRef)
|
|
27
|
+
throw new Error("preset run requires --task <task-id>");
|
|
28
|
+
const taskDir = resolveTaskDirectory(target, taskRef);
|
|
29
|
+
const taskPlan = readFileSafe(path.join(taskDir, "task_plan.md"));
|
|
30
|
+
const metadata = parseTaskMetadata(taskPlan);
|
|
31
|
+
if (metadata.preset !== preset.id)
|
|
32
|
+
throw new Error(`Task ${taskRef} was created by preset ${metadata.preset || "none"}, not ${preset.id}`);
|
|
33
|
+
const taskId = taskIdForDirectory(target, taskDir);
|
|
34
|
+
const resolvedInputs = readResolvedInputs(target, metadata);
|
|
35
|
+
const values = evaluateTemplateValues(preset, resolvedInputs, { taskId, taskTitle: taskId, moduleKey: "" });
|
|
36
|
+
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `harness-preset-${preset.id}-${entrypointName}-`));
|
|
37
|
+
const manifestPath = path.join(outputRoot, "materialization-manifest.json");
|
|
38
|
+
const contextPath = path.join(outputRoot, "preset-context.json");
|
|
39
|
+
const beforeSnapshot = targetSnapshot(target.projectRoot);
|
|
40
|
+
try {
|
|
41
|
+
const context = {
|
|
42
|
+
schemaVersion: "preset-run-context/v1",
|
|
43
|
+
preset: { id: preset.id, version: preset.version, source: preset.source },
|
|
44
|
+
entrypoint: entrypointName,
|
|
45
|
+
task: {
|
|
46
|
+
id: taskId,
|
|
47
|
+
ref: taskRef,
|
|
48
|
+
dir: toPosix(path.relative(target.projectRoot, taskDir)),
|
|
49
|
+
taskPlanPath: toPosix(path.relative(target.projectRoot, path.join(taskDir, "task_plan.md"))),
|
|
50
|
+
},
|
|
51
|
+
targetRoot: target.projectRoot,
|
|
52
|
+
targetRootPolicy: "read-only; direct target mutation before manifest materialization is a hard failure",
|
|
53
|
+
outputRoot,
|
|
54
|
+
materializationManifestPath: manifestPath,
|
|
55
|
+
inputs: sanitizeDeep(resolvedInputs),
|
|
56
|
+
values: sanitizeDeep(values),
|
|
57
|
+
audit: buildPresetAudit(preset, {
|
|
58
|
+
taskId,
|
|
59
|
+
targetRoot: target.projectRoot,
|
|
60
|
+
entrypoint: entrypointName,
|
|
61
|
+
writeScopes: entrypoint.writes,
|
|
62
|
+
resolvedInputs,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`);
|
|
66
|
+
const commandPath = path.join(preset.directory, entrypoint.command || "");
|
|
67
|
+
const script = spawnSync(process.execPath, [commandPath], {
|
|
68
|
+
cwd: outputRoot,
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
env: {
|
|
71
|
+
...process.env,
|
|
72
|
+
HARNESS_PRESET_CONTEXT: contextPath,
|
|
73
|
+
},
|
|
74
|
+
timeout: 120000,
|
|
75
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
76
|
+
});
|
|
77
|
+
if (script.error)
|
|
78
|
+
throw script.error;
|
|
79
|
+
if (script.status !== 0) {
|
|
80
|
+
throw new Error(`Preset entrypoint ${preset.id}.${entrypointName} failed with ${script.status}\n${script.stderr || script.stdout || ""}`.trim());
|
|
81
|
+
}
|
|
82
|
+
const afterScriptSnapshot = targetSnapshot(target.projectRoot);
|
|
83
|
+
assertSnapshotsEqual(beforeSnapshot, afterScriptSnapshot, "Preset script mutated target before materialization");
|
|
84
|
+
const manifest = readMaterializationManifest(manifestPath);
|
|
85
|
+
const materialization = validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot, targetRoot: target.projectRoot });
|
|
86
|
+
const governanceContext = beginGovernanceSync(target, {
|
|
87
|
+
operation: `preset-run ${preset.id}.${entrypointName}`,
|
|
88
|
+
allowDirtyWorktree: true,
|
|
89
|
+
allowedRelativePaths: materialization.map((item) => item.destination),
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
materializeWrites(target.projectRoot, materialization);
|
|
93
|
+
const commit = commitGovernanceSync(governanceContext, materialization.map((item) => item.destination), {
|
|
94
|
+
message: `chore(harness): run preset ${preset.id} ${entrypointName}`,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
preset: preset.id,
|
|
98
|
+
entrypoint: entrypointName,
|
|
99
|
+
taskId,
|
|
100
|
+
status: manifest.status || (entrypoint.type === "check" ? "pass" : "ok"),
|
|
101
|
+
materialized: materialization.map((item) => ({
|
|
102
|
+
source: item.source,
|
|
103
|
+
destination: item.destination,
|
|
104
|
+
type: item.type,
|
|
105
|
+
sha256: item.sha256,
|
|
106
|
+
})),
|
|
107
|
+
governance: { commit },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
releaseGovernanceSync(governanceContext);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function readResolvedInputs(target, metadata) {
|
|
119
|
+
const evidenceBundle = String(metadata.evidenceBundle || "").replace(/^TARGET:/, "").replace(/^\/+/, "");
|
|
120
|
+
if (!evidenceBundle)
|
|
121
|
+
return {};
|
|
122
|
+
const auditPath = path.join(target.projectRoot, evidenceBundle, "preset-audit.json");
|
|
123
|
+
const audit = readJsonSafe(auditPath, {});
|
|
124
|
+
return audit.resolvedInputs || {};
|
|
125
|
+
}
|
|
126
|
+
function readMaterializationManifest(manifestPath) {
|
|
127
|
+
if (!fs.existsSync(manifestPath))
|
|
128
|
+
throw new Error("Preset entrypoint did not emit materialization manifest");
|
|
129
|
+
const manifest = readJsonSafe(manifestPath, null);
|
|
130
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
|
|
131
|
+
throw new Error("Invalid preset materialization manifest");
|
|
132
|
+
if (manifest.schemaVersion !== materializationSchemaVersion)
|
|
133
|
+
throw new Error(`Invalid preset materialization schema: ${manifest.schemaVersion || "(missing)"}`);
|
|
134
|
+
if (!Array.isArray(manifest.writes))
|
|
135
|
+
throw new Error("Preset materialization manifest writes must be an array");
|
|
136
|
+
if (manifest.writes.length > maxMaterializedWrites)
|
|
137
|
+
throw new Error(`Preset materialization manifest has too many writes: ${manifest.writes.length}`);
|
|
138
|
+
return manifest;
|
|
139
|
+
}
|
|
140
|
+
function validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot, targetRoot }) {
|
|
141
|
+
const seenDestinations = new Set();
|
|
142
|
+
const writes = manifest.writes.map((write, index) => {
|
|
143
|
+
const source = normalizeManifestRelativePath(write.source, "Manifest source");
|
|
144
|
+
const destination = normalizeManifestRelativePath(write.destination, "Manifest destination");
|
|
145
|
+
if (seenDestinations.has(destination))
|
|
146
|
+
throw new Error(`Duplicate materialization destination: ${destination}`);
|
|
147
|
+
seenDestinations.add(destination);
|
|
148
|
+
assertEntrypointWriteScope(preset, entrypoint, destination);
|
|
149
|
+
const sourcePath = path.join(outputRoot, source);
|
|
150
|
+
assertOutputSource(outputRoot, sourcePath, source);
|
|
151
|
+
const stat = fs.lstatSync(sourcePath);
|
|
152
|
+
if (stat.size > maxMaterializedFileBytes)
|
|
153
|
+
throw new Error(`Manifest source exceeds size limit: ${source}`);
|
|
154
|
+
assertDestinationParent(targetRoot, destination);
|
|
155
|
+
return {
|
|
156
|
+
source,
|
|
157
|
+
sourcePath,
|
|
158
|
+
destination,
|
|
159
|
+
destinationPath: path.join(targetRoot, destination),
|
|
160
|
+
type: String(write.type || "text"),
|
|
161
|
+
visibility: String(write.visibility || ""),
|
|
162
|
+
sha256: sha256File(sourcePath),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
enforcePublicRedaction(manifest, writes, { outputRoot });
|
|
166
|
+
return writes;
|
|
167
|
+
}
|
|
168
|
+
function normalizeManifestRelativePath(value, label) {
|
|
169
|
+
const raw = String(value || "").trim();
|
|
170
|
+
const normalized = toPosix(path.normalize(raw));
|
|
171
|
+
if (!raw || path.isAbsolute(raw) || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
172
|
+
throw new Error(`${label} escapes preset output root: ${raw || "(missing)"}`);
|
|
173
|
+
}
|
|
174
|
+
return normalized;
|
|
175
|
+
}
|
|
176
|
+
function assertOutputSource(outputRoot, sourcePath, source) {
|
|
177
|
+
if (!fs.existsSync(sourcePath))
|
|
178
|
+
throw new Error(`Manifest source missing: ${source}`);
|
|
179
|
+
const stat = fs.lstatSync(sourcePath);
|
|
180
|
+
if (stat.isSymbolicLink())
|
|
181
|
+
throw new Error(`Manifest source must not be a symlink: ${source}`);
|
|
182
|
+
if (!stat.isFile())
|
|
183
|
+
throw new Error(`Manifest source must be a file: ${source}`);
|
|
184
|
+
const realRoot = fs.realpathSync(outputRoot);
|
|
185
|
+
const realSource = fs.realpathSync(sourcePath);
|
|
186
|
+
if (!isInside(realRoot, realSource))
|
|
187
|
+
throw new Error(`Manifest source escapes preset output root: ${source}`);
|
|
188
|
+
}
|
|
189
|
+
function assertDestinationParent(targetRoot, destination) {
|
|
190
|
+
let parent = path.dirname(path.join(targetRoot, destination));
|
|
191
|
+
const realTarget = fs.realpathSync(targetRoot);
|
|
192
|
+
while (!fs.existsSync(parent) && parent !== targetRoot && parent !== path.dirname(parent))
|
|
193
|
+
parent = path.dirname(parent);
|
|
194
|
+
if (fs.existsSync(parent)) {
|
|
195
|
+
const stat = fs.lstatSync(parent);
|
|
196
|
+
if (stat.isSymbolicLink())
|
|
197
|
+
throw new Error(`Manifest destination parent must not be a symlink: ${destination}`);
|
|
198
|
+
const realParent = fs.realpathSync(parent);
|
|
199
|
+
if (!isInside(realTarget, realParent))
|
|
200
|
+
throw new Error(`Manifest destination parent escapes target root: ${destination}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function assertEntrypointWriteScope(preset, entrypoint, destination) {
|
|
204
|
+
assertPresetWriteScope(preset, destination);
|
|
205
|
+
if (!entrypoint.writes.some((scope) => matchesScope(scope, destination))) {
|
|
206
|
+
throw new Error(`Preset write scope violation for ${destination}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function matchesScope(scope, relativePath) {
|
|
210
|
+
const normalizedScope = toPosix(path.normalize(String(scope || "")));
|
|
211
|
+
if (normalizedScope.endsWith("/**")) {
|
|
212
|
+
const prefix = normalizedScope.slice(0, -3);
|
|
213
|
+
return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
|
|
214
|
+
}
|
|
215
|
+
return relativePath === normalizedScope;
|
|
216
|
+
}
|
|
217
|
+
function enforcePublicRedaction(manifest, writes, { outputRoot }) {
|
|
218
|
+
const publicWrites = writes.filter((write) => write.visibility === "public" || write.destination.startsWith("docs-release/"));
|
|
219
|
+
if (publicWrites.length === 0)
|
|
220
|
+
return;
|
|
221
|
+
const reportSource = normalizeManifestRelativePath(manifest.publicRedactionReport?.source || "", "Public redaction report source");
|
|
222
|
+
const reportPath = path.join(outputRoot, reportSource);
|
|
223
|
+
assertOutputSource(outputRoot, reportPath, reportSource);
|
|
224
|
+
const report = readJsonSafe(reportPath, null);
|
|
225
|
+
if (!report || report.status !== "pass")
|
|
226
|
+
throw new Error("Public materialization requires a passing public redaction report");
|
|
227
|
+
}
|
|
228
|
+
function materializeWrites(targetRoot, writes) {
|
|
229
|
+
const backups = [];
|
|
230
|
+
try {
|
|
231
|
+
for (const write of writes) {
|
|
232
|
+
const destinationPath = write.destinationPath;
|
|
233
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
234
|
+
const existed = fs.existsSync(destinationPath);
|
|
235
|
+
const backupPath = existed ? `${destinationPath}.backup-${process.pid}-${crypto.randomBytes(4).toString("hex")}` : "";
|
|
236
|
+
if (existed) {
|
|
237
|
+
fs.copyFileSync(destinationPath, backupPath);
|
|
238
|
+
backups.push({ destinationPath, backupPath, existed });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
backups.push({ destinationPath, backupPath: "", existed });
|
|
242
|
+
}
|
|
243
|
+
const tempPath = `${destinationPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
244
|
+
fs.copyFileSync(write.sourcePath, tempPath);
|
|
245
|
+
fs.renameSync(tempPath, destinationPath);
|
|
246
|
+
}
|
|
247
|
+
for (const backup of backups) {
|
|
248
|
+
if (backup.backupPath)
|
|
249
|
+
fs.rmSync(backup.backupPath, { force: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
for (const backup of backups.reverse()) {
|
|
254
|
+
try {
|
|
255
|
+
if (backup.existed && backup.backupPath && fs.existsSync(backup.backupPath))
|
|
256
|
+
fs.renameSync(backup.backupPath, backup.destinationPath);
|
|
257
|
+
else if (!backup.existed)
|
|
258
|
+
fs.rmSync(backup.destinationPath, { force: true });
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Preserve the original materialization failure.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function targetSnapshot(root) {
|
|
268
|
+
const entries = new Map();
|
|
269
|
+
for (const filePath of walkFiles(root)) {
|
|
270
|
+
const relative = toPosix(path.relative(root, filePath));
|
|
271
|
+
if (relative.startsWith(".harness/locks/"))
|
|
272
|
+
continue;
|
|
273
|
+
const stat = fs.lstatSync(filePath);
|
|
274
|
+
entries.set(relative, `${stat.size}:${sha256File(filePath)}`);
|
|
275
|
+
}
|
|
276
|
+
return entries;
|
|
277
|
+
}
|
|
278
|
+
function assertSnapshotsEqual(before, after, message) {
|
|
279
|
+
const changed = [];
|
|
280
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
281
|
+
for (const item of paths) {
|
|
282
|
+
if (before.get(item) !== after.get(item))
|
|
283
|
+
changed.push(item);
|
|
284
|
+
}
|
|
285
|
+
if (changed.length)
|
|
286
|
+
throw new Error(`${message}: ${changed.slice(0, 12).join(", ")}`);
|
|
287
|
+
}
|
|
288
|
+
function sha256File(filePath) {
|
|
289
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
290
|
+
}
|
|
291
|
+
function isInside(root, candidate) {
|
|
292
|
+
const relative = path.relative(root, candidate);
|
|
293
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
294
|
+
}
|
package/dist/lib/task-index.mjs
CHANGED
|
@@ -56,6 +56,7 @@ export function buildTaskIndex(targetInput) {
|
|
|
56
56
|
supersedes: task.supersedes || [],
|
|
57
57
|
supersededBy: task.supersededBy || "",
|
|
58
58
|
deletionState: task.deletionState || "active",
|
|
59
|
+
archiveMetadata: task.archiveMetadata || {},
|
|
59
60
|
hiddenByDefault: task.hiddenByDefault === true,
|
|
60
61
|
repairPrompt: task.repairPrompt || "",
|
|
61
62
|
repairActions: repairActions(task),
|
|
@@ -570,9 +570,11 @@ export function updateModuleStep(targetInput, moduleKey, stepId, { state = "" }
|
|
|
570
570
|
releaseGovernanceSync(governanceContext);
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
|
-
export function listLifecycleTasks(targetInput, { state = "", moduleKey = "", queue = "", preset = "", review = "", lesson = "", search = "", missingMaterials = false } = {}) {
|
|
573
|
+
export function listLifecycleTasks(targetInput, { state = "", moduleKey = "", queue = "", preset = "", review = "", lesson = "", search = "", missingMaterials = false, includeArchived = false } = {}) {
|
|
574
574
|
const target = normalizeTarget(targetInput);
|
|
575
575
|
let tasks = collectTasks(target);
|
|
576
|
+
if (!includeArchived)
|
|
577
|
+
tasks = tasks.filter((task) => task.deletionState === "active" && task.hiddenByDefault !== true);
|
|
576
578
|
if (state)
|
|
577
579
|
tasks = tasks.filter((task) => task.state === String(state).toLowerCase().replaceAll("-", "_"));
|
|
578
580
|
if (moduleKey)
|
|
@@ -58,6 +58,7 @@ export function parseTaskTombstone(taskPlanContent) {
|
|
|
58
58
|
supersededBy: "",
|
|
59
59
|
supersedes: topLevelSupersedes,
|
|
60
60
|
deleteReason: "",
|
|
61
|
+
archiveMetadata: {},
|
|
61
62
|
hiddenByDefault: false,
|
|
62
63
|
reopenEligible: false,
|
|
63
64
|
archiveEligible: false,
|
|
@@ -71,6 +72,7 @@ export function parseTaskTombstone(taskPlanContent) {
|
|
|
71
72
|
supersededBy: fields.get("superseded by") || fields.get("替代任务") || "",
|
|
72
73
|
supersedes: [...new Set([...topLevelSupersedes, ...splitList(fields.get("supersedes") || fields.get("合并自") || "")])],
|
|
73
74
|
deleteReason: fields.get("reason") || fields.get("原因") || "",
|
|
75
|
+
archiveMetadata: Object.fromEntries([...fields.entries()].filter(([key]) => !["state", "状态", "superseded by", "替代任务", "supersedes", "合并自", "reason", "原因", "reopen eligible", "可重新打开", "archive eligible", "可归档"].includes(key))),
|
|
74
76
|
hiddenByDefault: true,
|
|
75
77
|
reopenEligible: parseTombstoneBooleanLike(fields.get("reopen eligible") || fields.get("可重新打开")),
|
|
76
78
|
archiveEligible: parseTombstoneBooleanLike(fields.get("archive eligible") || fields.get("可归档")),
|
|
@@ -344,6 +344,7 @@ export function collectTasks(target, { requireGeneratedScaffoldProvenance = fals
|
|
|
344
344
|
supersededBy: tombstone.supersededBy,
|
|
345
345
|
supersedes: tombstone.supersedes,
|
|
346
346
|
deleteReason: tombstone.deleteReason,
|
|
347
|
+
archiveMetadata: tombstone.archiveMetadata || {},
|
|
347
348
|
hiddenByDefault: tombstone.hiddenByDefault,
|
|
348
349
|
reopenEligible: tombstone.reopenEligible,
|
|
349
350
|
archiveEligible: tombstone.archiveEligible,
|
|
@@ -40,6 +40,7 @@ export function softDeleteTask(targetInput, taskRef, { reason = "" } = {}) {
|
|
|
40
40
|
export function archiveTask(targetInput, taskRef, { reason = "" } = {}) {
|
|
41
41
|
const target = normalizeTarget(targetInput);
|
|
42
42
|
const task = resolveTask(target, taskRef);
|
|
43
|
+
assertArchiveEligible(task);
|
|
43
44
|
return writeDeletionState(target, task, "archived", reason || "archive", "task-archive");
|
|
44
45
|
}
|
|
45
46
|
export function reopenTask(targetInput, taskRef, { reason = "" } = {}) {
|
|
@@ -100,6 +101,17 @@ function resolveTask(target, ref) {
|
|
|
100
101
|
throw new Error(`Ambiguous task reference: ${ref}`);
|
|
101
102
|
throw new Error(`Task not found: ${ref}`);
|
|
102
103
|
}
|
|
104
|
+
function assertArchiveEligible(task) {
|
|
105
|
+
if (task.state === "blocked" || (task.taskQueues || []).includes("blocked")) {
|
|
106
|
+
throw new Error("blocked tasks cannot be archived without an explicit human waiver");
|
|
107
|
+
}
|
|
108
|
+
const blockingRisks = (task.risks || []).filter((risk) => risk.open !== "no" && (risk.blocksRelease === "yes" || ["P0", "P1", "P2"].includes(risk.severity)));
|
|
109
|
+
if (blockingRisks.length)
|
|
110
|
+
throw new Error("tasks with open blocking review findings cannot be archived without an explicit human waiver");
|
|
111
|
+
if (task.materialsReady === false && task.reviewStatus !== "confirmed") {
|
|
112
|
+
throw new Error("tasks with incomplete closeout materials cannot be archived without an explicit human waiver");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
103
115
|
function writeTombstone(target, task, fields) {
|
|
104
116
|
const taskPlanPath = path.join(target.projectRoot, task.taskPlanPath.replace(/^TARGET:/, ""));
|
|
105
117
|
const content = readFileSafe(taskPlanPath).replace(/\n##\s*(?:Task Tombstone|任务墓碑)\s*$[\s\S]*?(?=^##\s+|(?![\s\S]))/im, "");
|
package/docs-release/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Not every document is written for the same reader.
|
|
|
71
71
|
- `guides/migration-playbook.md` / `guides/migration-playbook.en-US.md` — smooth migration guide for existing legacy harness projects. 旧 Harness 项目的平滑迁移指南。
|
|
72
72
|
- `guides/legacy-migration-agent-prompt.md` / `guides/legacy-migration-agent-prompt.zh-CN.md` — prompt contract for agents running baseline or full legacy migration. 给迁移 Agent 使用的执行合同。
|
|
73
73
|
- `guides/full-legacy-migration-subagent-strategy.md` / `guides/full-legacy-migration-subagent-strategy.zh-CN.md` — full readable cutover strategy with subagent roles, adversarial review, and dashboard/CLI proof gates. 完整可读迁移的 subagent 分工、对抗审查和 Dashboard/CLI 证据门禁。
|
|
74
|
-
- `guides/typescript-runtime-migration-closeout.md` — public closeout for the TypeScript runtime source-twin migration and the
|
|
74
|
+
- `guides/typescript-runtime-migration-closeout.md` — public closeout for the TypeScript runtime source-twin migration and the documented preset/dashboard JavaScript exceptions. TypeScript runtime source twin 迁移收口和已记录的 preset/dashboard JavaScript 例外。
|
|
75
75
|
|
|
76
76
|
## Repository Operating Models / 仓库运行模式
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coding-agent-harness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Document governance kernel for long-running coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"build:runtime": "node scripts/build-dist.mts",
|
|
32
32
|
"prepack": "node scripts/build-dist.mts --quiet",
|
|
33
33
|
"typecheck": "npm exec --yes --package typescript@5.9.3 -- tsc -p tsconfig.json",
|
|
34
|
-
"typecheck:guards": "node dist/check-type-boundaries.mjs",
|
|
34
|
+
"typecheck:guards": "node dist/check-type-boundaries.mjs && node dist/check-no-ts-nocheck.mjs",
|
|
35
|
+
"check:nocheck": "node dist/check-no-ts-nocheck.mjs",
|
|
35
36
|
"status": "node dist/harness.mjs status --json .",
|
|
36
37
|
"dashboard": "node dist/harness.mjs dashboard --out tmp/harness-dashboard.html examples/minimal-project",
|
|
37
38
|
"dashboard:folder": "node dist/harness.mjs dashboard --out-dir tmp/harness-dashboard examples/minimal-project",
|
|
@@ -62,6 +63,9 @@
|
|
|
62
63
|
"docs-release/",
|
|
63
64
|
"examples/"
|
|
64
65
|
],
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "24"
|
|
68
|
+
},
|
|
65
69
|
"engines": {
|
|
66
70
|
"node": ">=24"
|
|
67
71
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const context = JSON.parse(fs.readFileSync(process.env.HARNESS_PRESET_CONTEXT, "utf8"));
|
|
7
|
+
const release = String(context.inputs.release || "").trim();
|
|
8
|
+
const releaseRoot = path.join(context.targetRoot, "coding-agent-harness/governance/releases", release);
|
|
9
|
+
const required = ["INDEX.md", "task-aggregate.json", "task-archive-plan.md", "public-summary.md", "public-redaction-report.json"];
|
|
10
|
+
const missing = required.filter((file) => !fs.existsSync(path.join(releaseRoot, file)));
|
|
11
|
+
if (missing.length) {
|
|
12
|
+
console.error(`release package missing files: ${missing.join(", ")}`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
const redaction = JSON.parse(fs.readFileSync(path.join(releaseRoot, "public-redaction-report.json"), "utf8"));
|
|
16
|
+
if (redaction.status !== "pass") {
|
|
17
|
+
console.error("release public redaction report is not passing");
|
|
18
|
+
process.exit(3);
|
|
19
|
+
}
|
|
20
|
+
fs.writeFileSync(context.materializationManifestPath, `${JSON.stringify({
|
|
21
|
+
schemaVersion: "preset-materialization/v1",
|
|
22
|
+
status: "pass",
|
|
23
|
+
writes: [],
|
|
24
|
+
}, null, 2)}\n`);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
id: release-closeout
|
|
2
|
+
version: 1
|
|
3
|
+
purpose: Create and run a release closeout task that aggregates version tasks into an auditable release package
|
|
4
|
+
compatibleBudgets: [complex]
|
|
5
|
+
localeSupport: [en-US, zh-CN]
|
|
6
|
+
task:
|
|
7
|
+
kind: release-closeout
|
|
8
|
+
defaultTaskId: release-closeout
|
|
9
|
+
defaultOutcome: Produce a release closeout task whose runner entrypoints generate the version package, archive plan, and public summary.
|
|
10
|
+
projectLevelOnly: true
|
|
11
|
+
inputs:
|
|
12
|
+
release:
|
|
13
|
+
type: text
|
|
14
|
+
flag: --release
|
|
15
|
+
required: true
|
|
16
|
+
previousRelease:
|
|
17
|
+
type: text
|
|
18
|
+
flag: --previous-release
|
|
19
|
+
required: false
|
|
20
|
+
nextRelease:
|
|
21
|
+
type: text
|
|
22
|
+
flag: --next-release
|
|
23
|
+
required: false
|
|
24
|
+
taskQuery:
|
|
25
|
+
type: text
|
|
26
|
+
flag: --task-query
|
|
27
|
+
required: false
|
|
28
|
+
taskList:
|
|
29
|
+
type: json-file
|
|
30
|
+
flag: --task-list
|
|
31
|
+
required: false
|
|
32
|
+
publicSummary:
|
|
33
|
+
type: flag
|
|
34
|
+
flag: --public-summary
|
|
35
|
+
required: false
|
|
36
|
+
templateValues:
|
|
37
|
+
release:
|
|
38
|
+
from: inputs.release
|
|
39
|
+
previousRelease:
|
|
40
|
+
from: inputs.previousRelease
|
|
41
|
+
nextRelease:
|
|
42
|
+
from: inputs.nextRelease
|
|
43
|
+
taskQuery:
|
|
44
|
+
from: inputs.taskQuery
|
|
45
|
+
taskId:
|
|
46
|
+
from: task.id
|
|
47
|
+
entrypoints:
|
|
48
|
+
newTask:
|
|
49
|
+
type: template
|
|
50
|
+
writes: [coding-agent-harness/planning/tasks/**]
|
|
51
|
+
audit: true
|
|
52
|
+
templates:
|
|
53
|
+
taskPlanAppend: templates/task_plan.append.md
|
|
54
|
+
executionStrategyAppend: templates/execution_strategy.append.md
|
|
55
|
+
findingsSeed: templates/findings.seed.md
|
|
56
|
+
reviewSeed: templates/review.seed.md
|
|
57
|
+
plan:
|
|
58
|
+
type: script
|
|
59
|
+
command: scripts/generate-release-package.mjs
|
|
60
|
+
writes: [coding-agent-harness/governance/releases/**]
|
|
61
|
+
reads: [coding-agent-harness/planning/tasks/**]
|
|
62
|
+
audit: true
|
|
63
|
+
scaffold:
|
|
64
|
+
type: script
|
|
65
|
+
command: scripts/generate-release-package.mjs
|
|
66
|
+
writes: [coding-agent-harness/governance/releases/**]
|
|
67
|
+
reads: [coding-agent-harness/planning/tasks/**]
|
|
68
|
+
audit: true
|
|
69
|
+
check:
|
|
70
|
+
type: check
|
|
71
|
+
command: checks/check-release-package.mjs
|
|
72
|
+
writes: [coding-agent-harness/governance/releases/**]
|
|
73
|
+
reads: [coding-agent-harness/governance/releases/**, coding-agent-harness/planning/tasks/**]
|
|
74
|
+
audit: true
|
|
75
|
+
evidence:
|
|
76
|
+
bundleDir: artifacts/preset
|
|
77
|
+
files:
|
|
78
|
+
release:
|
|
79
|
+
path: release.txt
|
|
80
|
+
type: text
|
|
81
|
+
value: inputs.release
|
|
82
|
+
presetAudit:
|
|
83
|
+
path: preset-audit.json
|
|
84
|
+
type: preset-audit
|
|
85
|
+
presetManifest:
|
|
86
|
+
path: preset-manifest.json
|
|
87
|
+
type: preset-manifest
|
|
88
|
+
writeScope:
|
|
89
|
+
path: write-scope.json
|
|
90
|
+
type: write-scope
|
|
91
|
+
audit:
|
|
92
|
+
manifestRequired: true
|
|
93
|
+
evidenceFiles: [preset-audit.json, preset-manifest.json, write-scope.json]
|
|
94
|
+
writeScopes:
|
|
95
|
+
taskDocs:
|
|
96
|
+
path: coding-agent-harness/planning/tasks/**
|
|
97
|
+
access: write
|
|
98
|
+
releasePackage:
|
|
99
|
+
path: coding-agent-harness/governance/releases/**
|
|
100
|
+
access: write
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const context = JSON.parse(fs.readFileSync(process.env.HARNESS_PRESET_CONTEXT, "utf8"));
|
|
7
|
+
const release = safeRelease(context.inputs.release);
|
|
8
|
+
if (!release) {
|
|
9
|
+
console.error("release-closeout requires inputs.release");
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const tasksRoot = path.join(context.targetRoot, "coding-agent-harness/planning/tasks");
|
|
14
|
+
const releaseRoot = path.join(context.outputRoot, "release");
|
|
15
|
+
fs.mkdirSync(releaseRoot, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const tasks = collectTasks(tasksRoot)
|
|
18
|
+
.filter((task) => task.id !== context.task.id && task.preset !== "release-closeout")
|
|
19
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
20
|
+
const eligibleArchive = tasks.filter((task) => task.state === "done" && task.deletionState !== "superseded");
|
|
21
|
+
const blockedArchive = tasks.filter((task) => task.state === "blocked" || task.queue === "blocked");
|
|
22
|
+
|
|
23
|
+
const aggregate = {
|
|
24
|
+
schemaVersion: "release-closeout-aggregate/v1",
|
|
25
|
+
release,
|
|
26
|
+
generatedAt: new Date().toISOString(),
|
|
27
|
+
summary: {
|
|
28
|
+
totalTasks: tasks.length,
|
|
29
|
+
doneTasks: tasks.filter((task) => task.state === "done").length,
|
|
30
|
+
blockedTasks: tasks.filter((task) => task.state === "blocked").length,
|
|
31
|
+
archiveEligibleTasks: eligibleArchive.length,
|
|
32
|
+
},
|
|
33
|
+
tasks: tasks.map((task) => ({
|
|
34
|
+
id: task.id,
|
|
35
|
+
title: task.title,
|
|
36
|
+
state: task.state,
|
|
37
|
+
preset: task.preset || "none",
|
|
38
|
+
deletionState: task.deletionState,
|
|
39
|
+
archiveEligible: eligibleArchive.some((candidate) => candidate.id === task.id),
|
|
40
|
+
archiveBlockedReason: task.state === "blocked" ? "blocked tasks cannot be archived" : "",
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const index = `# Release Closeout Package: ${release}
|
|
45
|
+
|
|
46
|
+
Generated by \`presets/release-closeout\` through the generic preset runner.
|
|
47
|
+
|
|
48
|
+
| Document | Purpose |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| \`task-aggregate.json\` | Machine-readable task inventory for this release closeout. |
|
|
51
|
+
| \`task-archive-plan.md\` | Preset-owned archive eligibility plan. |
|
|
52
|
+
| \`public-summary.md\` | Redacted public-facing summary. |
|
|
53
|
+
| \`public-redaction-report.json\` | Redaction evidence for public materialization. |
|
|
54
|
+
|
|
55
|
+
## Summary
|
|
56
|
+
|
|
57
|
+
- Total tasks: ${aggregate.summary.totalTasks}
|
|
58
|
+
- Done tasks: ${aggregate.summary.doneTasks}
|
|
59
|
+
- Blocked tasks: ${aggregate.summary.blockedTasks}
|
|
60
|
+
- Archive eligible tasks: ${aggregate.summary.archiveEligibleTasks}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const archivePlan = `# Release ${release} Task Archive Plan
|
|
64
|
+
|
|
65
|
+
This plan is generated by the release-closeout preset. It does not archive tasks by itself.
|
|
66
|
+
|
|
67
|
+
## Eligible Tasks
|
|
68
|
+
|
|
69
|
+
${eligibleArchive.length ? eligibleArchive.map((task) => `- ${task.id} - ${task.title}`).join("\n") : "- none"}
|
|
70
|
+
|
|
71
|
+
## Not Eligible
|
|
72
|
+
|
|
73
|
+
${blockedArchive.length ? blockedArchive.map((task) => `- ${task.id} - blocked tasks cannot be archived`).join("\n") : "- none"}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const publicSummaryRaw = `# Release ${release} Public Summary
|
|
77
|
+
|
|
78
|
+
This release closeout aggregates ${aggregate.summary.totalTasks} tasks, including ${aggregate.summary.doneTasks} done tasks and ${aggregate.summary.blockedTasks} blocked tasks.
|
|
79
|
+
|
|
80
|
+
Recent evidence snippets:
|
|
81
|
+
|
|
82
|
+
${tasks.slice(0, 20).map((task) => `- ${task.id}: ${task.evidenceSnippet || "no evidence snippet"}`).join("\n")}
|
|
83
|
+
`;
|
|
84
|
+
const publicSummary = redactPublic(publicSummaryRaw);
|
|
85
|
+
const redactionReport = {
|
|
86
|
+
schemaVersion: "public-redaction-report/v1",
|
|
87
|
+
status: leaksLocalPath(publicSummary) ? "fail" : "pass",
|
|
88
|
+
rules: ["local-absolute-paths", "file-urls"],
|
|
89
|
+
checkedFiles: ["public-summary.md"],
|
|
90
|
+
findings: leaksLocalPath(publicSummary) ? ["public-summary contains local absolute path"] : [],
|
|
91
|
+
generatedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
if (redactionReport.status !== "pass") {
|
|
94
|
+
console.error("public redaction failed");
|
|
95
|
+
process.exit(3);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
write("INDEX.md", index);
|
|
99
|
+
write("task-archive-plan.md", archivePlan);
|
|
100
|
+
write("task-aggregate.json", `${JSON.stringify(aggregate, null, 2)}\n`);
|
|
101
|
+
write("public-summary.md", publicSummary);
|
|
102
|
+
write("public-redaction-report.json", `${JSON.stringify(redactionReport, null, 2)}\n`);
|
|
103
|
+
|
|
104
|
+
const destinationRoot = `coding-agent-harness/governance/releases/${release}`;
|
|
105
|
+
fs.writeFileSync(context.materializationManifestPath, `${JSON.stringify({
|
|
106
|
+
schemaVersion: "preset-materialization/v1",
|
|
107
|
+
status: "ok",
|
|
108
|
+
publicRedactionReport: { source: "release/public-redaction-report.json" },
|
|
109
|
+
writes: [
|
|
110
|
+
{ source: "release/INDEX.md", destination: `${destinationRoot}/INDEX.md`, type: "text" },
|
|
111
|
+
{ source: "release/task-archive-plan.md", destination: `${destinationRoot}/task-archive-plan.md`, type: "text" },
|
|
112
|
+
{ source: "release/task-aggregate.json", destination: `${destinationRoot}/task-aggregate.json`, type: "json" },
|
|
113
|
+
{ source: "release/public-summary.md", destination: `${destinationRoot}/public-summary.md`, type: "text", visibility: "public" },
|
|
114
|
+
{ source: "release/public-redaction-report.json", destination: `${destinationRoot}/public-redaction-report.json`, type: "json" },
|
|
115
|
+
],
|
|
116
|
+
}, null, 2)}\n`);
|
|
117
|
+
|
|
118
|
+
function write(name, content) {
|
|
119
|
+
fs.writeFileSync(path.join(releaseRoot, name), content.endsWith("\n") ? content : `${content}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectTasks(root) {
|
|
123
|
+
if (!fs.existsSync(root)) return [];
|
|
124
|
+
const taskPlans = walk(root).filter((file) => path.basename(file) === "task_plan.md");
|
|
125
|
+
return taskPlans.map((taskPlanPath) => {
|
|
126
|
+
const taskDir = path.dirname(taskPlanPath);
|
|
127
|
+
const taskPlan = read(taskPlanPath);
|
|
128
|
+
const progress = read(path.join(taskDir, "progress.md"));
|
|
129
|
+
const tombstone = parseTombstone(taskPlan);
|
|
130
|
+
return {
|
|
131
|
+
id: path.basename(taskDir),
|
|
132
|
+
title: titleFrom(taskPlan, path.basename(taskDir)),
|
|
133
|
+
preset: metadataLine(taskPlan, ["Task Preset", "Preset"]).toLowerCase(),
|
|
134
|
+
state: parseState(progress),
|
|
135
|
+
deletionState: tombstone.state || "active",
|
|
136
|
+
queue: parseState(progress) === "blocked" ? "blocked" : "active",
|
|
137
|
+
evidenceSnippet: progress.split(/\r?\n/).find((line) => /\/Users\/|\/Volumes\/|file:\/\//.test(line)) || "",
|
|
138
|
+
tombstone,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function walk(root) {
|
|
144
|
+
const files = [];
|
|
145
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
146
|
+
const full = path.join(root, entry.name);
|
|
147
|
+
if (entry.isDirectory()) files.push(...walk(full));
|
|
148
|
+
else if (entry.isFile()) files.push(full);
|
|
149
|
+
}
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function read(filePath) {
|
|
154
|
+
try {
|
|
155
|
+
return fs.readFileSync(filePath, "utf8");
|
|
156
|
+
} catch {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function titleFrom(content, fallback) {
|
|
162
|
+
const match = String(content || "").match(/^#\s+(.+)$/m);
|
|
163
|
+
return match ? match[1].trim() : fallback;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function metadataLine(content, labels) {
|
|
167
|
+
const escaped = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
168
|
+
const match = String(content || "").match(new RegExp(`^(?:${escaped})\\s*[::]\\s*([^\\n]+)`, "im"));
|
|
169
|
+
return match ? match[1].replace(/`/g, "").trim() : "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseState(progress) {
|
|
173
|
+
const match = String(progress || "").match(/^##\s*(?:Current Status|Status|状态)\s*[::]?\s*(?:\n\s*)?([^\n]+)/im);
|
|
174
|
+
const raw = (match ? match[1] : "").trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
|
|
175
|
+
return ["not_started", "planned", "in_progress", "review", "blocked", "done"].includes(raw) ? raw : "unknown";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseTombstone(content) {
|
|
179
|
+
const match = String(content || "").match(/^##\s*(?:Task Tombstone|任务墓碑)\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/im);
|
|
180
|
+
if (!match) return { state: "active", fields: {} };
|
|
181
|
+
const fields = {};
|
|
182
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
183
|
+
const row = line.trim();
|
|
184
|
+
if (!row.startsWith("|") || /---/.test(row) || /Field\s*\|\s*Value/i.test(row)) continue;
|
|
185
|
+
const cells = row.slice(1, -1).split("|").map((cell) => cell.trim().toLowerCase());
|
|
186
|
+
if (cells.length >= 2) fields[cells[0]] = cells.slice(1).join("|").trim();
|
|
187
|
+
}
|
|
188
|
+
return { state: fields.state || "soft-deleted", fields };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function safeRelease(value) {
|
|
192
|
+
const release = String(value || "").trim();
|
|
193
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(release) ? release : "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function redactPublic(value) {
|
|
197
|
+
return String(value || "")
|
|
198
|
+
.replace(/file:\/\/\/[^\s)"'`<>\]]+/g, "LOCAL_FILE_URL_REDACTED")
|
|
199
|
+
.replaceAll("file://", "LOCAL_FILE_URL_REDACTED")
|
|
200
|
+
.replace(/\/Users\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
|
|
201
|
+
.replace(/\/Volumes\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
|
|
202
|
+
.replace(/\/(?:private\/)?tmp\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
|
|
203
|
+
.replace(/\/var\/folders\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
|
|
204
|
+
.replace(/\/home\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
|
|
205
|
+
.replace(/[A-Za-z]:\\[^\s)"'`<>\]]+(?:\\[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function leaksLocalPath(value) {
|
|
209
|
+
return /(?:^|[\s"'(])(?:\/Users\/|\/Volumes\/|\/tmp\/|\/private\/tmp\/|\/var\/folders\/|\/home\/|[A-Za-z]:\\|file:\/\/)/.test(String(value || ""));
|
|
210
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## Release Closeout Strategy
|
|
2
|
+
|
|
3
|
+
| Phase ID | Depends On | State | Completion | Output | Required Evidence | Evidence Status | Blocking Risk | Owner / Handoff |
|
|
4
|
+
| --- | --- | --- | ---: | --- | --- | --- | --- | --- |
|
|
5
|
+
| RC-PLAN | none | planned | 0 | Release task inventory and archive eligibility plan | `harness preset run release-closeout plan --task {{taskId}} .` | missing | none | coordinator |
|
|
6
|
+
| RC-SCAFFOLD | RC-PLAN | planned | 0 | Version package materialized under governance releases | `harness preset run release-closeout scaffold --task {{taskId}} .` | missing | none | coordinator |
|
|
7
|
+
| RC-CHECK | RC-SCAFFOLD | planned | 0 | Release package validation report | `harness preset run release-closeout check --task {{taskId}} .` | missing | none | coordinator |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
## Release Closeout
|
|
2
|
+
|
|
3
|
+
Release Version: {{release}}
|
|
4
|
+
Previous Release: {{previousRelease}}
|
|
5
|
+
Next Release: {{nextRelease}}
|
|
6
|
+
Task Query: {{taskQuery}}
|
|
7
|
+
|
|
8
|
+
## Release Package Workflow
|
|
9
|
+
|
|
10
|
+
Run these preset entrypoints from the target root. The task scaffold only records the release intent; version package files are generated by the generic preset runner.
|
|
11
|
+
|
|
12
|
+
1. `harness preset run release-closeout plan --task {{taskId}} .`
|
|
13
|
+
2. `harness preset run release-closeout scaffold --task {{taskId}} .`
|
|
14
|
+
3. `harness preset run release-closeout check --task {{taskId}} .`
|
|
15
|
+
|
|
16
|
+
## Release Package Outputs
|
|
17
|
+
|
|
18
|
+
| Output | Owner |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `coding-agent-harness/governance/releases/{{release}}/INDEX.md` | `presets/release-closeout` |
|
|
21
|
+
| `coding-agent-harness/governance/releases/{{release}}/task-aggregate.json` | `presets/release-closeout` |
|
|
22
|
+
| `coding-agent-harness/governance/releases/{{release}}/task-archive-plan.md` | `presets/release-closeout` |
|
|
23
|
+
| `coding-agent-harness/governance/releases/{{release}}/public-summary.md` | `presets/release-closeout` |
|
|
24
|
+
| `coding-agent-harness/governance/releases/{{release}}/public-redaction-report.json` | `presets/release-closeout` |
|
|
@@ -25,8 +25,8 @@ The PR body must include:
|
|
|
25
25
|
|
|
26
26
|
## Content Rules
|
|
27
27
|
|
|
28
|
-
- State the target version explicitly. If `package.json` changes from
|
|
29
|
-
to
|
|
28
|
+
- State the target version explicitly. If `package.json` changes from one
|
|
29
|
+
version to another, say so in Version Impact.
|
|
30
30
|
- List changed surfaces by user-visible area or module, not by dumping every
|
|
31
31
|
file path.
|
|
32
32
|
- Verification must name the real commands, browser checks, CI runs, or
|
|
@@ -25,8 +25,8 @@ then the localized section follows after the English section.
|
|
|
25
25
|
|
|
26
26
|
## Content Rules
|
|
27
27
|
|
|
28
|
-
- State the target version explicitly. If `package.json` changes from
|
|
29
|
-
to
|
|
28
|
+
- State the target version explicitly. If `package.json` changes from one
|
|
29
|
+
version to another, say so in Version Impact.
|
|
30
30
|
- List changed surfaces by user-visible area or module, not by dumping every
|
|
31
31
|
file path.
|
|
32
32
|
- Verification must name the real commands, browser checks, CI runs, or
|
|
@@ -20,7 +20,7 @@ PR body 必须包含:
|
|
|
20
20
|
|
|
21
21
|
## 内容规则
|
|
22
22
|
|
|
23
|
-
- 必须明确目标版本。如果 `package.json`
|
|
23
|
+
- 必须明确目标版本。如果 `package.json` 从一个版本变为另一个版本,就在版本影响里写清楚。
|
|
24
24
|
- 改动内容按用户可见面或模块总结,不要只堆文件路径。
|
|
25
25
|
- 验证必须列真实命令、浏览器检查、CI run 或证据产物。没有跑的检查必须说明原因。
|
|
26
26
|
- 审查证据必须说明自查、subagent 审查、人工审查或代码质量审查状态。release-blocking finding 必须在 merge 前关闭或路由。
|