coding-agent-harness 1.0.4 → 1.0.5

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +661 -21
  3. package/LICENSE-EXCEPTION.md +37 -0
  4. package/README.md +33 -1
  5. package/README.zh-CN.md +23 -1
  6. package/SKILL.md +9 -8
  7. package/docs-release/architecture/overview.md +1 -1
  8. package/docs-release/architecture/overview.zh-CN.md +1 -1
  9. package/docs-release/architecture/system-explainer/01-system-overview.md +217 -0
  10. package/docs-release/architecture/system-explainer/02-module-dependency.md +257 -0
  11. package/docs-release/architecture/system-explainer/03-task-lifecycle.md +304 -0
  12. package/docs-release/architecture/system-explainer/04-check-and-governance.md +239 -0
  13. package/docs-release/architecture/system-explainer/05-data-flow.md +276 -0
  14. package/docs-release/architecture/system-explainer/06-preset-and-migration.md +303 -0
  15. package/docs-release/architecture/system-explainer/README.md +67 -0
  16. package/docs-release/architecture/system-explainer/en-US/01-system-overview.md +226 -0
  17. package/docs-release/architecture/system-explainer/en-US/02-module-dependency.md +263 -0
  18. package/docs-release/architecture/system-explainer/en-US/03-task-lifecycle.md +319 -0
  19. package/docs-release/architecture/system-explainer/en-US/04-check-and-governance.md +250 -0
  20. package/docs-release/architecture/system-explainer/en-US/05-data-flow.md +290 -0
  21. package/docs-release/architecture/system-explainer/en-US/06-preset-and-migration.md +323 -0
  22. package/docs-release/architecture/system-explainer/en-US/README.md +70 -0
  23. package/docs-release/guides/agent-installation.en-US.md +8 -7
  24. package/docs-release/guides/agent-installation.md +9 -7
  25. package/docs-release/guides/preset-development.md +26 -2
  26. package/docs-release/guides/task-state-machine.en-US.md +30 -13
  27. package/docs-release/guides/task-state-machine.md +30 -13
  28. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/INDEX.md +60 -0
  29. package/package.json +3 -2
  30. package/references/harness-ledger.md +1 -1
  31. package/scripts/commands/migration-command.mjs +30 -0
  32. package/scripts/commands/task-command.mjs +26 -25
  33. package/scripts/harness.mjs +7 -3
  34. package/scripts/lib/capability-registry.mjs +17 -21
  35. package/scripts/lib/check-module-parallel.mjs +9 -16
  36. package/scripts/lib/check-profiles.mjs +35 -81
  37. package/scripts/lib/check-task-contracts.mjs +13 -5
  38. package/scripts/lib/core-shared.mjs +55 -2
  39. package/scripts/lib/dashboard-data.mjs +126 -18
  40. package/scripts/lib/dashboard-workbench.mjs +80 -1
  41. package/scripts/lib/dashboard-writer.mjs +6 -2
  42. package/scripts/lib/git-status-summary.mjs +1 -1
  43. package/scripts/lib/governance-sync.mjs +180 -83
  44. package/scripts/lib/harness-core.mjs +1 -0
  45. package/scripts/lib/markdown-utils.mjs +33 -0
  46. package/scripts/lib/migration-planner.mjs +4 -6
  47. package/scripts/lib/phase-kind.mjs +50 -0
  48. package/scripts/lib/preset-engine.mjs +5 -8
  49. package/scripts/lib/preset-registry.mjs +188 -39
  50. package/scripts/lib/review-confirm-git-gate.mjs +1 -1
  51. package/scripts/lib/status-builder.mjs +88 -0
  52. package/scripts/lib/status-dashboard-renderer.mjs +7 -4
  53. package/scripts/lib/task-audit-metadata.mjs +385 -0
  54. package/scripts/lib/task-audit-migration.mjs +350 -0
  55. package/scripts/lib/task-completion-consistency.mjs +11 -1
  56. package/scripts/lib/task-lifecycle/create-task-helpers.mjs +67 -0
  57. package/scripts/lib/task-lifecycle/phase-sync.mjs +88 -0
  58. package/scripts/lib/task-lifecycle/review-confirm.mjs +40 -29
  59. package/scripts/lib/task-lifecycle/review-gates.mjs +13 -10
  60. package/scripts/lib/task-lifecycle/review-submission.mjs +63 -0
  61. package/scripts/lib/task-lifecycle/scaffold-provenance.mjs +49 -0
  62. package/scripts/lib/task-lifecycle/template-files.mjs +53 -0
  63. package/scripts/lib/task-lifecycle.mjs +114 -147
  64. package/scripts/lib/task-metadata.mjs +118 -0
  65. package/scripts/lib/task-review-model.mjs +54 -68
  66. package/scripts/lib/task-scanner.mjs +70 -143
  67. package/skills/preset-creator/references/complex-task-skeleton/brief.md +11 -0
  68. package/templates/AGENTS.md.template +7 -5
  69. package/templates/dashboard/assets/app-src/00-state.js +12 -0
  70. package/templates/dashboard/assets/app-src/10-router.js +3 -0
  71. package/templates/dashboard/assets/app-src/20-overview.js +7 -3
  72. package/templates/dashboard/assets/app-src/35-task-detail.js +46 -6
  73. package/templates/dashboard/assets/app-src/55-presets.js +375 -0
  74. package/templates/dashboard/assets/app-src/60-shared.js +3 -1
  75. package/templates/dashboard/assets/app-src/90-bindings.js +131 -0
  76. package/templates/dashboard/assets/app.css +583 -0
  77. package/templates/dashboard/assets/app.css.manifest.json +1 -0
  78. package/templates/dashboard/assets/app.js +578 -10
  79. package/templates/dashboard/assets/app.manifest.json +1 -0
  80. package/templates/dashboard/assets/css-src/00-foundation.css +4 -0
  81. package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +62 -0
  82. package/templates/dashboard/assets/css-src/45-presets.css +516 -0
  83. package/templates/dashboard/assets/i18n.js +140 -2
  84. package/templates/planning/INDEX.md +87 -0
  85. package/templates/planning/brief.md +1 -1
  86. package/templates/planning/module_session_prompt.md +1 -0
  87. package/templates/planning/review.md +0 -18
  88. package/templates/planning/task_plan.md +4 -43
  89. package/templates/planning/visual_map.md +13 -9
  90. package/templates/planning/visual_map.simple.md +52 -0
  91. package/templates/reference/execution-workflow-standard.md +29 -2
  92. package/templates-zh-CN/AGENTS.md.template +7 -5
  93. package/templates-zh-CN/planning/INDEX.md +87 -0
  94. package/templates-zh-CN/planning/brief.md +1 -1
  95. package/templates-zh-CN/planning/module_session_prompt.md +1 -0
  96. package/templates-zh-CN/planning/review.md +0 -18
  97. package/templates-zh-CN/planning/task_plan.md +3 -63
  98. package/templates-zh-CN/planning/visual_map.md +14 -7
  99. package/templates-zh-CN/planning/visual_map.simple.md +48 -0
  100. package/templates-zh-CN/reference/execution-workflow-standard.md +31 -6
