@brawnen/agent-harness-cli 0.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.
@@ -0,0 +1,219 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { normalizeOutputPolicy } from "./output-policy.js";
6
+ import { verifyTaskState } from "../commands/verify.js";
7
+
8
+ export function normalizeDeliveryPolicy(value) {
9
+ const policy = value && typeof value === "object" && !Array.isArray(value) ? value : {};
10
+ return {
11
+ commit: normalizeActionPolicy(policy.commit, ["verify_passed", "report_generated"]),
12
+ push: normalizeActionPolicy(policy.push, ["commit_exists"])
13
+ };
14
+ }
15
+
16
+ export function evaluateTaskDeliveryReadiness(cwd, taskState, options = {}) {
17
+ const deliveryPolicy = normalizeDeliveryPolicy(options.deliveryPolicy);
18
+ const reportPolicy = normalizeOutputPolicy({
19
+ report: options.reportPolicy
20
+ }).report;
21
+ const verification = verifyTaskState(taskState, { reportPolicy });
22
+ const signalStatus = buildSignalStatus(cwd, taskState, reportPolicy, verification, options);
23
+
24
+ return {
25
+ signals: signalStatus,
26
+ commit: evaluateActionReadiness("commit", deliveryPolicy.commit, signalStatus),
27
+ push: evaluateActionReadiness("push", deliveryPolicy.push, signalStatus)
28
+ };
29
+ }
30
+
31
+ export function summarizeDeliveryReadiness(readiness) {
32
+ const parts = [];
33
+ if (readiness?.commit?.configured) {
34
+ parts.push(formatReadinessSummary("commit", readiness.commit));
35
+ }
36
+ if (readiness?.push?.configured) {
37
+ parts.push(formatReadinessSummary("push", readiness.push));
38
+ }
39
+ return parts.join(", ");
40
+ }
41
+
42
+ function normalizeActionPolicy(value, defaultRequire) {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
44
+ return null;
45
+ }
46
+
47
+ return {
48
+ mode: typeof value.mode === "string" ? value.mode : "explicit_only",
49
+ via: typeof value.via === "string" ? value.via : "skill",
50
+ require: Array.isArray(value.require) ? value.require.map((item) => String(item)) : defaultRequire
51
+ };
52
+ }
53
+
54
+ function buildSignalStatus(cwd, taskState, reportPolicy, verification, options) {
55
+ const taskId = taskState?.task_id ?? null;
56
+ const reportPath = taskId ? path.join(cwd, reportPolicy.directory, `${taskId}.json`) : null;
57
+ const reportGenerated = Boolean(options.reportWillBeGenerated) || Boolean(reportPath && fs.existsSync(reportPath));
58
+ const commitStatus = resolveCommitExistsStatus(cwd, taskId, reportPath, options);
59
+
60
+ return {
61
+ verify_passed: {
62
+ name: "verify_passed",
63
+ satisfied: verification.allowed,
64
+ reason: verification.allowed
65
+ ? "verify 门禁已通过"
66
+ : `verify 未通过: ${(verification.missing_evidence ?? []).join(";")}`
67
+ },
68
+ report_generated: {
69
+ name: "report_generated",
70
+ satisfied: reportGenerated,
71
+ reason: reportGenerated
72
+ ? (options.reportWillBeGenerated ? "本次 report 执行将生成报告" : "已检测到报告文件")
73
+ : `未检测到报告文件: ${path.relative(cwd, reportPath ?? ".harness/reports/<task_id>.json")}`
74
+ },
75
+ commit_exists: {
76
+ name: "commit_exists",
77
+ satisfied: commitStatus.satisfied,
78
+ reason: commitStatus.reason
79
+ }
80
+ };
81
+ }
82
+
83
+ function resolveCommitExistsStatus(cwd, taskId, reportPath, options) {
84
+ if (options.commitExists === true) {
85
+ return {
86
+ satisfied: true,
87
+ reason: "已通过显式参数确认存在提交记录"
88
+ };
89
+ }
90
+
91
+ if (!taskId) {
92
+ return {
93
+ satisfied: false,
94
+ reason: "缺少 task_id,无法判断任务级 commit 状态"
95
+ };
96
+ }
97
+
98
+ if (!reportPath || !fs.existsSync(reportPath)) {
99
+ return {
100
+ satisfied: false,
101
+ reason: "尚未检测到任务报告,无法判断任务级 commit 状态"
102
+ };
103
+ }
104
+
105
+ const report = loadTaskReport(reportPath);
106
+ const candidatePaths = collectCommitCandidatePaths(report);
107
+ if (candidatePaths.length === 0) {
108
+ return {
109
+ satisfied: false,
110
+ reason: "任务报告未提供可用于判断 commit 的候选路径"
111
+ };
112
+ }
113
+
114
+ const gitStatus = spawnSync("git", ["status", "--short", "--", ...candidatePaths], {
115
+ cwd,
116
+ encoding: "utf8",
117
+ stdio: ["ignore", "pipe", "pipe"]
118
+ });
119
+
120
+ if (gitStatus.error) {
121
+ return {
122
+ satisfied: false,
123
+ reason: `git status 执行失败:${gitStatus.error.message}`
124
+ };
125
+ }
126
+
127
+ if (gitStatus.status !== 0) {
128
+ const stderr = String(gitStatus.stderr ?? "").trim();
129
+ return {
130
+ satisfied: false,
131
+ reason: stderr || "当前目录不是可用的 git 工作区,无法判断 commit 状态"
132
+ };
133
+ }
134
+
135
+ const dirtyLines = String(gitStatus.stdout ?? "")
136
+ .split("\n")
137
+ .map((line) => line.trimEnd())
138
+ .filter(Boolean);
139
+
140
+ if (dirtyLines.length === 0) {
141
+ return {
142
+ satisfied: true,
143
+ reason: "任务相关文件当前已无未提交改动,可视为已完成本地 commit"
144
+ };
145
+ }
146
+
147
+ const dirtyPaths = dirtyLines
148
+ .map((line) => line.slice(3))
149
+ .filter(Boolean);
150
+
151
+ return {
152
+ satisfied: false,
153
+ reason: `任务相关文件仍有未提交改动: ${dirtyPaths.join(", ")}`
154
+ };
155
+ }
156
+
157
+ function loadTaskReport(reportPath) {
158
+ const raw = fs.readFileSync(reportPath, "utf8");
159
+ return JSON.parse(raw);
160
+ }
161
+
162
+ function collectCommitCandidatePaths(report) {
163
+ const actualScope = Array.isArray(report?.actual_scope) ? report.actual_scope : [];
164
+ const outputArtifacts = report?.output_artifacts && typeof report.output_artifacts === "object"
165
+ ? Object.values(report.output_artifacts)
166
+ : [];
167
+
168
+ const artifactPaths = outputArtifacts
169
+ .filter((artifact) => artifact?.satisfied === true && typeof artifact?.path === "string" && artifact.path.trim().length > 0)
170
+ .map((artifact) => artifact.path.trim());
171
+
172
+ return Array.from(new Set([...actualScope, ...artifactPaths].filter(Boolean)));
173
+ }
174
+
175
+ function evaluateActionReadiness(name, policy, signalStatus) {
176
+ if (!policy) {
177
+ return {
178
+ configured: false,
179
+ mode: null,
180
+ via: null,
181
+ ready: null,
182
+ required_signals: [],
183
+ satisfied_signals: [],
184
+ missing_signals: [],
185
+ reason: `未配置 ${name} 策略`
186
+ };
187
+ }
188
+
189
+ const requiredSignals = policy.require.filter(Boolean);
190
+ const satisfiedSignals = requiredSignals.filter((signal) => signalStatus[signal]?.satisfied);
191
+ const missingSignals = requiredSignals.filter((signal) => !signalStatus[signal]?.satisfied);
192
+
193
+ return {
194
+ configured: true,
195
+ mode: policy.mode,
196
+ via: policy.via,
197
+ ready: missingSignals.length === 0,
198
+ required_signals: requiredSignals,
199
+ satisfied_signals: satisfiedSignals,
200
+ missing_signals: missingSignals,
201
+ reason: missingSignals.length === 0
202
+ ? `${name} 已满足前置条件`
203
+ : `缺少前置条件: ${missingSignals.join(", ")}`
204
+ };
205
+ }
206
+
207
+ function formatReadinessSummary(name, readiness) {
208
+ if (!readiness.configured) {
209
+ return `${name}=unconfigured`;
210
+ }
211
+
212
+ const status = readiness.ready ? "yes" : "no";
213
+ const base = `${name}=${readiness.mode} via=${readiness.via}, ready=${status}`;
214
+ if (readiness.ready) {
215
+ return base;
216
+ }
217
+
218
+ return `${base} (missing: ${readiness.missing_signals.join(", ")})`;
219
+ }
@@ -0,0 +1,266 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function normalizeOutputPolicy(value) {
5
+ const policy = value && typeof value === "object" && !Array.isArray(value) ? value : {};
6
+ return {
7
+ report: normalizeReportPolicy(policy.report),
8
+ changelog: normalizeChangelogPolicy(policy.changelog),
9
+ design_note: normalizeArtifactPolicy(policy.design_note, "docs/"),
10
+ adr: normalizeArtifactPolicy(policy.adr, "docs/")
11
+ };
12
+ }
13
+
14
+ export function evaluateTaskArtifactPolicy(taskState, outputPolicy) {
15
+ const normalizedPolicy = normalizeOutputPolicy(outputPolicy);
16
+ const taskContext = buildTaskContext(taskState);
17
+
18
+ return {
19
+ changelog: evaluateChangelogRequirement(normalizedPolicy.changelog, taskContext),
20
+ design_note: evaluateArtifactRequirement("design_note", normalizedPolicy.design_note, taskContext),
21
+ adr: evaluateArtifactRequirement("adr", normalizedPolicy.adr, taskContext)
22
+ };
23
+ }
24
+
25
+ export function validateTaskOutputArtifacts(cwd, taskState, outputPolicy, artifactInputs = {}) {
26
+ const requirements = evaluateTaskArtifactPolicy(taskState, outputPolicy);
27
+ const artifacts = {
28
+ changelog: validateChangelogArtifact(cwd, requirements.changelog, artifactInputs.changelog),
29
+ design_note: validateDocumentArtifact(cwd, "design_note", requirements.design_note, artifactInputs.design_note),
30
+ adr: validateDocumentArtifact(cwd, "adr", requirements.adr, artifactInputs.adr)
31
+ };
32
+
33
+ const missingRequired = Object.entries(artifacts)
34
+ .filter(([, artifact]) => artifact.required && !artifact.satisfied)
35
+ .map(([, artifact]) => artifact.name);
36
+
37
+ if (missingRequired.length > 0) {
38
+ const error = new Error(`缺少必需输出工件: ${missingRequired.join(", ")}`);
39
+ error.code = "MISSING_OUTPUT_ARTIFACTS";
40
+ error.missing_required = missingRequired;
41
+ error.artifacts = artifacts;
42
+ throw error;
43
+ }
44
+
45
+ return artifacts;
46
+ }
47
+
48
+ export function inspectOutputPolicyWorkspace(cwd, outputPolicy) {
49
+ const normalizedPolicy = normalizeOutputPolicy(outputPolicy);
50
+ const summary = [];
51
+ const warnings = [];
52
+
53
+ if (normalizedPolicy.report) {
54
+ summary.push(`report=${normalizedPolicy.report.format}@${normalizedPolicy.report.directory}`);
55
+ }
56
+
57
+ if (normalizedPolicy.changelog.mode !== "disabled") {
58
+ summary.push(`changelog=${normalizedPolicy.changelog.mode}`);
59
+ if (!fs.existsSync(path.join(cwd, normalizedPolicy.changelog.file))) {
60
+ warnings.push(`缺少 changelog 文件: ${normalizedPolicy.changelog.file}`);
61
+ }
62
+ }
63
+
64
+ if (normalizedPolicy.design_note.mode !== "disabled") {
65
+ summary.push(`design_note=${normalizedPolicy.design_note.mode}`);
66
+ if (!fs.existsSync(path.join(cwd, normalizedPolicy.design_note.directory))) {
67
+ warnings.push(`缺少 design_note 目录: ${normalizedPolicy.design_note.directory}`);
68
+ }
69
+ }
70
+
71
+ if (normalizedPolicy.adr.mode !== "disabled") {
72
+ summary.push(`adr=${normalizedPolicy.adr.mode}`);
73
+ if (!fs.existsSync(path.join(cwd, normalizedPolicy.adr.directory))) {
74
+ warnings.push(`缺少 adr 目录: ${normalizedPolicy.adr.directory}`);
75
+ }
76
+ }
77
+
78
+ return {
79
+ summary: summary.join(", "),
80
+ warnings
81
+ };
82
+ }
83
+
84
+ function normalizeReportPolicy(value) {
85
+ const policy = value && typeof value === "object" && !Array.isArray(value) ? value : {};
86
+ return {
87
+ required: policy.required !== false,
88
+ format: typeof policy.format === "string" ? policy.format : "json",
89
+ directory: typeof policy.directory === "string" ? policy.directory : ".harness/reports",
90
+ required_sections: Array.isArray(policy.required_sections)
91
+ ? policy.required_sections.map((item) => String(item))
92
+ : []
93
+ };
94
+ }
95
+
96
+ function normalizeChangelogPolicy(value) {
97
+ const policy = value && typeof value === "object" && !Array.isArray(value) ? value : {};
98
+ return {
99
+ mode: normalizeMode(policy.mode),
100
+ file: typeof policy.file === "string" ? policy.file : "CHANGELOG.md",
101
+ required_for: Array.isArray(policy.required_for) ? policy.required_for.map((item) => String(item)) : [],
102
+ skip_if: Array.isArray(policy.skip_if) ? policy.skip_if : []
103
+ };
104
+ }
105
+
106
+ function normalizeArtifactPolicy(value, defaultDirectory) {
107
+ const policy = value && typeof value === "object" && !Array.isArray(value) ? value : {};
108
+ return {
109
+ mode: normalizeMode(policy.mode),
110
+ directory: typeof policy.directory === "string" ? policy.directory : defaultDirectory,
111
+ required_if: Array.isArray(policy.required_if) ? policy.required_if : []
112
+ };
113
+ }
114
+
115
+ function normalizeMode(value) {
116
+ return typeof value === "string" ? value : "disabled";
117
+ }
118
+
119
+ function buildTaskContext(taskState) {
120
+ const contract = taskState?.confirmed_contract ?? {};
121
+ const draft = taskState?.task_draft ?? {};
122
+ return {
123
+ goal: String(contract.goal ?? draft.goal ?? ""),
124
+ intent: String(contract.intent ?? draft.intent ?? "unknown"),
125
+ risk_level: String(contract.risk_level ?? draft?.derived?.risk_level ?? "medium"),
126
+ scope: normalizeStringArray(contract.scope ?? draft.scope)
127
+ };
128
+ }
129
+
130
+ function evaluateChangelogRequirement(policy, taskContext) {
131
+ const requiredByMode = policy.mode === "required";
132
+ const requiredByCondition = policy.mode === "conditional" &&
133
+ policy.required_for.includes(taskContext.intent) &&
134
+ !policy.skip_if.some((condition) => matchesObjectCondition(condition, taskContext));
135
+
136
+ return {
137
+ file: policy.file,
138
+ mode: policy.mode,
139
+ name: "changelog",
140
+ required: requiredByMode || requiredByCondition
141
+ };
142
+ }
143
+
144
+ function evaluateArtifactRequirement(name, policy, taskContext) {
145
+ return {
146
+ directory: policy.directory,
147
+ mode: policy.mode,
148
+ name,
149
+ required: policy.mode === "required" || (
150
+ policy.mode === "conditional" &&
151
+ policy.required_if.some((condition) => matchesArtifactCondition(condition, taskContext))
152
+ )
153
+ };
154
+ }
155
+
156
+ function validateChangelogArtifact(cwd, requirement, explicitPath) {
157
+ const resolvedPath = explicitPath ?? requirement.file;
158
+ const absolutePath = path.join(cwd, resolvedPath);
159
+ const exists = fs.existsSync(absolutePath);
160
+ return {
161
+ name: requirement.name,
162
+ path: resolvedPath,
163
+ required: requirement.required,
164
+ satisfied: exists,
165
+ reason: requirement.required
166
+ ? (exists ? "满足 changelog 要求" : `缺少 changelog 文件: ${resolvedPath}`)
167
+ : (exists ? "检测到 changelog 文件" : "当前任务不强制要求 changelog")
168
+ };
169
+ }
170
+
171
+ function validateDocumentArtifact(cwd, name, requirement, explicitPath) {
172
+ if (!explicitPath) {
173
+ return {
174
+ name,
175
+ path: null,
176
+ required: requirement.required,
177
+ satisfied: false,
178
+ reason: requirement.required
179
+ ? `缺少 --${name.replace("_", "-")} 参数`
180
+ : `当前任务不强制要求 ${name}`
181
+ };
182
+ }
183
+
184
+ const absolutePath = path.join(cwd, explicitPath);
185
+ const exists = fs.existsSync(absolutePath);
186
+ return {
187
+ name,
188
+ path: explicitPath,
189
+ required: requirement.required,
190
+ satisfied: exists,
191
+ reason: exists ? `已提供 ${name}` : `${name} 文件不存在: ${explicitPath}`
192
+ };
193
+ }
194
+
195
+ function matchesArtifactCondition(condition, taskContext) {
196
+ if (typeof condition === "string") {
197
+ const normalized = condition.trim().toLowerCase();
198
+ if (normalized === "cross_module_change") {
199
+ return inferCrossModuleChange(taskContext.scope);
200
+ }
201
+ if (normalized === "public_contract_changed") {
202
+ return hasKeyword(taskContext.goal, ["api", "schema", "接口", "契约", "协议", "contract"]);
203
+ }
204
+ if (normalized === "reusable_decision") {
205
+ return hasKeyword(taskContext.goal, ["复用", "通用", "shared", "reusable"]);
206
+ }
207
+ if (normalized === "architectural_decision") {
208
+ return hasKeyword(taskContext.goal, ["架构", "architecture", "协议", "adapter", "hook"]);
209
+ }
210
+ if (normalized === "policy_change") {
211
+ return hasKeyword(taskContext.goal, ["策略", "policy", "规则"]);
212
+ }
213
+ if (normalized === "protocol_change") {
214
+ return hasKeyword(taskContext.goal, ["协议", "protocol"]);
215
+ }
216
+ if (normalized === "host_adapter_contract_change") {
217
+ return hasKeyword(taskContext.goal, ["adapter", "适配", "hook", "codex", "claude", "gemini"]);
218
+ }
219
+ return false;
220
+ }
221
+
222
+ if (condition && typeof condition === "object" && !Array.isArray(condition)) {
223
+ return matchesObjectCondition(condition, taskContext);
224
+ }
225
+
226
+ return false;
227
+ }
228
+
229
+ function matchesObjectCondition(condition, taskContext) {
230
+ return Object.entries(condition).every(([key, value]) => {
231
+ if (key === "intent") {
232
+ return taskContext.intent === String(value);
233
+ }
234
+ if (key === "risk_level") {
235
+ return taskContext.risk_level === String(value);
236
+ }
237
+ return false;
238
+ });
239
+ }
240
+
241
+ function inferCrossModuleChange(scope) {
242
+ const modules = new Set();
243
+ for (const item of scope) {
244
+ if (!item || item === "待澄清作用范围") {
245
+ continue;
246
+ }
247
+
248
+ const firstSegment = item.replace(/^\.\//, "").split("/")[0];
249
+ if (firstSegment) {
250
+ modules.add(firstSegment);
251
+ }
252
+ }
253
+ return modules.size >= 2;
254
+ }
255
+
256
+ function hasKeyword(text, keywords) {
257
+ const normalized = String(text ?? "").toLowerCase();
258
+ return keywords.some((keyword) => normalized.includes(String(keyword).toLowerCase()));
259
+ }
260
+
261
+ function normalizeStringArray(value) {
262
+ if (!Array.isArray(value)) {
263
+ return [];
264
+ }
265
+ return value.map((item) => String(item)).filter(Boolean);
266
+ }