coding-agent-harness 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/CONTRIBUTING.md +8 -4
- package/README.md +12 -2
- package/README.zh-CN.md +10 -2
- package/SKILL.md +14 -3
- package/dist/build-dist.mjs +19 -6
- package/dist/check-dist-observation.mjs +57 -29
- package/dist/check-harness.mjs +0 -1
- package/dist/check-import-graph.mjs +44 -27
- package/dist/check-lite-forbidden-surfaces.mjs +121 -0
- package/dist/check-no-ts-nocheck.mjs +7 -7
- package/dist/check-runtime-emit.mjs +10 -3
- package/dist/check-type-boundaries.mjs +51 -9
- package/dist/commands/dashboard-command.mjs +52 -14
- package/dist/commands/migration-command.mjs +18 -8
- package/dist/commands/module-command.mjs +142 -0
- package/dist/commands/preset-command.mjs +51 -12
- package/dist/commands/registry.mjs +483 -0
- package/dist/commands/task-command.mjs +109 -52
- package/dist/harness.mjs +6 -304
- package/dist/lib/capability-registry.mjs +229 -53
- package/dist/lib/check-module-parallel.mjs +1 -6
- package/dist/lib/check-profiles.mjs +39 -46
- package/dist/lib/check-task-contracts.mjs +6 -4
- package/dist/lib/command-registry.mjs +248 -0
- package/dist/lib/core-shared.mjs +78 -3
- package/dist/lib/dashboard-data.mjs +203 -22
- package/dist/lib/dashboard-workbench.mjs +245 -21
- package/dist/lib/dashboard-writer.mjs +4 -1
- package/dist/lib/git-status-summary.mjs +0 -1
- package/dist/lib/governance-index-generator.mjs +7 -5
- package/dist/lib/governance-sync.mjs +46 -121
- package/dist/lib/governance-table-boundary.mjs +1 -14
- package/dist/lib/harness-core.mjs +4 -1
- package/dist/lib/harness-paths.mjs +115 -1
- package/dist/lib/impact-classifier.mjs +420 -0
- package/dist/lib/lesson-maintenance.mjs +1 -2
- package/dist/lib/markdown-utils.mjs +50 -1
- package/dist/lib/migration-planner.mjs +31 -16
- package/dist/lib/migration-support.mjs +5 -4
- package/dist/lib/module-registry.mjs +296 -0
- package/dist/lib/preset-audit-contracts.mjs +24 -1
- package/dist/lib/preset-engine.mjs +67 -29
- package/dist/lib/preset-registry.mjs +361 -71
- package/dist/lib/preset-runner.mjs +292 -26
- package/dist/lib/review-confirm-git-gate.mjs +73 -19
- package/dist/lib/status-builder.mjs +23 -8
- package/dist/lib/structure-migration.mjs +6 -4
- package/dist/lib/subagent-authorization-audit.mjs +8 -2
- package/dist/lib/task-archive-eligibility.mjs +65 -0
- package/dist/lib/task-audit-metadata.mjs +25 -11
- package/dist/lib/task-audit-migration.mjs +21 -14
- package/dist/lib/task-discovery-contract.mjs +32 -0
- package/dist/lib/task-index.mjs +3 -2
- package/dist/lib/task-lesson-candidates.mjs +1 -2
- package/dist/lib/task-lesson-sedimentation.mjs +310 -9
- package/dist/lib/task-lifecycle/create-task-helpers.mjs +6 -3
- package/dist/lib/task-lifecycle/phase-sync.mjs +0 -1
- package/dist/lib/task-lifecycle/preset-interop.mjs +16 -0
- package/dist/lib/task-lifecycle/review-confirm.mjs +34 -2
- package/dist/lib/task-lifecycle/review-gates.mjs +12 -5
- package/dist/lib/task-lifecycle/review-submission.mjs +1 -2
- package/dist/lib/task-lifecycle/scaffold-provenance.mjs +0 -1
- package/dist/lib/task-lifecycle/template-files.mjs +2 -5
- package/dist/lib/task-lifecycle.mjs +116 -160
- package/dist/lib/task-metadata.mjs +10 -5
- package/dist/lib/task-preset-contract-drift.mjs +45 -0
- package/dist/lib/task-repository.mjs +192 -0
- package/dist/lib/task-review-model.mjs +36 -17
- package/dist/lib/task-scanner.mjs +74 -23
- package/dist/lib/task-template-materials.mjs +131 -0
- package/dist/lib/task-tombstone-commands.mjs +186 -29
- package/dist/lib/types/check-profiles.js +1 -0
- package/dist/lib/types/impact.js +1 -0
- package/dist/lib/types/preset.js +1 -0
- package/dist/lib/types/task-lifecycle.js +1 -0
- package/dist/lib/types/task-scanner.js +1 -0
- package/dist/postinstall.mjs +2 -2
- package/dist/run-built-tests.mjs +10 -3
- package/docs-release/README.md +1 -0
- package/docs-release/architecture/document-contract-kernel/README.md +150 -0
- package/docs-release/architecture/document-contract-kernel/products/full-skill-overlay.md +29 -0
- package/docs-release/architecture/document-contract-kernel/products/lite-forbidden-surfaces.txt +26 -0
- package/docs-release/architecture/document-contract-kernel/products/lite-skill-overlay.md +37 -0
- package/docs-release/architecture/overview.md +2 -2
- package/docs-release/architecture/overview.zh-CN.md +2 -2
- package/docs-release/architecture/system-explainer/01-system-overview.md +11 -7
- package/docs-release/architecture/system-explainer/02-module-dependency.md +4 -4
- package/docs-release/architecture/system-explainer/03-task-lifecycle.md +17 -12
- package/docs-release/architecture/system-explainer/05-data-flow.md +6 -6
- package/docs-release/architecture/system-explainer/06-preset-and-migration.md +2 -2
- package/docs-release/architecture/system-explainer/README.md +1 -1
- package/docs-release/architecture/system-explainer/en-US/01-system-overview.md +12 -8
- package/docs-release/architecture/system-explainer/en-US/02-module-dependency.md +5 -5
- package/docs-release/architecture/system-explainer/en-US/03-task-lifecycle.md +19 -11
- package/docs-release/architecture/system-explainer/en-US/05-data-flow.md +5 -5
- package/docs-release/architecture/system-explainer/en-US/06-preset-and-migration.md +2 -2
- package/docs-release/architecture/system-explainer/en-US/README.md +1 -1
- package/docs-release/guides/agent-installation.en-US.md +4 -6
- package/docs-release/guides/agent-installation.md +11 -8
- package/docs-release/guides/contributing.md +10 -3
- package/docs-release/guides/contributing.zh-CN.md +10 -3
- package/docs-release/guides/legacy-migration-agent-prompt.md +1 -1
- package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +1 -1
- package/docs-release/guides/migration-playbook.en-US.md +9 -6
- package/docs-release/guides/migration-playbook.md +9 -6
- package/docs-release/guides/preset-development.md +68 -2
- package/docs-release/guides/task-state-machine.en-US.md +8 -8
- package/docs-release/guides/task-state-machine.md +7 -7
- package/docs-release/guides/typescript-runtime-migration-closeout.md +17 -13
- package/package.json +16 -12
- package/postinstall.mjs +37 -0
- package/presets/legacy-migration/preset.yaml +5 -5
- package/presets/legacy-migration/templates/execution_strategy.append.md +1 -1
- package/presets/lesson-sedimentation/preset.yaml +3 -3
- package/presets/module/preset.yaml +2 -2
- package/presets/module/templates/execution_strategy.append.md +1 -1
- package/presets/module/templates/task_plan.append.md +3 -3
- package/presets/release-closeout/checks/check-release-package.mjs +6 -1
- package/presets/release-closeout/preset.yaml +9 -9
- package/presets/release-closeout/scripts/generate-release-package.mjs +387 -25
- package/presets/release-closeout/templates/task_plan.append.md +5 -5
- package/presets/standard-task/preset.yaml +2 -2
- package/references/agents-md-pattern.md +23 -17
- package/references/lessons-governance.md +2 -2
- package/references/module-parallel-standard.md +3 -6
- package/references/ssot-governance.md +2 -2
- package/references/taskr-gap-analysis.md +3 -3
- package/run-dist.mjs +34 -0
- package/skills/preset-creator/SKILL.md +40 -8
- package/skills/preset-creator/references/complex-task-skeleton/brief.md +32 -8
- package/skills/preset-creator/references/preset-package-skeleton.md +15 -5
- package/skills/preset-creator/references/structure-aware-paths.md +112 -0
- package/templates/AGENTS.md.template +28 -26
- package/templates/architecture/README.md +2 -2
- package/templates/architecture/service-catalog.md +2 -2
- package/templates/architecture/services/service-template.md +1 -1
- package/templates/dashboard/assets/app-src/00-state.js +5 -1
- package/templates/dashboard/assets/app-src/10-router.js +7 -0
- package/templates/dashboard/assets/app-src/20-overview.js +8 -8
- package/templates/dashboard/assets/app-src/30-tasks.js +132 -40
- package/templates/dashboard/assets/app-src/32-task-swimlane.js +314 -0
- package/templates/dashboard/assets/app-src/35-task-detail.js +35 -5
- package/templates/dashboard/assets/app-src/40-modules.js +257 -41
- package/templates/dashboard/assets/app-src/45-review.js +127 -1
- package/templates/dashboard/assets/app-src/90-bindings.js +185 -2
- package/templates/dashboard/assets/app.css +928 -53
- package/templates/dashboard/assets/app.css.manifest.json +2 -0
- package/templates/dashboard/assets/app.js +1071 -98
- package/templates/dashboard/assets/app.manifest.json +1 -0
- package/templates/dashboard/assets/css-src/00-foundation.css +12 -6
- package/templates/dashboard/assets/css-src/10-panels-flow.css +2 -2
- package/templates/dashboard/assets/css-src/30-task-index.css +21 -13
- package/templates/dashboard/assets/css-src/31-archive.css +94 -0
- package/templates/dashboard/assets/css-src/32-task-swimlane.css +487 -0
- package/templates/dashboard/assets/css-src/35-review-workspace.css +78 -0
- package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +191 -14
- package/templates/dashboard/assets/css-src/50-responsive-overrides.css +23 -0
- package/templates/dashboard/assets/i18n.js +166 -2
- package/templates/development/README.md +9 -9
- package/templates/development/cross-repo-debugging.md +3 -3
- package/templates/development/external-context/service-template.md +1 -1
- package/templates/development/external-source-packs/README.md +2 -2
- package/templates/integrations/README.md +4 -4
- package/templates/integrations/api-contract.md +1 -1
- package/templates/integrations/event-contract.md +1 -1
- package/templates/integrations/third-party/vendor-template.md +1 -1
- package/templates/integrations/webhook-contract.md +1 -1
- package/templates/ledger/Harness-Ledger.md +1 -1
- package/templates/modules/module_brief.md +50 -0
- package/templates/modules/module_plan.md +49 -0
- package/templates/modules/registry_view.md +9 -0
- package/templates/modules/session_prompt_pack.md +55 -0
- package/templates/planning/brief.md +32 -8
- package/templates/planning/module_brief.md +28 -3
- package/templates/planning/module_plan.md +26 -11
- package/templates/planning/module_session_prompt.md +11 -2
- package/templates/planning/optional/slices/_slice-template/brief.md +28 -0
- package/templates/planning/review.md +1 -1
- package/templates/planning/visual_map.md +1 -1
- package/templates/reference/docs-library-standard.md +7 -7
- package/templates/reference/execution-workflow-standard.md +13 -0
- package/templates/reference/external-source-intake-standard.md +10 -10
- package/templates/reference/repo-governance-standard.md +1 -1
- package/templates/reference/review-routing-standard.md +4 -0
- package/templates/ssot/Module-Registry.md +4 -38
- package/templates/walkthrough/walkthrough-template.md +1 -1
- package/templates-zh-CN/AGENTS.md.template +27 -25
- package/templates-zh-CN/CLAUDE.md.template +1 -1
- package/templates-zh-CN/architecture/README.md +2 -2
- package/templates-zh-CN/architecture/service-catalog.md +2 -2
- package/templates-zh-CN/architecture/services/service-template.md +1 -1
- package/templates-zh-CN/development/README.md +9 -9
- package/templates-zh-CN/development/cross-repo-debugging.md +3 -3
- package/templates-zh-CN/development/external-context/service-template.md +1 -1
- package/templates-zh-CN/development/external-source-packs/README.md +2 -2
- package/templates-zh-CN/integrations/README.md +4 -4
- package/templates-zh-CN/integrations/api-contract.md +1 -1
- package/templates-zh-CN/integrations/event-contract.md +1 -1
- package/templates-zh-CN/integrations/third-party/vendor-template.md +1 -1
- package/templates-zh-CN/integrations/webhook-contract.md +1 -1
- package/templates-zh-CN/ledger/Harness-Ledger.md +1 -1
- package/templates-zh-CN/lessons/lesson-arch-process-change.md +1 -1
- package/templates-zh-CN/lessons/lesson-new-doc.md +3 -3
- package/templates-zh-CN/lessons/lesson-ref-change.md +4 -4
- package/templates-zh-CN/modules/module_brief.md +47 -0
- package/templates-zh-CN/modules/module_plan.md +48 -0
- package/templates-zh-CN/modules/registry_view.md +9 -0
- package/templates-zh-CN/modules/session_prompt_pack.md +50 -0
- package/templates-zh-CN/planning/INDEX.md +1 -0
- package/templates-zh-CN/planning/brief.md +26 -7
- package/templates-zh-CN/planning/module_brief.md +24 -2
- package/templates-zh-CN/planning/module_plan.md +35 -29
- package/templates-zh-CN/planning/module_session_prompt.md +15 -11
- package/templates-zh-CN/planning/optional/slices/_slice-template/brief.md +28 -11
- package/templates-zh-CN/planning/review.md +1 -1
- package/templates-zh-CN/reference/adversarial-review-standard.md +1 -1
- package/templates-zh-CN/reference/delivery-operating-model-standard.md +3 -3
- package/templates-zh-CN/reference/docs-library-standard.md +27 -27
- package/templates-zh-CN/reference/execution-workflow-standard.md +12 -2
- package/templates-zh-CN/reference/external-source-intake-standard.md +10 -10
- package/templates-zh-CN/reference/harness-ledger-standard.md +3 -3
- package/templates-zh-CN/reference/regression-ssot-governance.md +2 -2
- package/templates-zh-CN/reference/repo-governance-standard.md +1 -1
- package/templates-zh-CN/reference/review-routing-standard.md +3 -0
- package/templates-zh-CN/reference/walkthrough-standard.md +2 -2
- package/templates-zh-CN/reference/worktree-standard.md +1 -1
- package/templates-zh-CN/regression/Cadence-Ledger.md +2 -2
- package/templates-zh-CN/ssot/Delivery-SSoT.md +2 -2
- package/templates-zh-CN/ssot/Module-Registry.md +5 -44
- package/templates-zh-CN/ssot/Regression-SSoT.md +2 -2
- package/templates-zh-CN/walkthrough/walkthrough-template.md +4 -4
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
1
|
// Generic preset entrypoint runner. Domain logic belongs in preset packages.
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import crypto from "node:crypto";
|
|
7
6
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { normalizeTarget, readFileSafe, readJsonSafe, sanitizeDeep, toPosix, walkFiles, } from "./core-shared.mjs";
|
|
7
|
+
import { absoluteHarnessPathContext, harnessPathContext, normalizeTarget, readFileSafe, readJsonSafe, renderHarnessTemplate, sanitizeDeep, toPosix, walkFiles, } from "./core-shared.mjs";
|
|
9
8
|
import { beginGovernanceSync, commitGovernanceSync, releaseGovernanceSync } from "./governance-sync.mjs";
|
|
10
9
|
import { parseTaskMetadata } from "./task-metadata.mjs";
|
|
11
10
|
import { taskIdForDirectory } from "./task-scanner.mjs";
|
|
12
11
|
import { resolveTaskDirectory } from "./task-lifecycle.mjs";
|
|
13
|
-
import { evaluateTemplateValues, assertPresetWriteScope } from "./preset-engine.mjs";
|
|
14
|
-
import { buildPresetAudit, readPresetPackage } from "./preset-registry.mjs";
|
|
12
|
+
import { evaluateTemplateValues, assertPresetWriteScope, resolvePresetScopes } from "./preset-engine.mjs";
|
|
13
|
+
import { buildPresetAudit, buildPresetScriptPolicy, presetScriptTrustValid, readPresetPackage } from "./preset-registry.mjs";
|
|
15
14
|
const materializationSchemaVersion = "preset-materialization/v1";
|
|
16
15
|
const maxMaterializedFileBytes = 10 * 1024 * 1024;
|
|
17
16
|
const maxMaterializedWrites = 500;
|
|
18
|
-
export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", targetInput = ".", json = false } = {}) {
|
|
17
|
+
export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", targetInput = ".", json = false, allowScripts = false, useCurrentPreset = false, reason = "" } = {}) {
|
|
18
|
+
void json;
|
|
19
19
|
const target = normalizeTarget(targetInput);
|
|
20
20
|
const preset = readPresetPackage(presetId, { targetInput });
|
|
21
21
|
const entrypoint = preset.entrypoints?.[entrypointName];
|
|
@@ -23,6 +23,10 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
23
23
|
throw new Error(`Preset ${preset.id} does not declare entrypoint: ${entrypointName}`);
|
|
24
24
|
if (!["script", "check"].includes(entrypoint.type))
|
|
25
25
|
throw new Error(`Preset entrypoint ${entrypointName} is not runnable by preset run`);
|
|
26
|
+
const scriptPolicy = buildPresetScriptPolicy(preset);
|
|
27
|
+
if (scriptPolicy.requiresTrustedSource && !allowScripts && !presetScriptTrustValid(preset)) {
|
|
28
|
+
throw new Error(`Preset entrypoint ${preset.id}.${entrypointName} executes trusted local code. Re-run with --allow-scripts if you trust this preset source.`);
|
|
29
|
+
}
|
|
26
30
|
if (!taskRef)
|
|
27
31
|
throw new Error("preset run requires --task <task-id>");
|
|
28
32
|
const taskDir = resolveTaskDirectory(target, taskRef);
|
|
@@ -31,8 +35,11 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
31
35
|
if (metadata.preset !== preset.id)
|
|
32
36
|
throw new Error(`Task ${taskRef} was created by preset ${metadata.preset || "none"}, not ${preset.id}`);
|
|
33
37
|
const taskId = taskIdForDirectory(target, taskDir);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
38
|
+
const audit = readPresetAudit(target, metadata);
|
|
39
|
+
const presetDrift = assessPresetDrift(audit, preset, { useCurrentPreset, reason });
|
|
40
|
+
const resolvedInputs = asRecord(audit.resolvedInputs);
|
|
41
|
+
const values = evaluateTemplateValues(preset, resolvedInputs, { taskId, taskTitle: taskId, moduleKey: "", target });
|
|
42
|
+
const resolvedScopes = resolvePresetScopes(preset, target);
|
|
36
43
|
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `harness-preset-${preset.id}-${entrypointName}-`));
|
|
37
44
|
const manifestPath = path.join(outputRoot, "materialization-manifest.json");
|
|
38
45
|
const contextPath = path.join(outputRoot, "preset-context.json");
|
|
@@ -50,15 +57,20 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
50
57
|
},
|
|
51
58
|
targetRoot: target.projectRoot,
|
|
52
59
|
targetRootPolicy: "read-only; direct target mutation before manifest materialization is a hard failure",
|
|
60
|
+
runtime: {
|
|
61
|
+
coreModule: new URL("./harness-core.mjs", import.meta.url).href,
|
|
62
|
+
},
|
|
53
63
|
outputRoot,
|
|
54
64
|
materializationManifestPath: manifestPath,
|
|
65
|
+
paths: harnessPathContext(target),
|
|
66
|
+
absolutePaths: absoluteHarnessPathContext(target),
|
|
55
67
|
inputs: sanitizeDeep(resolvedInputs),
|
|
56
68
|
values: sanitizeDeep(values),
|
|
57
69
|
audit: buildPresetAudit(preset, {
|
|
58
70
|
taskId,
|
|
59
71
|
targetRoot: target.projectRoot,
|
|
60
72
|
entrypoint: entrypointName,
|
|
61
|
-
writeScopes: entrypoint.writes,
|
|
73
|
+
writeScopes: resolvedScopes.entrypoints[entrypointName] || entrypoint.writes,
|
|
62
74
|
resolvedInputs,
|
|
63
75
|
}),
|
|
64
76
|
};
|
|
@@ -67,10 +79,7 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
67
79
|
const script = spawnSync(process.execPath, [commandPath], {
|
|
68
80
|
cwd: outputRoot,
|
|
69
81
|
encoding: "utf8",
|
|
70
|
-
env:
|
|
71
|
-
...process.env,
|
|
72
|
-
HARNESS_PRESET_CONTEXT: contextPath,
|
|
73
|
-
},
|
|
82
|
+
env: presetScriptEnv(contextPath),
|
|
74
83
|
timeout: 120000,
|
|
75
84
|
maxBuffer: 10 * 1024 * 1024,
|
|
76
85
|
});
|
|
@@ -82,7 +91,7 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
82
91
|
const afterScriptSnapshot = targetSnapshot(target.projectRoot);
|
|
83
92
|
assertSnapshotsEqual(beforeSnapshot, afterScriptSnapshot, "Preset script mutated target before materialization");
|
|
84
93
|
const manifest = readMaterializationManifest(manifestPath);
|
|
85
|
-
const materialization = validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot,
|
|
94
|
+
const materialization = validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot, target, entrypointName });
|
|
86
95
|
const governanceContext = beginGovernanceSync(target, {
|
|
87
96
|
operation: `preset-run ${preset.id}.${entrypointName}`,
|
|
88
97
|
allowDirtyWorktree: true,
|
|
@@ -105,6 +114,7 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
105
114
|
sha256: item.sha256,
|
|
106
115
|
})),
|
|
107
116
|
governance: { commit },
|
|
117
|
+
presetDrift,
|
|
108
118
|
};
|
|
109
119
|
}
|
|
110
120
|
finally {
|
|
@@ -115,19 +125,253 @@ export function runPresetEntrypoint(presetId, entrypointName, { taskRef = "", ta
|
|
|
115
125
|
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
116
126
|
}
|
|
117
127
|
}
|
|
118
|
-
function
|
|
119
|
-
|
|
128
|
+
export function runPresetAction(presetId, actionName, { taskRef = "", targetInput = ".", json = false, actionArgs = [], allowScripts = false, useCurrentPreset = false, reason = "" } = {}) {
|
|
129
|
+
void json;
|
|
130
|
+
const target = normalizeTarget(targetInput);
|
|
131
|
+
const preset = readPresetPackage(presetId, { targetInput });
|
|
132
|
+
const action = preset.actions?.[actionName];
|
|
133
|
+
if (!action)
|
|
134
|
+
throw new Error(`Preset ${preset.id} does not declare action: ${actionName}`);
|
|
135
|
+
if (action.type !== "script")
|
|
136
|
+
throw new Error(`Preset action ${actionName} is not runnable by preset action`);
|
|
137
|
+
if (action.taskRequired !== true)
|
|
138
|
+
throw new Error(`Preset action ${preset.id}.${actionName} requires taskRequired: true`);
|
|
139
|
+
if (!taskRef)
|
|
140
|
+
throw new Error("preset action requires --task <task-id>");
|
|
141
|
+
const scriptPolicy = buildPresetScriptPolicy(preset);
|
|
142
|
+
if (scriptPolicy.requiresTrustedSource && !allowScripts && !presetScriptTrustValid(preset)) {
|
|
143
|
+
throw new Error(`Preset action ${preset.id}.${actionName} executes trusted local code. Re-run with --allow-scripts if you trust this preset source.`);
|
|
144
|
+
}
|
|
145
|
+
const taskDir = resolveTaskDirectory(target, taskRef);
|
|
146
|
+
const taskPlan = readFileSafe(path.join(taskDir, "task_plan.md"));
|
|
147
|
+
const metadata = parseTaskMetadata(taskPlan);
|
|
148
|
+
if (metadata.preset !== preset.id)
|
|
149
|
+
throw new Error(`Task ${taskRef} was created by preset ${metadata.preset || "none"}, not ${preset.id}`);
|
|
150
|
+
const taskId = taskIdForDirectory(target, taskDir);
|
|
151
|
+
const taskPaths = taskPathContext(target, taskDir);
|
|
152
|
+
const actionInputs = resolveActionInputs(action, actionArgs);
|
|
153
|
+
const audit = readPresetAudit(target, metadata);
|
|
154
|
+
const presetDrift = assessPresetDrift(audit, preset, { useCurrentPreset, reason });
|
|
155
|
+
const creationInputs = asRecord(audit.resolvedInputs);
|
|
156
|
+
const values = evaluateTemplateValues(preset, creationInputs, { taskId, taskTitle: taskId, moduleKey: "", target });
|
|
157
|
+
const actionWriteScopes = resolveActionScopes(action.writes, { target, taskPaths, label: `${actionName} action write scope` });
|
|
158
|
+
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `harness-preset-${preset.id}-${actionName}-`));
|
|
159
|
+
const manifestPath = path.join(outputRoot, "materialization-manifest.json");
|
|
160
|
+
const contextPath = path.join(outputRoot, "preset-action-context.json");
|
|
161
|
+
let governanceContext = null;
|
|
162
|
+
try {
|
|
163
|
+
governanceContext = beginGovernanceSync(target, {
|
|
164
|
+
operation: `preset-action ${preset.id}.${actionName}`,
|
|
165
|
+
allowDirtyWorktree: true,
|
|
166
|
+
allowedRelativePaths: concreteActionWriteScopes(actionWriteScopes),
|
|
167
|
+
});
|
|
168
|
+
const beforeSnapshot = targetSnapshot(target.projectRoot);
|
|
169
|
+
const context = {
|
|
170
|
+
schemaVersion: "preset-action-context/v1",
|
|
171
|
+
preset: { id: preset.id, version: preset.version, source: preset.source, manifestSha256: preset.manifestSha256 },
|
|
172
|
+
action: { id: actionName, type: action.type },
|
|
173
|
+
task: {
|
|
174
|
+
id: taskId,
|
|
175
|
+
ref: taskRef,
|
|
176
|
+
dir: taskPaths.dir,
|
|
177
|
+
taskPlanPath: taskPaths.taskPlan,
|
|
178
|
+
paths: taskPaths,
|
|
179
|
+
},
|
|
180
|
+
targetRoot: target.projectRoot,
|
|
181
|
+
targetRootPolicy: "read-only; direct target mutation before manifest materialization is a hard failure",
|
|
182
|
+
outputRoot,
|
|
183
|
+
materializationManifestPath: manifestPath,
|
|
184
|
+
paths: harnessPathContext(target),
|
|
185
|
+
inputs: sanitizeDeep(actionInputs),
|
|
186
|
+
creationInputs: sanitizeDeep(creationInputs),
|
|
187
|
+
values: sanitizeDeep(values),
|
|
188
|
+
audit: buildPresetAudit(preset, {
|
|
189
|
+
taskId,
|
|
190
|
+
targetRoot: target.projectRoot,
|
|
191
|
+
entrypoint: `action:${actionName}`,
|
|
192
|
+
writeScopes: actionWriteScopes,
|
|
193
|
+
resolvedInputs: actionInputs,
|
|
194
|
+
}),
|
|
195
|
+
presetDrift,
|
|
196
|
+
};
|
|
197
|
+
fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`);
|
|
198
|
+
const commandPath = path.join(preset.directory, action.command || "");
|
|
199
|
+
const script = spawnSync(process.execPath, [commandPath], {
|
|
200
|
+
cwd: outputRoot,
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
env: presetScriptEnv(contextPath),
|
|
203
|
+
timeout: 120000,
|
|
204
|
+
maxBuffer: 128 * 1024,
|
|
205
|
+
});
|
|
206
|
+
if (script.error)
|
|
207
|
+
throw script.error;
|
|
208
|
+
if (script.status !== 0) {
|
|
209
|
+
throw new Error(`Preset action ${preset.id}.${actionName} failed with ${script.status}\n${boundedScriptOutput(script.stderr || script.stdout || "")}`.trim());
|
|
210
|
+
}
|
|
211
|
+
const afterScriptSnapshot = targetSnapshot(target.projectRoot);
|
|
212
|
+
assertSnapshotsEqual(beforeSnapshot, afterScriptSnapshot, "Preset script mutated target before materialization");
|
|
213
|
+
const manifest = readMaterializationManifest(manifestPath);
|
|
214
|
+
const materialization = validateMaterializationManifest(preset, {
|
|
215
|
+
type: action.type,
|
|
216
|
+
command: action.command,
|
|
217
|
+
templates: {},
|
|
218
|
+
writes: actionWriteScopes,
|
|
219
|
+
reads: action.reads,
|
|
220
|
+
audit: action.audit,
|
|
221
|
+
}, manifest, { outputRoot, target, entrypointName: actionName });
|
|
222
|
+
materializeWrites(target.projectRoot, materialization);
|
|
223
|
+
const commit = commitGovernanceSync(governanceContext, materialization.map((item) => item.destination), {
|
|
224
|
+
message: `chore(harness): run preset action ${preset.id} ${actionName}`,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
preset: preset.id,
|
|
228
|
+
action: actionName,
|
|
229
|
+
source: preset.source,
|
|
230
|
+
manifestSha256: preset.manifestSha256,
|
|
231
|
+
taskId,
|
|
232
|
+
status: manifest.status || "ok",
|
|
233
|
+
materialized: materialization.map((item) => ({
|
|
234
|
+
source: item.source,
|
|
235
|
+
destination: item.destination,
|
|
236
|
+
type: item.type,
|
|
237
|
+
sha256: item.sha256,
|
|
238
|
+
})),
|
|
239
|
+
governance: { commit },
|
|
240
|
+
presetDrift,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (governanceContext)
|
|
245
|
+
releaseGovernanceSync(governanceContext);
|
|
246
|
+
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function readPresetAudit(target, metadata) {
|
|
250
|
+
const evidenceBundle = normalizeTargetRelativePath(metadata.evidenceBundle || "", "Preset evidence bundle");
|
|
120
251
|
if (!evidenceBundle)
|
|
121
252
|
return {};
|
|
122
253
|
const auditPath = path.join(target.projectRoot, evidenceBundle, "preset-audit.json");
|
|
123
|
-
const audit = readJsonSafe(auditPath, {});
|
|
124
|
-
return audit
|
|
254
|
+
const audit = asRecord(readJsonSafe(auditPath, {}));
|
|
255
|
+
return audit;
|
|
256
|
+
}
|
|
257
|
+
function assessPresetDrift(audit, preset, { useCurrentPreset = false, reason = "" } = {}) {
|
|
258
|
+
const recorded = String(audit.manifestSha256 || "");
|
|
259
|
+
const current = String(preset.manifestSha256 || "");
|
|
260
|
+
if (!recorded || !current || recorded === current) {
|
|
261
|
+
return { detected: false, accepted: false, recordedManifestSha256: recorded, currentManifestSha256: current, reason: "" };
|
|
262
|
+
}
|
|
263
|
+
const normalizedReason = String(reason || "").trim();
|
|
264
|
+
if (!useCurrentPreset || !normalizedReason) {
|
|
265
|
+
throw new Error(`Preset manifest hash drift detected for ${preset.id}: recorded ${recorded}, current ${current}. Re-run with --use-current-preset --reason <why-current-semantics-are-intended>, or create a new task with the current preset.`);
|
|
266
|
+
}
|
|
267
|
+
return { detected: true, accepted: true, recordedManifestSha256: recorded, currentManifestSha256: current, reason: normalizedReason };
|
|
268
|
+
}
|
|
269
|
+
function resolveActionInputs(action, cliArgs) {
|
|
270
|
+
const remaining = [...cliArgs];
|
|
271
|
+
const inputs = {};
|
|
272
|
+
for (const [name, declaration] of Object.entries(action.inputs || {})) {
|
|
273
|
+
inputs[name] = resolveActionInputValue(name, declaration, remaining);
|
|
274
|
+
}
|
|
275
|
+
if (remaining.length > 0)
|
|
276
|
+
throw new Error(`Unknown action argument: ${remaining[0]}`);
|
|
277
|
+
return inputs;
|
|
278
|
+
}
|
|
279
|
+
function resolveActionInputValue(name, declaration, remaining) {
|
|
280
|
+
const flag = declaration.flag || name;
|
|
281
|
+
const index = remaining.indexOf(flag);
|
|
282
|
+
if (declaration.type === "flag") {
|
|
283
|
+
if (index >= 0) {
|
|
284
|
+
remaining.splice(index, 1);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
return Boolean(declaration.default);
|
|
288
|
+
}
|
|
289
|
+
if (index < 0) {
|
|
290
|
+
if (declaration.required)
|
|
291
|
+
throw new Error(`Missing required action input ${flag}`);
|
|
292
|
+
return declaration.default ?? "";
|
|
293
|
+
}
|
|
294
|
+
const value = remaining[index + 1] || "";
|
|
295
|
+
if (!value || value.startsWith("--"))
|
|
296
|
+
throw new Error(`Missing value for action input ${flag}`);
|
|
297
|
+
remaining.splice(index, 2);
|
|
298
|
+
if (String(value).includes("\0"))
|
|
299
|
+
throw new Error(`Action input ${flag} contains NUL byte`);
|
|
300
|
+
if (declaration.type === "text") {
|
|
301
|
+
if (String(value).length > 4096)
|
|
302
|
+
throw new Error(`Action input ${flag} exceeds text length limit`);
|
|
303
|
+
return String(value);
|
|
304
|
+
}
|
|
305
|
+
if (declaration.type === "json-file") {
|
|
306
|
+
const filePath = path.resolve(String(value));
|
|
307
|
+
if (!fs.existsSync(filePath))
|
|
308
|
+
throw new Error(`Action input file not found for ${flag}: ${value}`);
|
|
309
|
+
const stat = fs.statSync(filePath);
|
|
310
|
+
if (!stat.isFile())
|
|
311
|
+
throw new Error(`Action input file must be a file for ${flag}: ${value}`);
|
|
312
|
+
if (stat.size > 1024 * 1024)
|
|
313
|
+
throw new Error(`Action input file exceeds size limit for ${flag}: ${value}`);
|
|
314
|
+
let readError = null;
|
|
315
|
+
const parsed = readJsonSafe(filePath, null, { onError: (error) => { readError = error; } });
|
|
316
|
+
if (!isRecord(parsed))
|
|
317
|
+
throw new Error(`Invalid action JSON input ${flag}: ${errorMessage(readError)}`);
|
|
318
|
+
return parsed;
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`Unsupported action input type for ${flag}: ${declaration.type}`);
|
|
321
|
+
}
|
|
322
|
+
function taskPathContext(target, taskDir) {
|
|
323
|
+
const dir = toPosix(path.relative(target.projectRoot, taskDir));
|
|
324
|
+
return {
|
|
325
|
+
dir,
|
|
326
|
+
taskPlan: toPosix(path.join(dir, "task_plan.md")),
|
|
327
|
+
progress: toPosix(path.join(dir, "progress.md")),
|
|
328
|
+
artifacts: toPosix(path.join(dir, "artifacts")),
|
|
329
|
+
artifactsIndex: toPosix(path.join(dir, "artifacts/INDEX.md")),
|
|
330
|
+
visualMap: toPosix(path.join(dir, "visual_map.md")),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function resolveActionScopes(scopes, { target, taskPaths, label }) {
|
|
334
|
+
return (scopes || []).map((scope) => {
|
|
335
|
+
const rendered = renderHarnessTemplate(String(scope || ""), { paths: harnessPathContext(target), task: { paths: taskPaths } }, { strict: true });
|
|
336
|
+
const normalized = toPosix(path.normalize(rendered));
|
|
337
|
+
if (!rendered.trim() || path.isAbsolute(rendered) || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
338
|
+
throw new Error(`${label} escapes target root: ${scope}`);
|
|
339
|
+
}
|
|
340
|
+
return normalized;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function concreteActionWriteScopes(scopes) {
|
|
344
|
+
return scopes.filter((scope) => !scope.endsWith("/**"));
|
|
345
|
+
}
|
|
346
|
+
function presetScriptEnv(contextPath) {
|
|
347
|
+
const env = { HARNESS_PRESET_CONTEXT: contextPath };
|
|
348
|
+
for (const key of ["PATH", "TMPDIR", "TEMP", "TMP", "SystemRoot", "ComSpec"]) {
|
|
349
|
+
if (process.env[key])
|
|
350
|
+
env[key] = process.env[key];
|
|
351
|
+
}
|
|
352
|
+
return env;
|
|
353
|
+
}
|
|
354
|
+
function normalizeTargetRelativePath(value, label) {
|
|
355
|
+
const raw = String(value || "").replace(/^TARGET:/, "").replace(/^\/+/, "").trim();
|
|
356
|
+
if (!raw)
|
|
357
|
+
return "";
|
|
358
|
+
const normalized = toPosix(path.normalize(raw));
|
|
359
|
+
if (path.isAbsolute(raw) || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
360
|
+
throw new Error(`${label} escapes target root: ${raw}`);
|
|
361
|
+
}
|
|
362
|
+
return normalized;
|
|
363
|
+
}
|
|
364
|
+
function boundedScriptOutput(output) {
|
|
365
|
+
const redacted = String(output || "")
|
|
366
|
+
.replace(/(api[_-]?key|token|secret|password)=\S+/gi, "$1=[redacted]")
|
|
367
|
+
.replace(/\/Users\/[^/\s]+/g, "/Users/[redacted]");
|
|
368
|
+
return redacted.length > 4096 ? `${redacted.slice(0, 4096)}\n[output truncated]` : redacted;
|
|
125
369
|
}
|
|
126
370
|
function readMaterializationManifest(manifestPath) {
|
|
127
371
|
if (!fs.existsSync(manifestPath))
|
|
128
372
|
throw new Error("Preset entrypoint did not emit materialization manifest");
|
|
129
373
|
const manifest = readJsonSafe(manifestPath, null);
|
|
130
|
-
if (!
|
|
374
|
+
if (!isRecord(manifest))
|
|
131
375
|
throw new Error("Invalid preset materialization manifest");
|
|
132
376
|
if (manifest.schemaVersion !== materializationSchemaVersion)
|
|
133
377
|
throw new Error(`Invalid preset materialization schema: ${manifest.schemaVersion || "(missing)"}`);
|
|
@@ -135,9 +379,15 @@ function readMaterializationManifest(manifestPath) {
|
|
|
135
379
|
throw new Error("Preset materialization manifest writes must be an array");
|
|
136
380
|
if (manifest.writes.length > maxMaterializedWrites)
|
|
137
381
|
throw new Error(`Preset materialization manifest has too many writes: ${manifest.writes.length}`);
|
|
138
|
-
return
|
|
382
|
+
return {
|
|
383
|
+
schemaVersion: String(manifest.schemaVersion),
|
|
384
|
+
writes: manifest.writes.map((write) => asRecord(write)),
|
|
385
|
+
status: manifest.status === undefined ? undefined : String(manifest.status),
|
|
386
|
+
publicRedactionReport: isRecord(manifest.publicRedactionReport) ? { source: String(manifest.publicRedactionReport.source || "") } : undefined,
|
|
387
|
+
};
|
|
139
388
|
}
|
|
140
|
-
function validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot,
|
|
389
|
+
function validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot, target, entrypointName }) {
|
|
390
|
+
const targetRoot = target.projectRoot;
|
|
141
391
|
const seenDestinations = new Set();
|
|
142
392
|
const writes = manifest.writes.map((write, index) => {
|
|
143
393
|
const source = normalizeManifestRelativePath(write.source, "Manifest source");
|
|
@@ -145,7 +395,10 @@ function validateMaterializationManifest(preset, entrypoint, manifest, { outputR
|
|
|
145
395
|
if (seenDestinations.has(destination))
|
|
146
396
|
throw new Error(`Duplicate materialization destination: ${destination}`);
|
|
147
397
|
seenDestinations.add(destination);
|
|
148
|
-
assertEntrypointWriteScope(preset, entrypoint, destination);
|
|
398
|
+
assertEntrypointWriteScope(preset, entrypoint, destination, target, entrypointName);
|
|
399
|
+
if (entrypointName && preset.actions?.[entrypointName] && isDefaultTaskGovernanceFile(destination)) {
|
|
400
|
+
throw new Error(`Preset action ${entrypointName} cannot write task governance file by default: ${destination}`);
|
|
401
|
+
}
|
|
149
402
|
const sourcePath = path.join(outputRoot, source);
|
|
150
403
|
assertOutputSource(outputRoot, sourcePath, source);
|
|
151
404
|
const stat = fs.lstatSync(sourcePath);
|
|
@@ -200,9 +453,10 @@ function assertDestinationParent(targetRoot, destination) {
|
|
|
200
453
|
throw new Error(`Manifest destination parent escapes target root: ${destination}`);
|
|
201
454
|
}
|
|
202
455
|
}
|
|
203
|
-
function assertEntrypointWriteScope(preset, entrypoint, destination) {
|
|
204
|
-
assertPresetWriteScope(preset, destination);
|
|
205
|
-
|
|
456
|
+
function assertEntrypointWriteScope(preset, entrypoint, destination, target, entrypointName) {
|
|
457
|
+
assertPresetWriteScope(preset, destination, target);
|
|
458
|
+
const resolved = resolvePresetScopes(preset, target).entrypoints[entrypointName] || entrypoint.writes;
|
|
459
|
+
if (!resolved.some((scope) => matchesScope(scope, destination))) {
|
|
206
460
|
throw new Error(`Preset write scope violation for ${destination}`);
|
|
207
461
|
}
|
|
208
462
|
}
|
|
@@ -214,6 +468,9 @@ function matchesScope(scope, relativePath) {
|
|
|
214
468
|
}
|
|
215
469
|
return relativePath === normalizedScope;
|
|
216
470
|
}
|
|
471
|
+
function isDefaultTaskGovernanceFile(destination) {
|
|
472
|
+
return /(^|\/)(review|brief|task_plan)\.md$/.test(destination);
|
|
473
|
+
}
|
|
217
474
|
function enforcePublicRedaction(manifest, writes, { outputRoot }) {
|
|
218
475
|
const publicWrites = writes.filter((write) => write.visibility === "public" || write.destination.startsWith("docs-release/"));
|
|
219
476
|
if (publicWrites.length === 0)
|
|
@@ -221,8 +478,8 @@ function enforcePublicRedaction(manifest, writes, { outputRoot }) {
|
|
|
221
478
|
const reportSource = normalizeManifestRelativePath(manifest.publicRedactionReport?.source || "", "Public redaction report source");
|
|
222
479
|
const reportPath = path.join(outputRoot, reportSource);
|
|
223
480
|
assertOutputSource(outputRoot, reportPath, reportSource);
|
|
224
|
-
const report = readJsonSafe(reportPath, null);
|
|
225
|
-
if (
|
|
481
|
+
const report = asRecord(readJsonSafe(reportPath, null));
|
|
482
|
+
if (report.status !== "pass")
|
|
226
483
|
throw new Error("Public materialization requires a passing public redaction report");
|
|
227
484
|
}
|
|
228
485
|
function materializeWrites(targetRoot, writes) {
|
|
@@ -292,3 +549,12 @@ function isInside(root, candidate) {
|
|
|
292
549
|
const relative = path.relative(root, candidate);
|
|
293
550
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
294
551
|
}
|
|
552
|
+
function isRecord(value) {
|
|
553
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
554
|
+
}
|
|
555
|
+
function asRecord(value) {
|
|
556
|
+
return isRecord(value) ? value : {};
|
|
557
|
+
}
|
|
558
|
+
function errorMessage(error) {
|
|
559
|
+
return error instanceof Error ? error.message : String(error);
|
|
560
|
+
}
|
|
@@ -33,34 +33,41 @@ export function prepareReviewConfirmGitGate(projectRoot, allowedFilesAbs) {
|
|
|
33
33
|
const gitRoot = root;
|
|
34
34
|
const allowedPaths = allowedFilesAbs.map((filePath) => toPosix(path.relative(gitRoot, path.resolve(filePath))));
|
|
35
35
|
assertAllowedPaths(allowedPaths);
|
|
36
|
-
|
|
36
|
+
const baselineEntries = statusEntries(gitRoot);
|
|
37
|
+
assertOwnedPathsClean(allowedPaths, baselineEntries);
|
|
37
38
|
assertCommitIdentity(gitRoot);
|
|
38
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
gitRoot,
|
|
41
|
+
allowedPaths,
|
|
42
|
+
baselineOutsideEntries: outsideEntries(baselineEntries, allowedPaths),
|
|
43
|
+
};
|
|
39
44
|
}
|
|
40
45
|
export function commitReviewConfirmationGate(gate, { taskId, reviewPath, writeFinalAudit, message = "" }) {
|
|
41
46
|
const subjectSuffix = taskId.replace(/[^A-Za-z0-9._/-]+/g, "-");
|
|
42
|
-
|
|
47
|
+
assertOutsideStatusUnchanged(gate.gitRoot, gate.allowedPaths, gate.baselineOutsideEntries);
|
|
43
48
|
git(gate.gitRoot, ["add", "--", ...gate.allowedPaths]);
|
|
44
|
-
|
|
45
|
-
const confirmCommit =
|
|
49
|
+
assertOutsideStatusUnchanged(gate.gitRoot, gate.allowedPaths, gate.baselineOutsideEntries);
|
|
50
|
+
const confirmCommit = commitOnly(gate.gitRoot, `chore: confirm review ${subjectSuffix}`, gate.allowedPaths, {
|
|
46
51
|
recovery: [
|
|
47
52
|
"Review confirmation files were written but not committed.",
|
|
48
|
-
`Inspect and either fix hooks then run: git add -- ${gate.allowedPaths.join(" ")} && git commit`,
|
|
53
|
+
`Inspect and either fix hooks then run: git add -- ${gate.allowedPaths.join(" ")} && git commit --only -- ${gate.allowedPaths.join(" ")}`,
|
|
49
54
|
"Or manually revert the written review confirmation files if the confirmation should not proceed.",
|
|
50
55
|
],
|
|
51
56
|
});
|
|
57
|
+
assertOwnedPathsClean(gate.allowedPaths, statusEntries(gate.gitRoot));
|
|
52
58
|
writeFinalAudit(confirmCommit);
|
|
53
59
|
const reviewRelativePath = toPosix(path.relative(gate.gitRoot, path.resolve(reviewPath)));
|
|
54
60
|
git(gate.gitRoot, ["add", "--", reviewRelativePath]);
|
|
55
|
-
|
|
56
|
-
const auditCommit =
|
|
61
|
+
assertOutsideStatusUnchanged(gate.gitRoot, gate.allowedPaths, gate.baselineOutsideEntries);
|
|
62
|
+
const auditCommit = commitOnly(gate.gitRoot, `chore: record review confirmation audit ${subjectSuffix}`, gate.allowedPaths, {
|
|
57
63
|
recovery: [
|
|
58
64
|
"The confirmation commit was created, but final audit metadata could not be committed.",
|
|
59
65
|
`Confirmation commit SHA: ${confirmCommit}`,
|
|
60
|
-
`Fix hooks, then stage ${reviewRelativePath} and commit
|
|
66
|
+
`Fix hooks, then stage ${reviewRelativePath} and commit --only -- ${reviewRelativePath}.`,
|
|
61
67
|
],
|
|
62
68
|
});
|
|
63
|
-
|
|
69
|
+
assertOwnedPathsClean(gate.allowedPaths, statusEntries(gate.gitRoot));
|
|
70
|
+
assertOutsideStatusUnchanged(gate.gitRoot, gate.allowedPaths, gate.baselineOutsideEntries);
|
|
64
71
|
return {
|
|
65
72
|
commitSha: confirmCommit,
|
|
66
73
|
auditCommitSha: auditCommit,
|
|
@@ -99,7 +106,8 @@ export function validateReviewConfirmationGitAudit({ projectRoot, taskId, review
|
|
|
99
106
|
addIssue("git-audit-commit-not-reachable");
|
|
100
107
|
const subject = git(root, ["show", "-s", "--format=%s", fullCommitSha], { allowFailure: true }).stdout.trim();
|
|
101
108
|
const expectedSubject = `chore: confirm review ${String(taskId || "").replace(/[^A-Za-z0-9._/-]+/g, "-")}`;
|
|
102
|
-
|
|
109
|
+
const batchSubject = subject === "chore: confirm selected reviews";
|
|
110
|
+
if (subject !== expectedSubject && !batchSubject)
|
|
103
111
|
addIssue("git-audit-subject-mismatch");
|
|
104
112
|
const changedPaths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", fullCommitSha], { allowFailure: true }).stdout
|
|
105
113
|
.split(/\r?\n/)
|
|
@@ -108,7 +116,13 @@ export function validateReviewConfirmationGitAudit({ projectRoot, taskId, review
|
|
|
108
116
|
.sort();
|
|
109
117
|
if (expectedPaths.length === 0)
|
|
110
118
|
addIssue("git-audit-allowlist-missing");
|
|
111
|
-
if (
|
|
119
|
+
if (batchSubject) {
|
|
120
|
+
const expectedIncluded = expectedPaths.every((expectedPath) => changedPaths.includes(expectedPath));
|
|
121
|
+
const batchPathsAllowed = changedPaths.every((changedPath) => /(^|\/)coding-agent-harness\/planning\/(?:tasks|modules)\/.+\/INDEX\.md$/.test(changedPath));
|
|
122
|
+
if (!expectedIncluded || !batchPathsAllowed)
|
|
123
|
+
addIssue("git-audit-allowlist-mismatch");
|
|
124
|
+
}
|
|
125
|
+
else if (changedPaths.join("\n") !== expectedPaths.join("\n"))
|
|
112
126
|
addIssue("git-audit-allowlist-mismatch");
|
|
113
127
|
return {
|
|
114
128
|
valid: issues.length === 0,
|
|
@@ -134,13 +148,7 @@ function assertAllowedPaths(paths) {
|
|
|
134
148
|
const disallowed = paths.filter((relativePath) => {
|
|
135
149
|
if (!relativePath || relativePath.startsWith("../") || path.isAbsolute(relativePath))
|
|
136
150
|
return true;
|
|
137
|
-
|
|
138
|
-
return true;
|
|
139
|
-
if (relativePath === "docs" || relativePath.startsWith("docs/"))
|
|
140
|
-
return false;
|
|
141
|
-
if (relativePath === ".harness-private" || relativePath.startsWith(".harness-private/"))
|
|
142
|
-
return true;
|
|
143
|
-
return false;
|
|
151
|
+
return !/(^|\/)coding-agent-harness\/planning\/(?:tasks|modules)\/.+\/INDEX\.md$/.test(relativePath);
|
|
144
152
|
});
|
|
145
153
|
if (disallowed.length > 0) {
|
|
146
154
|
throw new ReviewConfirmGitGateError("Review confirmation write allowlist contains forbidden paths.", {
|
|
@@ -163,6 +171,41 @@ function assertCleanWorkingTree(gitRoot) {
|
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
}
|
|
174
|
+
function assertOwnedPathsClean(allowedPaths, entries) {
|
|
175
|
+
const ownedDirty = entries.filter((entry) => allowedPaths.includes(entry.path));
|
|
176
|
+
if (ownedDirty.length > 0) {
|
|
177
|
+
throw new ReviewConfirmGitGateError("Review confirmation owned path is already dirty; refusing to overwrite existing task confirmation state.", {
|
|
178
|
+
code: "git-owned-path-dirty",
|
|
179
|
+
details: { entries: ownedDirty, allowedPaths },
|
|
180
|
+
recovery: [
|
|
181
|
+
"Commit or resolve the existing task INDEX.md edits before retrying review-confirm.",
|
|
182
|
+
"Unrelated dirty files may remain; only the review-confirm owned paths must be clean.",
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function outsideEntries(entries, allowedPaths) {
|
|
188
|
+
return entries.filter((entry) => !allowedPaths.includes(entry.path));
|
|
189
|
+
}
|
|
190
|
+
function assertOutsideStatusUnchanged(gitRoot, allowedPaths, baselineOutsideEntries) {
|
|
191
|
+
const currentOutside = outsideEntries(statusEntries(gitRoot), allowedPaths);
|
|
192
|
+
if (statusSignature(currentOutside) !== statusSignature(baselineOutsideEntries)) {
|
|
193
|
+
throw new ReviewConfirmGitGateError("Review confirmation changed files outside the write allowlist.", {
|
|
194
|
+
code: "git-outside-status-changed",
|
|
195
|
+
details: { before: baselineOutsideEntries, after: currentOutside, allowedPaths },
|
|
196
|
+
recovery: [
|
|
197
|
+
"Inspect the extra files and do not commit them through review-confirm.",
|
|
198
|
+
"Revert only unintended review-confirm side effects, then retry.",
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function statusSignature(entries) {
|
|
204
|
+
return entries
|
|
205
|
+
.map((entry) => `${entry.index}${entry.worktree} ${entry.path}`)
|
|
206
|
+
.sort()
|
|
207
|
+
.join("\n");
|
|
208
|
+
}
|
|
166
209
|
function assertCommitIdentity(gitRoot) {
|
|
167
210
|
const name = git(gitRoot, ["config", "--get", "user.name"], { allowFailure: true }).stdout.trim();
|
|
168
211
|
const email = git(gitRoot, ["config", "--get", "user.email"], { allowFailure: true }).stdout.trim();
|
|
@@ -214,6 +257,17 @@ function commit(gitRoot, message, { recovery }) {
|
|
|
214
257
|
}
|
|
215
258
|
return git(gitRoot, ["rev-parse", "HEAD"]).stdout.trim();
|
|
216
259
|
}
|
|
260
|
+
function commitOnly(gitRoot, message, paths, { recovery }) {
|
|
261
|
+
const result = git(gitRoot, ["commit", "--only", "-m", message, "--", ...paths], { allowFailure: true });
|
|
262
|
+
if (result.status !== 0) {
|
|
263
|
+
throw new ReviewConfirmGitGateError("Git commit failed during review confirmation auto-commit.", {
|
|
264
|
+
code: "git-commit-failed",
|
|
265
|
+
details: { stdout: result.stdout.trim(), stderr: result.stderr.trim() },
|
|
266
|
+
recovery,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return git(gitRoot, ["rev-parse", "HEAD"]).stdout.trim();
|
|
270
|
+
}
|
|
217
271
|
function statusEntries(gitRoot) {
|
|
218
272
|
const output = git(gitRoot, ["status", "--porcelain=v1", "--untracked-files=all"]).stdout;
|
|
219
273
|
return output.split(/\r?\n/).filter(Boolean).map((line) => ({
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { normalizeTarget, toPosix } from "./core-shared.mjs";
|
|
4
3
|
import { capabilityDefinitions, readCapabilityRegistry } from "./capability-registry.mjs";
|
|
5
4
|
import { summarizeGitState } from "./git-status-summary.mjs";
|
|
6
|
-
import {
|
|
5
|
+
import { createScannerTaskRepository, taskCutoverCounters } from "./task-repository.mjs";
|
|
6
|
+
import { readHarnessModules } from "./module-registry.mjs";
|
|
7
|
+
const taskCutoverCountersForStatus = taskCutoverCounters;
|
|
7
8
|
export function buildStatusData(targetInput, options = {}) {
|
|
8
|
-
const target = targetInput
|
|
9
|
+
const target = hasProjectRoot(targetInput) ? targetInput : normalizeTarget(targetInput);
|
|
9
10
|
const validationMode = options.validationMode || "data-only";
|
|
10
11
|
const gitState = options.gitState || summarizeGitState(target);
|
|
11
|
-
const registry = options.capabilityState?.registry || readCapabilityRegistry(target);
|
|
12
|
+
const registry = (options.capabilityState?.registry || readCapabilityRegistry(target));
|
|
12
13
|
const detected = options.capabilityState?.detected || [];
|
|
13
14
|
const capabilityWarnings = options.capabilityState?.warnings || [];
|
|
14
15
|
const failures = [...(options.failures || [])];
|
|
15
16
|
const warnings = [...(options.warnings || [])];
|
|
16
17
|
const legacy = options.legacy || { status: "skipped", code: 0, stdout: "", stderr: "" };
|
|
17
|
-
const tasks = options.tasks ||
|
|
18
|
+
const tasks = options.tasks || createScannerTaskRepository(target, {
|
|
18
19
|
requireGeneratedScaffoldProvenance: options.requireGeneratedScaffoldProvenance === true,
|
|
19
|
-
taskPlanPaths: options.taskPlanPaths,
|
|
20
20
|
closeoutContent: options.closeoutContent,
|
|
21
|
-
});
|
|
21
|
+
}).list();
|
|
22
|
+
const modules = harnessModulesForStatus(target);
|
|
22
23
|
const briefReady = tasks.filter((task) => task.briefSource === "standalone").length;
|
|
23
24
|
const briefMissing = tasks.length - briefReady;
|
|
24
25
|
const capabilityNames = new Map(registry.capabilities.map((capability) => [capability.name, capability]));
|
|
@@ -26,7 +27,7 @@ export function buildStatusData(targetInput, options = {}) {
|
|
|
26
27
|
if (!capabilityNames.has(capability))
|
|
27
28
|
capabilityNames.set(capability, { name: capability, state: "configured" });
|
|
28
29
|
}
|
|
29
|
-
const cutoverCounters =
|
|
30
|
+
const cutoverCounters = taskCutoverCountersForStatus(tasks);
|
|
30
31
|
const fullCutoverEligible = validationMode === "validated" &&
|
|
31
32
|
failures.length === 0 &&
|
|
32
33
|
warnings.length === 0 &&
|
|
@@ -54,6 +55,7 @@ export function buildStatusData(targetInput, options = {}) {
|
|
|
54
55
|
git: gitState.summary,
|
|
55
56
|
summary: {
|
|
56
57
|
tasks: tasks.length,
|
|
58
|
+
modules: modules.length,
|
|
57
59
|
briefCoverage: {
|
|
58
60
|
ready: briefReady,
|
|
59
61
|
missing: briefMissing,
|
|
@@ -81,7 +83,20 @@ export function buildStatusData(targetInput, options = {}) {
|
|
|
81
83
|
warnings: capabilityWarnings.filter((warning) => warning.includes(capability.name)),
|
|
82
84
|
})),
|
|
83
85
|
tasks,
|
|
86
|
+
modules,
|
|
84
87
|
handoffs: tasks.flatMap((task) => task.handoffs || []),
|
|
85
88
|
recentActivity: tasks.slice(0, 8).map((task) => ({ at: new Date().toISOString(), type: "task", summary: task.title })),
|
|
86
89
|
};
|
|
87
90
|
}
|
|
91
|
+
function harnessModulesForStatus(target) {
|
|
92
|
+
try {
|
|
93
|
+
const registry = readHarnessModules(target);
|
|
94
|
+
return Object.entries(registry.items || {}).map(([key, module]) => ({ key, ...module }));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function hasProjectRoot(value) {
|
|
101
|
+
return Boolean(value && typeof value === "object" && "projectRoot" in value);
|
|
102
|
+
}
|