@@ -1,23 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import os from "node:os";
3
4
  import crypto from "node:crypto";
5
+ import zlib from "node:zlib";
4
6
  import { builtinPresetRoot, projectPresetRoot, repoRoot, toPosix, userPresetRoot, userPresetRootForHome } from "./core-shared.mjs";
5
7
 
6
8
  const allowedEntrypoints = new Set(["newTask", "plan", "scaffold", "check"]);
7
9
  const allowedEntrypointTypes = new Set(["template", "script", "check"]);
8
10
  const allowedEvidenceTypes = new Set(["text", "json", "input-json", "preset-audit", "preset-manifest", "write-scope", "migration-verify", "migration-ledger", "dashboard-hash", "target-git-status", "target-commit", "harness-version", "generated-at"]);
11
+ const allowedNewTaskTemplateKeys = new Set(["taskPlanAppend", "executionStrategyAppend", "visualMapAppend", "findingsSeed", "reviewSeed", "prompt"]);
12
+ const maxPresetArchiveBytes = 25 * 1024 * 1024;
13
+ const maxPresetArchiveUncompressedBytes = 50 * 1024 * 1024;
14
+ const maxPresetArchiveEntries = 500;
9
15
 
10
16
  export function listPresetPackages({ targetInput = "", home = "" } = {}) {
11
- const seen = new Set();
17
+ return listPresetPackageLayers({ targetInput, home }).filter((preset) => preset.effective);
18
+ }
19
+
20
+ export function listPresetPackageLayers({ targetInput = "", home = "" } = {}) {
21
+ const effectiveIds = new Set();
12
22
  const presets = [];
13
23
  for (const { root, source } of presetSearchRoots({ targetInput, home })) {
14
24
  if (!fs.existsSync(root)) continue;
15
25
  for (const entry of fs.readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
16
26
  const id = tryNormalizePresetId(entry.name);
17
27
  if (!id) continue;
18
- if (seen.has(id)) continue;
19
- seen.add(id);
20
- presets.push(readPresetPackage(id, { targetInput, home }));
28
+ const preset = readPresetPackageFromPath(path.join(root, id), source);
29
+ const effective = !effectiveIds.has(preset.id);
30
+ if (effective) effectiveIds.add(preset.id);
31
+ presets.push({ ...preset, effective });
21
32
  }
22
33
  }
23
34
  return presets;
@@ -67,13 +78,13 @@ export function checkPresetPackage(id, { targetInput = "", home = "" } = {}) {
67
78
  };
68
79
  }
