@aipper/aiws 0.0.24 → 0.0.26
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/README.md +89 -33
- package/package.json +3 -3
- package/src/cli.js +40 -2
- package/src/codex-skills.js +4 -4
- package/src/commands/change-advice.js +191 -0
- package/src/commands/change-evidence-command.js +242 -0
- package/src/commands/change-evidence-entry.js +40 -0
- package/src/commands/change-evidence.js +338 -0
- package/src/commands/change-finish.js +484 -0
- package/src/commands/change-git-status.js +53 -0
- package/src/commands/change-lifecycle-entry.js +60 -0
- package/src/commands/change-lifecycle.js +234 -0
- package/src/commands/change-metrics-command.js +110 -0
- package/src/commands/change-next-command.js +132 -0
- package/src/commands/change-review-gates.js +315 -0
- package/src/commands/change-scope-gate.js +34 -0
- package/src/commands/change-start.js +138 -0
- package/src/commands/change-state-command.js +40 -0
- package/src/commands/change-status-command.js +126 -0
- package/src/commands/change-status-context.js +202 -0
- package/src/commands/change-validate-entry.js +45 -0
- package/src/commands/change-validate.js +127 -0
- package/src/commands/change.js +540 -1692
- package/src/commands/dashboard.js +38 -12
- package/src/commands/init.js +1 -1
- package/src/commands/opencode-status.js +61 -0
- package/src/commands/update.js +5 -1
- package/src/dashboard/app.js +205 -2
- package/src/dashboard/index.html +5 -2
- package/src/dashboard/style.css +86 -0
- package/src/governance.js +157 -0
- package/src/json-schema-lite.js +164 -0
- package/src/opencode-env.js +76 -0
- package/src/spec.js +131 -0
- package/src/template.js +42 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
buildDeliverySummary,
|
|
5
|
+
collectCollaborationArtifacts,
|
|
6
|
+
findLatestFileByMtime,
|
|
7
|
+
formatDeclaredValues,
|
|
8
|
+
mergeDeclaredValues,
|
|
9
|
+
relFromRoot,
|
|
10
|
+
upsertIdLine,
|
|
11
|
+
upsertProposalEvidencePath,
|
|
12
|
+
} from "./change-evidence.js";
|
|
13
|
+
import { TMP_REVIEW_FALLBACKS, tmpReviewFallbackPath } from "./change-review-gates.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* gitRoot: string,
|
|
18
|
+
* changeId: string,
|
|
19
|
+
* changeDir: string,
|
|
20
|
+
* proposalText: string,
|
|
21
|
+
* options: { noValidate: boolean, allowFail: boolean }
|
|
22
|
+
* }} input
|
|
23
|
+
* @param {{
|
|
24
|
+
* appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
|
|
25
|
+
* computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
|
|
26
|
+
* currentBranch: (gitRoot: string) => Promise<string>,
|
|
27
|
+
* ensureDir: (path: string) => Promise<void>,
|
|
28
|
+
* extractId: (label: string, text: string) => string,
|
|
29
|
+
* nowIsoUtc: () => string,
|
|
30
|
+
* nowStampUtc: () => string,
|
|
31
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
32
|
+
* planVerifyHint: (changeId: string) => string,
|
|
33
|
+
* readText: (path: string) => Promise<string>,
|
|
34
|
+
* splitDeclaredValues: (s: string) => string[],
|
|
35
|
+
* validateChangeArtifacts: (gitRoot: string, changeId: string, options: any) => Promise<any>,
|
|
36
|
+
* writeText: (path: string, content: string) => Promise<void>,
|
|
37
|
+
* UserError: typeof import("../errors.js").UserError
|
|
38
|
+
* }} deps
|
|
39
|
+
*/
|
|
40
|
+
export async function runChangeEvidenceWorkflow(input, deps) {
|
|
41
|
+
const { gitRoot, changeId, changeDir, proposalText, options } = input;
|
|
42
|
+
const planDecl = deps.extractId("Plan_File", proposalText) || deps.extractId("Plan file", proposalText);
|
|
43
|
+
const planFiles = deps.splitDeclaredValues(planDecl).map((item) => String(item || "").replace(/^\.\//, ""));
|
|
44
|
+
const planRel = planFiles.length === 1 ? planFiles[0] : "";
|
|
45
|
+
const planAbs = planRel ? path.join(gitRoot, planRel) : "";
|
|
46
|
+
const planText = planAbs && (await deps.pathExists(planAbs)) ? await deps.readText(planAbs) : "";
|
|
47
|
+
|
|
48
|
+
const now = deps.nowStampUtc();
|
|
49
|
+
const evidenceDir = path.join(changeDir, "evidence");
|
|
50
|
+
const reviewDir = path.join(changeDir, "review");
|
|
51
|
+
const analysisDir = path.join(changeDir, "analysis");
|
|
52
|
+
const patchesDir = path.join(changeDir, "patches");
|
|
53
|
+
await deps.ensureDir(evidenceDir);
|
|
54
|
+
await deps.ensureDir(reviewDir);
|
|
55
|
+
await deps.ensureDir(analysisDir);
|
|
56
|
+
await deps.ensureDir(patchesDir);
|
|
57
|
+
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const created = [];
|
|
60
|
+
const branch = await deps.currentBranch(gitRoot);
|
|
61
|
+
const status = await deps.computeChangeStatus(gitRoot, changeId);
|
|
62
|
+
|
|
63
|
+
const statusPath = path.join(evidenceDir, `change-status-${now}.json`);
|
|
64
|
+
await deps.writeText(
|
|
65
|
+
statusPath,
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
{
|
|
68
|
+
generated_at: deps.nowIsoUtc(),
|
|
69
|
+
ws_root: gitRoot,
|
|
70
|
+
change_id: changeId,
|
|
71
|
+
branch: branch || "",
|
|
72
|
+
phase: status.phase,
|
|
73
|
+
tasks: status.tasks,
|
|
74
|
+
bindings: status.bindings,
|
|
75
|
+
truth: {
|
|
76
|
+
baseline: { label: status.baselineLabel, at: status.baselineAt },
|
|
77
|
+
drift_files: status.driftFiles,
|
|
78
|
+
},
|
|
79
|
+
evidence: status.evidence?.counts || null,
|
|
80
|
+
collaboration: status.collaboration?.counts || null,
|
|
81
|
+
note: "aiws change evidence status snapshot; does not contain secrets.",
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
) + "\n",
|
|
86
|
+
);
|
|
87
|
+
created.push(relFromRoot(gitRoot, statusPath));
|
|
88
|
+
|
|
89
|
+
/** @type {any | null} */
|
|
90
|
+
let validateRes = null;
|
|
91
|
+
let validateFailed = false;
|
|
92
|
+
let validatePathForError = "";
|
|
93
|
+
if (!options.noValidate) {
|
|
94
|
+
validateRes = await deps.validateChangeArtifacts(gitRoot, changeId, { strict: true, allowTruthDrift: false });
|
|
95
|
+
const validatePath = path.join(evidenceDir, `change-validate-strict-${now}.json`);
|
|
96
|
+
await deps.writeText(validatePath, JSON.stringify(validateRes, null, 2) + "\n");
|
|
97
|
+
created.push(relFromRoot(gitRoot, validatePath));
|
|
98
|
+
validateFailed = validateRes.ok !== true;
|
|
99
|
+
validatePathForError = validatePath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const validateStampDir = path.join(gitRoot, ".agentdocs", "tmp", "aiws-validate");
|
|
103
|
+
const latestValidateStamp = await findLatestFileByMtime(validateStampDir, (name) => name.endsWith(".json"));
|
|
104
|
+
if (latestValidateStamp) {
|
|
105
|
+
const dest = path.join(evidenceDir, `aiws-validate-stamp-${now}.json`);
|
|
106
|
+
await fs.copyFile(latestValidateStamp, dest);
|
|
107
|
+
created.push(relFromRoot(gitRoot, dest));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const syncStampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
111
|
+
const latestSyncStamp = await findLatestFileByMtime(syncStampDir, (name) => name.endsWith(`-${changeId}.json`));
|
|
112
|
+
if (latestSyncStamp) {
|
|
113
|
+
const dest = path.join(evidenceDir, `change-sync-stamp-${now}.json`);
|
|
114
|
+
await fs.copyFile(latestSyncStamp, dest);
|
|
115
|
+
created.push(relFromRoot(gitRoot, dest));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const { fileName, persistentKind } of TMP_REVIEW_FALLBACKS) {
|
|
119
|
+
const src = tmpReviewFallbackPath(gitRoot, fileName);
|
|
120
|
+
if (!(await deps.pathExists(src))) continue;
|
|
121
|
+
const dest = persistentKind === "evidence" ? path.join(evidenceDir, fileName) : path.join(reviewDir, fileName);
|
|
122
|
+
if (!(await deps.pathExists(dest))) {
|
|
123
|
+
await fs.copyFile(src, dest);
|
|
124
|
+
}
|
|
125
|
+
const rel = relFromRoot(gitRoot, dest);
|
|
126
|
+
if (!created.includes(rel)) created.push(rel);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (await deps.pathExists(reviewDir)) {
|
|
130
|
+
try {
|
|
131
|
+
const reviewEntries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
132
|
+
for (const entry of reviewEntries) {
|
|
133
|
+
if (!entry.isFile()) continue;
|
|
134
|
+
if (entry.name === "codex-review.md") continue;
|
|
135
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
136
|
+
const rel = relFromRoot(gitRoot, path.join(reviewDir, entry.name));
|
|
137
|
+
if (!created.includes(rel)) created.push(rel);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
|
|
145
|
+
const collaborationPath = path.join(evidenceDir, `collaboration-summary-${now}.json`);
|
|
146
|
+
await deps.writeText(
|
|
147
|
+
collaborationPath,
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
generated_at: deps.nowIsoUtc(),
|
|
151
|
+
ws_root: gitRoot,
|
|
152
|
+
change_id: changeId,
|
|
153
|
+
counts: collaboration.counts,
|
|
154
|
+
dirs: collaboration.dirs,
|
|
155
|
+
note: "aiws collaboration summary; patch files are drafts and must be reviewed before apply.",
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
) + "\n",
|
|
160
|
+
);
|
|
161
|
+
created.push(relFromRoot(gitRoot, collaborationPath));
|
|
162
|
+
|
|
163
|
+
const summaryAbs = path.join(evidenceDir, `delivery-summary-${now}.md`);
|
|
164
|
+
await deps.writeText(
|
|
165
|
+
summaryAbs,
|
|
166
|
+
buildDeliverySummary({
|
|
167
|
+
gitRoot,
|
|
168
|
+
changeId,
|
|
169
|
+
branch: branch || "",
|
|
170
|
+
status,
|
|
171
|
+
collaboration,
|
|
172
|
+
created,
|
|
173
|
+
validateRes,
|
|
174
|
+
validateFailed,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
created.push(relFromRoot(gitRoot, summaryAbs));
|
|
178
|
+
|
|
179
|
+
const declaredEvidence = deps.extractId("Evidence_Path", proposalText) || deps.extractId("Evidence_Path(s)", proposalText);
|
|
180
|
+
const existingProposalEvidence = deps.splitDeclaredValues(declaredEvidence).map((item) => String(item || "").replace(/^\.\//, ""));
|
|
181
|
+
const mergedEvidence = mergeDeclaredValues(existingProposalEvidence, created);
|
|
182
|
+
const mergedEvidenceText = formatDeclaredValues(mergedEvidence);
|
|
183
|
+
|
|
184
|
+
const updatedProposal = upsertProposalEvidencePath(proposalText, mergedEvidenceText);
|
|
185
|
+
if (updatedProposal.changed) {
|
|
186
|
+
await deps.writeText(path.join(changeDir, "proposal.md"), updatedProposal.text);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let planUpdated = false;
|
|
190
|
+
if (planAbs && planText) {
|
|
191
|
+
const existingPlanEvidence = deps
|
|
192
|
+
.splitDeclaredValues(deps.extractId("Evidence_Path", planText) || deps.extractId("Evidence_Path(s)", planText))
|
|
193
|
+
.map((item) => String(item || "").replace(/^\.\//, ""));
|
|
194
|
+
const mergedPlanEvidence = mergeDeclaredValues(existingPlanEvidence, mergedEvidence);
|
|
195
|
+
const mergedPlanEvidenceText = formatDeclaredValues(mergedPlanEvidence);
|
|
196
|
+
const updatedPlan = upsertIdLine(planText, ["Evidence_Path", "Evidence_Path(s)"], mergedPlanEvidenceText, { insertAfterLabels: ["Plan_File", "Contract_Row"] });
|
|
197
|
+
if (updatedPlan.changed) {
|
|
198
|
+
await deps.writeText(planAbs, updatedPlan.text);
|
|
199
|
+
planUpdated = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await deps.appendMetricsEvent(gitRoot, changeId, "evidence", {
|
|
204
|
+
generated_at: deps.nowIsoUtc(),
|
|
205
|
+
created: created.length,
|
|
206
|
+
strict_validate: options.noValidate ? "skipped" : validateRes && validateRes.ok === true ? "ok" : "failed",
|
|
207
|
+
allow_fail: options.allowFail === true,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/** @type {string[]} */
|
|
211
|
+
const outputLines = [];
|
|
212
|
+
outputLines.push(`✓ aiws change evidence: ${changeId}`);
|
|
213
|
+
outputLines.push(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
214
|
+
if (planRel) outputLines.push(`Plan_File: ${planRel}`);
|
|
215
|
+
outputLines.push("created:");
|
|
216
|
+
if (created.length > 0) {
|
|
217
|
+
for (const item of created) outputLines.push(`- ${item}`);
|
|
218
|
+
} else {
|
|
219
|
+
outputLines.push("- (none)");
|
|
220
|
+
}
|
|
221
|
+
outputLines.push("updated:");
|
|
222
|
+
outputLines.push(`- ${path.relative(gitRoot, path.join(changeDir, "proposal.md"))} (Evidence_Path)`);
|
|
223
|
+
if (planRel && planText && planUpdated) outputLines.push(`- ${planRel} (Evidence_Path)`);
|
|
224
|
+
else if (planRel && planText) outputLines.push(`- ${planRel} (Evidence_Path)`);
|
|
225
|
+
outputLines.push("");
|
|
226
|
+
outputLines.push("next:");
|
|
227
|
+
outputLines.push(`- ${deps.planVerifyHint(changeId)}`);
|
|
228
|
+
if (validateFailed) outputLines.push("- fix validation errors, then re-run evidence (or run with --allow-fail to proceed)");
|
|
229
|
+
else outputLines.push(`- verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
230
|
+
|
|
231
|
+
let errorToThrow = null;
|
|
232
|
+
if (validateFailed && !options.allowFail) {
|
|
233
|
+
const details = (validateRes && validateRes.raw ? String(validateRes.raw) : "").trim() || "Validation failed (see evidence JSON for details).";
|
|
234
|
+
const hint = validatePathForError ? `\n\nevidence:\n- ${path.relative(gitRoot, validatePathForError)}` : "";
|
|
235
|
+
errorToThrow = new deps.UserError("Failed to generate evidence due to strict validation errors.", { details: `${details}${hint}` });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
output: outputLines.join("\n") + "\n",
|
|
240
|
+
errorToThrow,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* gitRoot: string,
|
|
6
|
+
* changeId: string,
|
|
7
|
+
* options: { noValidate: boolean, allowFail: boolean }
|
|
8
|
+
* }} input
|
|
9
|
+
* @param {{
|
|
10
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
11
|
+
* fileState: (changeDir: string, rel: string) => Promise<any>,
|
|
12
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
13
|
+
* runChangeEvidenceWorkflow: (input: any, deps: any) => Promise<{ output: string, errorToThrow: any }>,
|
|
14
|
+
* workflowDeps: Record<string, any>,
|
|
15
|
+
* UserError: typeof import("../errors.js").UserError
|
|
16
|
+
* }} deps
|
|
17
|
+
*/
|
|
18
|
+
export async function runChangeEvidenceCommand(input, deps) {
|
|
19
|
+
const { gitRoot, changeId, options } = input;
|
|
20
|
+
const changeDir = deps.changeDirAbs(gitRoot, changeId);
|
|
21
|
+
if (!(await deps.pathExists(changeDir))) {
|
|
22
|
+
throw new deps.UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const proposal = await deps.fileState(changeDir, "proposal.md");
|
|
26
|
+
if (proposal.state !== "ok") {
|
|
27
|
+
throw new deps.UserError("proposal.md is required for evidence generation.", { details: path.relative(gitRoot, proposal.abs) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return deps.runChangeEvidenceWorkflow(
|
|
31
|
+
{
|
|
32
|
+
gitRoot,
|
|
33
|
+
changeId,
|
|
34
|
+
changeDir,
|
|
35
|
+
proposalText: proposal.text,
|
|
36
|
+
options,
|
|
37
|
+
},
|
|
38
|
+
deps.workflowDeps,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { pathExists } from "../fs.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} input
|
|
7
|
+
*/
|
|
8
|
+
function normalizeSlashes(input) {
|
|
9
|
+
return String(input || "").replaceAll("\\", "/");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} gitRoot
|
|
14
|
+
* @param {string} absPath
|
|
15
|
+
*/
|
|
16
|
+
export function relFromRoot(gitRoot, absPath) {
|
|
17
|
+
return normalizeSlashes(path.relative(gitRoot, absPath));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} absDir
|
|
22
|
+
* @param {{ maxDepth?: number, maxFiles?: number }} [options]
|
|
23
|
+
* @returns {Promise<string[]>}
|
|
24
|
+
*/
|
|
25
|
+
async function listFilesRecursive(absDir, options) {
|
|
26
|
+
const maxDepth = Number(options?.maxDepth ?? 4);
|
|
27
|
+
const maxFiles = Number(options?.maxFiles ?? 200);
|
|
28
|
+
/** @type {string[]} */
|
|
29
|
+
const out = [];
|
|
30
|
+
|
|
31
|
+
/** @param {string} curAbs @param {number} depth */
|
|
32
|
+
async function walk(curAbs, depth) {
|
|
33
|
+
if (depth > maxDepth) return;
|
|
34
|
+
let entries = [];
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(curAbs, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (out.length >= maxFiles) return;
|
|
43
|
+
const nextAbs = path.join(curAbs, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
await walk(nextAbs, depth + 1);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (entry.isFile()) out.push(nextAbs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!(await pathExists(absDir))) return out;
|
|
53
|
+
await walk(absDir, 0);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} gitRoot
|
|
59
|
+
* @param {string} changeDir
|
|
60
|
+
*/
|
|
61
|
+
export async function collectCollaborationArtifacts(gitRoot, changeDir) {
|
|
62
|
+
const groups = [
|
|
63
|
+
["analysis", "analysis"],
|
|
64
|
+
["patches", "patches"],
|
|
65
|
+
["review", "review"],
|
|
66
|
+
["evidence", "evidence"],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** @type {Record<string, { label: string, dir: string, exists: boolean, count: number, files: string[], truncated: boolean }>} */
|
|
70
|
+
const dirs = {};
|
|
71
|
+
const counts = {
|
|
72
|
+
analysis: 0,
|
|
73
|
+
patches: 0,
|
|
74
|
+
review: 0,
|
|
75
|
+
evidence: 0,
|
|
76
|
+
total: 0,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const [key, label] of groups) {
|
|
80
|
+
const abs = path.join(changeDir, key);
|
|
81
|
+
const exists = await pathExists(abs);
|
|
82
|
+
const filesAbs = exists ? await listFilesRecursive(abs, { maxDepth: 4, maxFiles: 200 }) : [];
|
|
83
|
+
const files = filesAbs.map((item) => relFromRoot(gitRoot, item));
|
|
84
|
+
dirs[key] = {
|
|
85
|
+
label,
|
|
86
|
+
dir: relFromRoot(gitRoot, abs),
|
|
87
|
+
exists,
|
|
88
|
+
count: files.length,
|
|
89
|
+
files: files.slice(0, 8),
|
|
90
|
+
truncated: files.length > 8,
|
|
91
|
+
};
|
|
92
|
+
counts[key] = files.length;
|
|
93
|
+
counts.total += files.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { dirs, counts };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Evidence_Path is user-declared; we treat it as best-effort hints, not hard gates.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} gitRoot
|
|
103
|
+
* @param {string} changeId
|
|
104
|
+
* @param {string[]} evidencePaths
|
|
105
|
+
* @returns {Promise<{
|
|
106
|
+
* entries: Array<{rel: string, abs: string, exists: boolean, kind: string, isAbsolute: boolean}>,
|
|
107
|
+
* counts: { total: number, exists: number, missing: number, tmp: number, persistent: number, other: number, absolute: number },
|
|
108
|
+
* missing: string[]
|
|
109
|
+
* }>}
|
|
110
|
+
*/
|
|
111
|
+
export async function analyzeEvidencePaths(gitRoot, changeId, evidencePaths) {
|
|
112
|
+
const changePrefix = `changes/${changeId}/`;
|
|
113
|
+
const evidencePrefix = `changes/${changeId}/evidence/`;
|
|
114
|
+
const reviewPrefix = `changes/${changeId}/review/`;
|
|
115
|
+
|
|
116
|
+
const entries = await Promise.all(
|
|
117
|
+
(evidencePaths || []).map(async (raw) => {
|
|
118
|
+
const rel = String(raw || "").trim();
|
|
119
|
+
const isAbsolute = path.isAbsolute(rel);
|
|
120
|
+
const abs = isAbsolute ? rel : path.join(gitRoot, rel);
|
|
121
|
+
const exists = rel ? await pathExists(abs) : false;
|
|
122
|
+
|
|
123
|
+
const relNorm = normalizeSlashes(rel);
|
|
124
|
+
let kind = "other";
|
|
125
|
+
if (relNorm.startsWith(".agentdocs/tmp/") || relNorm.startsWith(".agentdocs/")) kind = "tmp";
|
|
126
|
+
else if (relNorm.startsWith(evidencePrefix) || relNorm.startsWith(reviewPrefix) || relNorm.startsWith(changePrefix)) kind = "persistent";
|
|
127
|
+
|
|
128
|
+
return { rel, abs: path.resolve(abs), exists, kind, isAbsolute };
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const missing = entries.filter((entry) => entry.rel && !entry.exists).map((entry) => entry.rel);
|
|
133
|
+
const counts = {
|
|
134
|
+
total: entries.filter((entry) => entry.rel).length,
|
|
135
|
+
exists: entries.filter((entry) => entry.rel && entry.exists).length,
|
|
136
|
+
missing: missing.length,
|
|
137
|
+
tmp: entries.filter((entry) => entry.rel && entry.kind === "tmp").length,
|
|
138
|
+
persistent: entries.filter((entry) => entry.rel && entry.kind === "persistent").length,
|
|
139
|
+
other: entries.filter((entry) => entry.rel && entry.kind === "other").length,
|
|
140
|
+
absolute: entries.filter((entry) => entry.rel && entry.isAbsolute).length,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { entries: entries.filter((entry) => entry.rel), counts, missing };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string[]} a
|
|
148
|
+
* @param {string[]} b
|
|
149
|
+
*/
|
|
150
|
+
export function mergeDeclaredValues(a, b) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
/** @type {string[]} */
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const raw of [...(a || []), ...(b || [])]) {
|
|
155
|
+
const value = String(raw || "").trim().replace(/^`+/, "").replace(/`+$/, "").trim();
|
|
156
|
+
if (!value) continue;
|
|
157
|
+
if (seen.has(value)) continue;
|
|
158
|
+
seen.add(value);
|
|
159
|
+
out.push(value);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string[]} values
|
|
166
|
+
*/
|
|
167
|
+
export function formatDeclaredValues(values) {
|
|
168
|
+
return (values || []).join(", ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} input
|
|
173
|
+
*/
|
|
174
|
+
function escapeRegExp(input) {
|
|
175
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replace or insert a simple "ID: value" style line in Markdown.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} text
|
|
182
|
+
* @param {string[]} labelCandidates
|
|
183
|
+
* @param {string} value
|
|
184
|
+
* @param {{ insertAfterLabels?: string[] }} options
|
|
185
|
+
*/
|
|
186
|
+
export function upsertIdLine(text, labelCandidates, value, options) {
|
|
187
|
+
const src = String(text || "");
|
|
188
|
+
const labels = Array.isArray(labelCandidates) ? labelCandidates : [];
|
|
189
|
+
for (const label of labels) {
|
|
190
|
+
const re = new RegExp(`^(.*${escapeRegExp(label)}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
191
|
+
const match = re.exec(src);
|
|
192
|
+
if (!match) continue;
|
|
193
|
+
const prefix = match[1] || "";
|
|
194
|
+
const replaced = src.replace(re, `${prefix}${value}`);
|
|
195
|
+
return { ok: true, changed: replaced !== src, text: replaced, mode: "replaced", label };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const insertAfter = Array.isArray(options?.insertAfterLabels) ? options.insertAfterLabels : [];
|
|
199
|
+
const lines = src.split(/\r?\n/);
|
|
200
|
+
let idx = 0;
|
|
201
|
+
for (let i = 0; i < Math.min(lines.length, 80); i++) {
|
|
202
|
+
const line = lines[i] || "";
|
|
203
|
+
if (insertAfter.some((label) => line.includes(label))) idx = i + 1;
|
|
204
|
+
}
|
|
205
|
+
const insertedLabel = labels[0] || "Evidence_Path";
|
|
206
|
+
lines.splice(idx, 0, `- ${insertedLabel}: ${value}`);
|
|
207
|
+
const inserted = lines.join("\n");
|
|
208
|
+
return { ok: true, changed: inserted !== src, text: inserted, mode: "inserted", label: labels[0] || "" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Special-case insertion for proposal.md template style.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} proposalText
|
|
215
|
+
* @param {string} value
|
|
216
|
+
*/
|
|
217
|
+
export function upsertProposalEvidencePath(proposalText, value) {
|
|
218
|
+
const src = String(proposalText || "");
|
|
219
|
+
const re = new RegExp(`^(.*${escapeRegExp("Evidence_Path")}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
220
|
+
if (re.test(src)) {
|
|
221
|
+
const match = re.exec(src);
|
|
222
|
+
const prefix = match?.[1] || "";
|
|
223
|
+
const out = src.replace(re, `${prefix}${value}`);
|
|
224
|
+
return { changed: out !== src, text: out, mode: "replaced" };
|
|
225
|
+
}
|
|
226
|
+
const lines = src.split(/\r?\n/);
|
|
227
|
+
let idx = 0;
|
|
228
|
+
for (let i = 0; i < Math.min(lines.length, 120); i++) {
|
|
229
|
+
if ((lines[i] || "").includes("Plan_File")) {
|
|
230
|
+
idx = i + 1;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
lines.splice(idx, 0, `- \`Evidence_Path\` = ${value}`);
|
|
235
|
+
const inserted = lines.join("\n");
|
|
236
|
+
return { changed: inserted !== src, text: inserted, mode: "inserted" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {string} changeId
|
|
241
|
+
*/
|
|
242
|
+
export function planVerifyHint(changeId) {
|
|
243
|
+
return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {string} absDir
|
|
248
|
+
* @param {(fileName: string) => boolean} predicate
|
|
249
|
+
*/
|
|
250
|
+
export async function findLatestFileByMtime(absDir, predicate) {
|
|
251
|
+
/** @type {Array<{ abs: string, mtimeMs: number }>} */
|
|
252
|
+
const matches = [];
|
|
253
|
+
if (!(await pathExists(absDir))) return "";
|
|
254
|
+
try {
|
|
255
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
if (!entry.isFile()) continue;
|
|
258
|
+
if (!predicate(entry.name)) continue;
|
|
259
|
+
const abs = path.join(absDir, entry.name);
|
|
260
|
+
const st = await fs.stat(abs);
|
|
261
|
+
matches.push({ abs, mtimeMs: st.mtimeMs });
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
matches.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
267
|
+
return matches[0]?.abs || "";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {{
|
|
272
|
+
* gitRoot: string,
|
|
273
|
+
* changeId: string,
|
|
274
|
+
* branch: string,
|
|
275
|
+
* status: any,
|
|
276
|
+
* collaboration: any,
|
|
277
|
+
* created: string[],
|
|
278
|
+
* validateRes: any,
|
|
279
|
+
* validateFailed: boolean
|
|
280
|
+
* }} options
|
|
281
|
+
*/
|
|
282
|
+
export function buildDeliverySummary(options) {
|
|
283
|
+
const { gitRoot, changeId, branch, status, collaboration, created, validateRes, validateFailed } = options;
|
|
284
|
+
/** @type {string[]} */
|
|
285
|
+
const lines = [];
|
|
286
|
+
lines.push(`# Delivery Summary: ${changeId}`);
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push(`Generated: ${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}`);
|
|
289
|
+
lines.push("");
|
|
290
|
+
lines.push("## Context");
|
|
291
|
+
lines.push(`- Worktree: \`${gitRoot}\``);
|
|
292
|
+
lines.push(`- Branch: \`${branch || "(detached)"}\``);
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push("## Status");
|
|
295
|
+
lines.push(`- Phase: \`${status.phase}\``);
|
|
296
|
+
lines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
|
|
297
|
+
lines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
|
|
298
|
+
lines.push("");
|
|
299
|
+
lines.push("## Collaboration");
|
|
300
|
+
lines.push(`- analysis: ${collaboration.counts.analysis}`);
|
|
301
|
+
lines.push(`- patches: ${collaboration.counts.patches}`);
|
|
302
|
+
lines.push(`- review: ${collaboration.counts.review}`);
|
|
303
|
+
lines.push(`- evidence dir files: ${collaboration.counts.evidence}`);
|
|
304
|
+
for (const key of ["analysis", "patches", "review"]) {
|
|
305
|
+
const group = collaboration.dirs[key];
|
|
306
|
+
if (!group || group.count === 0) continue;
|
|
307
|
+
lines.push(`- ${group.label} files:`);
|
|
308
|
+
for (const rel of group.files) lines.push(` - \`${rel}\``);
|
|
309
|
+
if (group.truncated) lines.push(" - ...(truncated)");
|
|
310
|
+
}
|
|
311
|
+
lines.push("");
|
|
312
|
+
lines.push("## Bindings");
|
|
313
|
+
if (status.reqId) lines.push(`- Req_ID: \`${status.reqId}\``);
|
|
314
|
+
if (status.probId) lines.push(`- Problem_ID: \`${status.probId}\``);
|
|
315
|
+
if (status.bindings?.contractRow) lines.push(`- Contract_Row: \`${status.bindings.contractRow}\``);
|
|
316
|
+
if (status.bindings?.planFile) lines.push(`- Plan_File: \`${status.bindings.planFile}\``);
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push("## Evidence (Created/Collected)");
|
|
319
|
+
if (created.length === 0) lines.push("- (none)");
|
|
320
|
+
else for (const item of created) lines.push(`- \`${item}\``);
|
|
321
|
+
lines.push("");
|
|
322
|
+
lines.push("## Quality Gate");
|
|
323
|
+
if (!validateRes) {
|
|
324
|
+
lines.push("- Strict validation: (skipped via --no-validate)");
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(`- Strict validation ok: \`${validateRes.ok === true}\``);
|
|
327
|
+
lines.push(`- Errors: \`${Array.isArray(validateRes.errors) ? validateRes.errors.length : 0}\``);
|
|
328
|
+
lines.push(`- Warnings: \`${Array.isArray(validateRes.warnings) ? validateRes.warnings.length : 0}\``);
|
|
329
|
+
}
|
|
330
|
+
lines.push("");
|
|
331
|
+
lines.push("## Next");
|
|
332
|
+
lines.push(`- ${planVerifyHint(changeId)}`);
|
|
333
|
+
if (validateFailed) lines.push("- Fix strict validation errors, then re-run `aiws change evidence`");
|
|
334
|
+
else lines.push(`- Verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
335
|
+
if (collaboration.counts.patches > 0) lines.push("- Review delegated patch drafts before apply/merge; do not treat `changes/<id>/patches/` as auto-applied changes");
|
|
336
|
+
if (collaboration.counts.analysis > 0 && collaboration.counts.review === 0) lines.push("- Summarize delegated analysis into `changes/<id>/review/` before delivery");
|
|
337
|
+
return lines.join("\n") + "\n";
|
|
338
|
+
}
|