69
80
 
70
- function readPresetPackageFromPath(directory) {
81
+ function readPresetPackageFromPath(directory, source = "local") {
71
82
  const manifestPath = path.join(directory, "preset.yaml");
72
83
  assertPresetDirectory(directory);
73
84
  assertPresetManifestFile(directory, manifestPath);
74
85
  const raw = fs.readFileSync(manifestPath, "utf8");
75
86
  const manifest = parseSimpleYaml(raw);
76
- return normalizePresetManifest(manifest, { id: normalizePresetId(manifest.id || path.basename(directory)), manifestPath, raw, source: "local" });
87
+ return normalizePresetManifest(manifest, { id: normalizePresetId(manifest.id || path.basename(directory)), manifestPath, raw, source });
77
88
  }
78
89
 
79
90
  function assertPresetDirectory(directory) {
@@ -95,38 +106,43 @@ function assertPresetManifestFile(directory, manifestPath) {
95
106
 
96
107
  export function installPresetPackage(source, { force = false, scope = "user", targetInput = ".", home = "" } = {}) {
97
108
  if (!source) throw new Error("Missing preset source");
98
- const sourcePath = resolveInstallSource(source);
99
- const stagedPreset = readPresetPackageFromPath(sourcePath);
100
- const stagedReport = validatePresetPackage(stagedPreset);
101
- if (stagedReport.failures.length) throw new Error(`Invalid preset package ${stagedPreset.id}: ${stagedReport.failures.join("; ")}`);
102
- const id = stagedPreset.id;
103
- if (!id) throw new Error("Preset manifest missing id");
104
- const destination = scope === "project" ? projectPresetDestination(id, targetInput) : userPresetDestination(id, { home });
105
- if (fs.existsSync(destination)) {
106
- if (!force) throw new Error(`Preset already installed: ${id}. Re-run with --force to overwrite.`);
107
- }
108
- fs.mkdirSync(path.dirname(destination), { recursive: true });
109
- const tempDestination = path.join(path.dirname(destination), `.${id}.install-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
110
- fs.rmSync(tempDestination, { recursive: true, force: true });
111
- copyDirectory(sourcePath, tempDestination);
109
+ const resolvedSource = resolveInstallSource(source);
112
110
  try {
113
- const tempPreset = readPresetPackageFromPath(tempDestination);
114
- const tempReport = validatePresetPackage(tempPreset);
115
- if (tempReport.failures.length) throw new Error(`Invalid preset package ${id}: ${tempReport.failures.join("; ")}`);
116
- fs.rmSync(destination, { recursive: true, force: true });
117
- fs.renameSync(tempDestination, destination);
118
- const preset = readPresetPackage(id, scope === "project" ? { targetInput, home } : { home });
119
- return {
120
- installed: true,
121
- id: preset.id,
122
- version: preset.version,
123
- source: preset.source,
124
- destination: toPosix(destination),
125
- manifestPath: preset.manifestRelativePath,
126
- };
127
- } catch (error) {
111
+ const sourcePath = resolvedSource.path;
112
+ const stagedPreset = readPresetPackageFromPath(sourcePath);
113
+ const stagedReport = validatePresetPackage(stagedPreset);
114
+ if (stagedReport.failures.length) throw new Error(`Invalid preset package ${stagedPreset.id}: ${stagedReport.failures.join("; ")}`);
115
+ const id = stagedPreset.id;
116
+ if (!id) throw new Error("Preset manifest missing id");
117
+ const destination = scope === "project" ? projectPresetDestination(id, targetInput) : userPresetDestination(id, { home });
118
+ if (fs.existsSync(destination)) {
119
+ if (!force) throw new Error(`Preset already installed: ${id}. Re-run with --force to overwrite.`);
120
+ }
121
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
122
+ const tempDestination = path.join(path.dirname(destination), `.${id}.install-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
128
123
  fs.rmSync(tempDestination, { recursive: true, force: true });
129
- throw error;
124
+ copyDirectory(sourcePath, tempDestination);
125
+ try {
126
+ const tempPreset = readPresetPackageFromPath(tempDestination);
127
+ const tempReport = validatePresetPackage(tempPreset);
128
+ if (tempReport.failures.length) throw new Error(`Invalid preset package ${id}: ${tempReport.failures.join("; ")}`);
129
+ fs.rmSync(destination, { recursive: true, force: true });
130
+ fs.renameSync(tempDestination, destination);
131
+ const preset = readPresetPackage(id, scope === "project" ? { targetInput, home } : { home });
132
+ return {
133
+ installed: true,
134
+ id: preset.id,
135
+ version: preset.version,
136
+ source: preset.source,
137
+ destination: toPosix(destination),
138
+ manifestPath: preset.manifestRelativePath,
139
+ };
140
+ } catch (error) {
141
+ fs.rmSync(tempDestination, { recursive: true, force: true });
142
+ throw error;
143
+ }
144
+ } finally {
145
+ resolvedSource.cleanup();
130
146
  }
131
147
  }
132
148
 
@@ -226,7 +242,11 @@ export function validatePresetPackage(preset) {
226
242
  else validatePresetPackageFile(preset, entrypoint.command, `${name} command`, failures);
227
243
  }
228
244
  }
229
- for (const templatePath of Object.values(preset.newTaskTemplates)) {
245
+ for (const [templateKey, templatePath] of Object.entries(preset.newTaskTemplates)) {
246
+ if (!allowedNewTaskTemplateKeys.has(templateKey)) {
247
+ failures.push(`unsupported newTask template: ${templateKey}`);
248
+ continue;
249
+ }
230
250
  const absolute = path.join(preset.directory, templatePath);
231
251
  if (!isInside(preset.directory, absolute)) failures.push(`template escapes preset package: ${templatePath}`);
232
252
  else validatePresetPackageFile(preset, templatePath, "template", failures);
@@ -593,12 +613,141 @@ function presetSearchRoots({ targetInput = "", home = "" } = {}) {
593
613
 
594
614
  function resolveInstallSource(source) {
595
615
  const localPath = path.resolve(source);
596
- if (fs.existsSync(path.join(localPath, "preset.yaml"))) return localPath;
616
+ if (fs.existsSync(path.join(localPath, "preset.yaml"))) return { path: localPath, cleanup: () => {} };
617
+ if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
618
+ if (!localPath.toLowerCase().endsWith(".zip")) throw new Error(`Preset source file must be a .zip archive: ${toPosix(localPath)}`);
619
+ return resolveZipInstallSource(localPath);
620
+ }
597
621
  const builtinPath = path.join(builtinPresetRoot, normalizePresetId(source));
598
- if (fs.existsSync(path.join(builtinPath, "preset.yaml"))) return builtinPath;
622
+ if (fs.existsSync(path.join(builtinPath, "preset.yaml"))) return { path: builtinPath, cleanup: () => {} };
599
623
  throw new Error(`Preset source not found: ${source}`);
600
624
  }
601
625
 
626
+ function resolveZipInstallSource(sourcePath) {
627
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "harness-preset-archive-"));
628
+ try {
629
+ extractPresetZip(sourcePath, tempRoot);
630
+ return {
631
+ path: presetRootFromExtractedArchive(tempRoot),
632
+ cleanup: () => fs.rmSync(tempRoot, { recursive: true, force: true }),
633
+ };
634
+ } catch (error) {
635
+ fs.rmSync(tempRoot, { recursive: true, force: true });
636
+ throw error;
637
+ }
638
+ }
639
+
640
+ function presetRootFromExtractedArchive(tempRoot) {
641
+ if (fs.existsSync(path.join(tempRoot, "preset.yaml"))) return tempRoot;
642
+ const children = fs.readdirSync(tempRoot, { withFileTypes: true })
643
+ .filter((entry) => entry.name !== "__MACOSX" && entry.name !== ".DS_Store");
644
+ const presetDirs = children
645
+ .filter((entry) => entry.isDirectory())
646
+ .map((entry) => path.join(tempRoot, entry.name))
647
+ .filter((directory) => fs.existsSync(path.join(directory, "preset.yaml")));
648
+ if (presetDirs.length === 1) return presetDirs[0];
649
+ throw new Error("Preset archive must contain preset.yaml at the archive root or inside one top-level directory.");
650
+ }
651
+
652
+ function extractPresetZip(sourcePath, destinationRoot) {
653
+ const archiveStat = fs.statSync(sourcePath);
654
+ if (archiveStat.size > maxPresetArchiveBytes) throw new Error("Preset archive file is too large.");
655
+ const archive = fs.readFileSync(sourcePath);
656
+ const eocdOffset = findZipEndOfCentralDirectory(archive);
657
+ const entryCount = archive.readUInt16LE(eocdOffset + 10);
658
+ const centralSize = archive.readUInt32LE(eocdOffset + 12);
659
+ const centralOffset = archive.readUInt32LE(eocdOffset + 16);
660
+ if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) {
661
+ throw new Error("Zip64 preset archives are not supported.");
662
+ }
663
+ if (entryCount > maxPresetArchiveEntries) throw new Error(`Preset archive has too many entries: ${entryCount}`);
664
+ if (centralOffset + centralSize > archive.length) throw new Error("Invalid preset archive central directory.");
665
+ const written = new Set();
666
+ let cursor = centralOffset;
667
+ let totalUncompressed = 0;
668
+ for (let index = 0; index < entryCount; index += 1) {
669
+ if (archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error("Invalid preset archive central directory entry.");
670
+ const flags = archive.readUInt16LE(cursor + 8);
671
+ const method = archive.readUInt16LE(cursor + 10);
672
+ const compressedSize = archive.readUInt32LE(cursor + 20);
673
+ const uncompressedSize = archive.readUInt32LE(cursor + 24);
674
+ const nameLength = archive.readUInt16LE(cursor + 28);
675
+ const extraLength = archive.readUInt16LE(cursor + 30);
676
+ const commentLength = archive.readUInt16LE(cursor + 32);
677
+ const externalAttributes = archive.readUInt32LE(cursor + 38);
678
+ const localOffset = archive.readUInt32LE(cursor + 42);
679
+ const rawName = archive.slice(cursor + 46, cursor + 46 + nameLength).toString(flags & 0x0800 ? "utf8" : "utf8");
680
+ cursor += 46 + nameLength + extraLength + commentLength;
681
+ if (shouldSkipZipEntry(rawName)) continue;
682
+ if (flags & 0x0001) throw new Error(`Encrypted preset archive entries are not supported: ${rawName}`);
683
+ if (method !== 0 && method !== 8) throw new Error(`Unsupported preset archive compression method ${method}: ${rawName}`);
684
+ const mode = (externalAttributes >>> 16) & 0o170000;
685
+ if (mode === 0o120000) throw new Error(`Preset archive must not contain symlinks: ${rawName}`);
686
+ const entryName = safeZipEntryName(rawName);
687
+ if (!entryName) continue;
688
+ if (entryName.endsWith("/")) {
689
+ fs.mkdirSync(path.join(destinationRoot, entryName), { recursive: true });
690
+ continue;
691
+ }
692
+ if (written.has(entryName)) throw new Error(`Preset archive contains duplicate entry: ${entryName}`);
693
+ if (uncompressedSize > maxPresetArchiveUncompressedBytes - totalUncompressed) throw new Error("Preset archive is too large.");
694
+ const data = readZipEntryData(archive, { localOffset, compressedSize, uncompressedSize, method, name: entryName });
695
+ totalUncompressed += data.length;
696
+ const destination = path.resolve(destinationRoot, entryName);
697
+ if (!isInside(path.resolve(destinationRoot), destination)) throw new Error(`Preset archive entry escapes extraction root: ${rawName}`);
698
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
699
+ fs.writeFileSync(destination, data);
700
+ written.add(entryName);
701
+ }
702
+ }
703
+
704
+ function findZipEndOfCentralDirectory(archive) {
705
+ const minOffset = Math.max(0, archive.length - 22 - 65535);
706
+ for (let offset = archive.length - 22; offset >= minOffset; offset -= 1) {
707
+ if (archive.readUInt32LE(offset) === 0x06054b50) return offset;
708
+ }
709
+ throw new Error("Invalid preset zip archive: end of central directory not found.");
710
+ }
711
+
712
+ function readZipEntryData(archive, { localOffset, compressedSize, uncompressedSize, method, name }) {
713
+ if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50) {
714
+ throw new Error(`Invalid preset archive local header: ${name}`);
715
+ }
716
+ const localNameLength = archive.readUInt16LE(localOffset + 26);
717
+ const localExtraLength = archive.readUInt16LE(localOffset + 28);
718
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
719
+ const dataEnd = dataStart + compressedSize;
720
+ if (dataEnd > archive.length) throw new Error(`Invalid preset archive entry size: ${name}`);
721
+ const compressed = archive.slice(dataStart, dataEnd);
722
+ let data;
723
+ try {
724
+ data = method === 0 ? Buffer.from(compressed) : zlib.inflateRawSync(compressed, { maxOutputLength: uncompressedSize });
725
+ } catch (error) {
726
+ throw new Error(`Preset archive entry could not be decompressed within its declared size: ${name}`);
727
+ }
728
+ if (data.length !== uncompressedSize) throw new Error(`Preset archive entry size mismatch: ${name}`);
729
+ return data;
730
+ }
731
+
732
+ function shouldSkipZipEntry(rawName) {
733
+ const normalized = String(rawName || "").replace(/\\/g, "/");
734
+ return normalized === "__MACOSX/" || normalized.startsWith("__MACOSX/") || normalized.endsWith("/.DS_Store") || normalized === ".DS_Store";
735
+ }
736
+
737
+ function safeZipEntryName(rawName) {
738
+ if (String(rawName).includes("\0")) throw new Error("Preset archive entry contains NUL byte.");
739
+ const withSlashes = String(rawName || "").replace(/\\/g, "/");
740
+ if (/^[A-Za-z]:/.test(withSlashes) || withSlashes.startsWith("/")) {
741
+ throw new Error(`Preset archive entry must be relative: ${rawName}`);
742
+ }
743
+ const normalized = path.posix.normalize(withSlashes);
744
+ if (normalized === "." || normalized === "") return "";
745
+ if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
746
+ throw new Error(`Preset archive entry escapes extraction root: ${rawName}`);
747
+ }
748
+ return withSlashes.endsWith("/") ? `${normalized}/` : normalized;
749
+ }
750
+
602
751
  function copyDirectory(source, destination) {
603
752
  fs.mkdirSync(destination, { recursive: true });
604
753
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
@@ -141,7 +141,7 @@ function assertAllowedPaths(paths) {
141
141
  throw new ReviewConfirmGitGateError("Review confirmation write allowlist contains forbidden paths.", {
142
142
  code: "git-allowlist-forbidden-path",
143
143
  details: { disallowed },
144
- recovery: ["Limit review-confirm writes to the current task review.md and progress.md files."],
144
+ recovery: ["Limit review-confirm writes to the current task INDEX.md file."],
145
145
  });
146
146
  }
147
147
  }
@@ -0,0 +1,88 @@
1
+ import path from "node:path";
2
+ import { normalizeTarget, toPosix } from "./core-shared.mjs";
3
+ import { capabilityDefinitions, readCapabilityRegistry } from "./capability-registry.mjs";
4
+ import { summarizeGitState } from "./git-status-summary.mjs";
5
+ import { collectTasks, taskCutoverCounters } from "./task-scanner.mjs";
6
+
7
+ export function buildStatusData(targetInput, options = {}) {
8
+ const target = targetInput?.projectRoot ? targetInput : normalizeTarget(targetInput);
9
+ const validationMode = options.validationMode || "data-only";
10
+ const gitState = options.gitState || summarizeGitState(target);
11
+ const registry = options.capabilityState?.registry || readCapabilityRegistry(target);
12
+ const detected = options.capabilityState?.detected || [];
13
+ const capabilityWarnings = options.capabilityState?.warnings || [];
14
+ const failures = [...(options.failures || [])];
15
+ const warnings = [...(options.warnings || [])];
16
+ const legacy = options.legacy || { status: "skipped", code: 0, stdout: "", stderr: "" };
17
+ const tasks = options.tasks || collectTasks(target, {
18
+ requireGeneratedScaffoldProvenance: options.requireGeneratedScaffoldProvenance === true,
19
+ taskPlanPaths: options.taskPlanPaths,
20
+ closeoutContent: options.closeoutContent,
21
+ });
22
+ const briefReady = tasks.filter((task) => task.briefSource === "standalone").length;
23
+ const briefMissing = tasks.length - briefReady;
24
+ const capabilityNames = new Map(registry.capabilities.map((capability) => [capability.name, capability]));
25
+ for (const capability of detected) {
26
+ if (!capabilityNames.has(capability)) capabilityNames.set(capability, { name: capability, state: "configured" });
27
+ }
28
+ const cutoverCounters = taskCutoverCounters(tasks);
29
+ const fullCutoverEligible =
30
+ validationMode === "validated" &&
31
+ failures.length === 0 &&
32
+ warnings.length === 0 &&
33
+ cutoverCounters.legacyVisualOnlyCount === 0 &&
34
+ cutoverCounters.unknownClassificationCount === 0 &&
35
+ cutoverCounters.weakBriefCount === 0 &&
36
+ cutoverCounters.missingCanonicalVisualMapCount === 0;
37
+
38
+ return {
39
+ project: {
40
+ name: path.basename(target.projectRoot),
41
+ root: `TARGET:${target.docsOnly ? toPosix(path.relative(target.projectRoot, target.docsRoot)) : "."}`,
42
+ docsOnly: target.docsOnly,
43
+ },
44
+ schemaVersion: 2,
45
+ generatedAt: options.generatedAt || new Date().toISOString(),
46
+ mode: registry.mode,
47
+ checkState: {
48
+ status: failures.length > 0 ? "fail" : warnings.length > 0 ? "warn" : "pass",
49
+ validationMode,
50
+ failures: failures.length,
51
+ warnings: warnings.length,
52
+ details: { failures, warnings },
53
+ legacy,
54
+ },
55
+ git: gitState.summary,
56
+ summary: {
57
+ tasks: tasks.length,
58
+ briefCoverage: {
59
+ ready: briefReady,
60
+ missing: briefMissing,
61
+ total: tasks.length,
62
+ },
63
+ visualMapCoverage: {
64
+ canonical: tasks.filter((task) => task.visualMapSource === "canonical").length,
65
+ legacyOnly: cutoverCounters.legacyVisualOnlyCount,
66
+ missing: tasks.filter((task) => task.visualMapStatus === "missing").length,
67
+ total: tasks.length,
68
+ },
69
+ fullCutoverEligible,
70
+ legacyVisualOnlyCount: cutoverCounters.legacyVisualOnlyCount,
71
+ unknownClassificationCount: cutoverCounters.unknownClassificationCount,
72
+ weakBriefCount: cutoverCounters.weakBriefCount,
73
+ visualMapRequiredCount: cutoverCounters.visualMapRequiredCount,
74
+ missingCanonicalVisualMapCount: cutoverCounters.missingCanonicalVisualMapCount,
75
+ },
76
+ capabilities: [...capabilityNames.values()].map((capability) => ({
77
+ name: capability.name,
78
+ state: capability.state || "configured",
79
+ dependencyStatus: capabilityDefinitions[capability.name]?.dependencies.every((dependency) => capabilityNames.has(dependency))
80
+ ? "valid"
81
+ : "invalid",
82
+ warnings: capabilityWarnings.filter((warning) => warning.includes(capability.name)),
83
+ })),
84
+ tasks,
85
+ handoffs: tasks.flatMap((task) => task.handoffs || []),
86
+ recentActivity: tasks.slice(0, 8).map((task) => ({ at: new Date().toISOString(), type: "task", summary: task.title })),
87
+ };
88
+ }
@@ -1,13 +1,16 @@
1
+ import { implementationPhases } from "./phase-kind.mjs";
2
+
1
3
  export function renderDashboard(status) {
2
4
  const taskCards = status.tasks
3
5
  .map((task) => {
4
6
  const phases = task.phases
5
7
  .map(
6
- (phase) => `<div class="phase ${escapeHtml(phase.state)}">
7
- <div class="phase-top"><strong>${escapeHtml(phase.id)}</strong><span>${phase.completion}%</span></div>
8
+ (phase) => `<div class="phase ${escapeHtml(phase.state)} ${escapeHtml(phase.kind || "execution")}">
9
+ <div class="phase-top"><strong>${escapeHtml(phase.id)}</strong><span>${escapeHtml(phase.kind || "execution")} · ${phase.completion}%</span></div>
8
10
  <div class="phase-output">${escapeHtml(phase.output)}</div>
9
11
  <div class="meter"><i style="width:${phase.completion}%"></i></div>
10
- <div class="muted">${escapeHtml(phase.state)} · evidence ${escapeHtml(phase.evidenceStatus)}</div>
12
+ <div class="muted">${escapeHtml(phase.state)} · actor ${escapeHtml(phase.actor || "agent")} · evidence ${escapeHtml(phase.evidenceStatus)}</div>
13
+ ${phase.exitCommand ? `<div class="muted">exit ${escapeHtml(phase.exitCommand)}</div>` : ""}
11
14
  </div>`,
12
15
  )
13
16
  .join("");
@@ -91,7 +94,7 @@ function escapeHtml(value) {
91
94
  }
92
95
 
93
96
  function evidenceCompletion(phases) {
94
- const scored = phases.filter((phase) => phase.state !== "skipped");
97
+ const scored = implementationPhases(phases);
95
98
  if (scored.length === 0) return 0;
96
99
  const score = scored.reduce((sum, phase) => {
97
100
  if (["present", "waived"].includes(phase.evidenceStatus)) return sum + 100;