vibe-coding-master 0.9.1 → 0.9.3
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 +11 -5
- package/dist/backend/adapters/command-runner.js +1 -0
- package/dist/backend/adapters/filesystem.js +5 -2
- package/dist/backend/api/artifact-routes.js +0 -3
- package/dist/backend/api/task-routes.js +0 -1
- package/dist/backend/cli/install-vcm-harness.js +9 -2
- package/dist/backend/role-tool-policy.js +10 -1
- package/dist/backend/server.js +2 -0
- package/dist/backend/services/artifact-service.js +1 -16
- package/dist/backend/services/docs-sync-check.js +32 -0
- package/dist/backend/services/gate-review-service.js +10 -2
- package/dist/backend/services/harness-service.js +3 -2
- package/dist/backend/services/lsp-plugin.js +24 -5
- package/dist/backend/services/managed-artifact-validation.js +3 -1
- package/dist/backend/services/session-service.js +2 -2
- package/dist/backend/services/status-service.js +0 -1
- package/dist/backend/services/translation-worker-service.js +44 -6
- package/dist/backend/services/workflow-control-service.js +103 -165
- package/dist/backend/templates/handoff.js +2 -48
- package/dist/backend/templates/harness/architect-agent.js +43 -24
- package/dist/backend/templates/harness/architect-evidence-worker-agent.js +6 -3
- package/dist/backend/templates/harness/claude-root.js +9 -9
- package/dist/backend/templates/harness/coder-agent.js +0 -1
- package/dist/backend/templates/harness/gate-review.js +83 -13
- package/dist/backend/templates/harness/project-manager-agent.js +33 -27
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +13 -14
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +2 -2
- package/dist/backend/templates/harness/vcm-workflow-review-skill.js +2 -2
- package/dist/shared/types/gate-review.js +4 -0
- package/dist/shared/types/workflow.js +0 -1
- package/dist/shared/validation/artifact-check.js +1 -27
- package/dist/shared/validation/artifact-contract.js +0 -2
- package/dist/shared/validation/artifact-registry.js +1 -18
- package/package.json +1 -1
- package/scripts/claude-plugins/vcm-lsp-bridge/vcm-lsp-proxy.mjs +273 -0
- package/scripts/harness-tools/check-docs-sync +246 -0
- package/scripts/harness-tools/check-durable-docs +24 -0
- package/scripts/harness-tools/run-long-check +3 -3
- package/scripts/verify-package.mjs +2 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stdio proxy between Claude Code's LSP client and one language server.
|
|
3
|
+
//
|
|
4
|
+
// Usage: node vcm-lsp-proxy.mjs -- <server-command> [server-args...]
|
|
5
|
+
//
|
|
6
|
+
// A language server that is still loading or indexing answers navigation
|
|
7
|
+
// queries with partial or empty results, which reads as "no references".
|
|
8
|
+
// The proxy tracks the server's work-done progress (and rust-analyzer's
|
|
9
|
+
// quiescence status), holds navigation queries until the server is idle, and
|
|
10
|
+
// fails a query with an explicit "still indexing" error when the server does
|
|
11
|
+
// not become idle in time. Every other message passes through unchanged.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const NAVIGATION_METHODS = new Set([
|
|
18
|
+
"textDocument/definition",
|
|
19
|
+
"textDocument/declaration",
|
|
20
|
+
"textDocument/typeDefinition",
|
|
21
|
+
"textDocument/implementation",
|
|
22
|
+
"textDocument/references",
|
|
23
|
+
"textDocument/hover",
|
|
24
|
+
"textDocument/documentSymbol",
|
|
25
|
+
"textDocument/prepareCallHierarchy",
|
|
26
|
+
"callHierarchy/incomingCalls",
|
|
27
|
+
"callHierarchy/outgoingCalls",
|
|
28
|
+
"workspace/symbol"
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const READY_TIMEOUT_MS = positiveNumber(process.env.VCM_LSP_READY_TIMEOUT_MS, 180_000);
|
|
32
|
+
// Servers announce indexing shortly after "initialized"; a query sent before
|
|
33
|
+
// any progress appears waits this long for indexing to start.
|
|
34
|
+
const STARTUP_GRACE_MS = positiveNumber(process.env.VCM_LSP_STARTUP_GRACE_MS, 2_000);
|
|
35
|
+
const REQUEST_FAILED = -32803;
|
|
36
|
+
const REQUEST_CANCELLED = -32800;
|
|
37
|
+
|
|
38
|
+
function positiveNumber(raw, fallback) {
|
|
39
|
+
const value = Number(raw);
|
|
40
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parses Content-Length framed JSON-RPC messages from a byte stream.
|
|
44
|
+
export function createMessageReader(onMessage) {
|
|
45
|
+
let buffer = Buffer.alloc(0);
|
|
46
|
+
return (chunk) => {
|
|
47
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
48
|
+
while (true) {
|
|
49
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
50
|
+
if (headerEnd < 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii");
|
|
54
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
55
|
+
if (!match) {
|
|
56
|
+
// Unframed bytes cannot be routed; drop the header and resynchronize.
|
|
57
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const length = Number(match[1]);
|
|
61
|
+
const bodyStart = headerEnd + 4;
|
|
62
|
+
if (buffer.length < bodyStart + length) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8");
|
|
66
|
+
buffer = buffer.subarray(bodyStart + length);
|
|
67
|
+
onMessage(JSON.parse(body));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function frameMessage(message) {
|
|
73
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
74
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Readiness state shared by both directions of the proxy.
|
|
78
|
+
export function createReadinessTracker(options = {}) {
|
|
79
|
+
const now = options.now ?? Date.now;
|
|
80
|
+
const activeProgress = new Map();
|
|
81
|
+
let quiescent = true;
|
|
82
|
+
let initializedAt;
|
|
83
|
+
let progressSeen = false;
|
|
84
|
+
const listeners = new Set();
|
|
85
|
+
|
|
86
|
+
function notify() {
|
|
87
|
+
for (const listener of [...listeners]) {
|
|
88
|
+
listener();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
markInitialized() {
|
|
94
|
+
initializedAt = now();
|
|
95
|
+
},
|
|
96
|
+
progressBegin(token, title) {
|
|
97
|
+
progressSeen = true;
|
|
98
|
+
activeProgress.set(String(token), title || "indexing");
|
|
99
|
+
notify();
|
|
100
|
+
},
|
|
101
|
+
progressEnd(token) {
|
|
102
|
+
activeProgress.delete(String(token));
|
|
103
|
+
notify();
|
|
104
|
+
},
|
|
105
|
+
serverStatus(isQuiescent) {
|
|
106
|
+
progressSeen = true;
|
|
107
|
+
quiescent = isQuiescent;
|
|
108
|
+
notify();
|
|
109
|
+
},
|
|
110
|
+
// Remaining startup grace in ms, or 0 once it no longer applies.
|
|
111
|
+
graceRemaining() {
|
|
112
|
+
if (progressSeen || initializedAt === undefined) {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
return Math.max(0, initializedAt + (options.startupGraceMs ?? STARTUP_GRACE_MS) - now());
|
|
116
|
+
},
|
|
117
|
+
isBusy() {
|
|
118
|
+
return activeProgress.size > 0 || !quiescent;
|
|
119
|
+
},
|
|
120
|
+
busyReason() {
|
|
121
|
+
const titles = [...new Set(activeProgress.values())];
|
|
122
|
+
if (!quiescent) {
|
|
123
|
+
titles.push("server not quiescent");
|
|
124
|
+
}
|
|
125
|
+
return titles.join(", ") || "starting up";
|
|
126
|
+
},
|
|
127
|
+
onChange(listener) {
|
|
128
|
+
listeners.add(listener);
|
|
129
|
+
return () => listeners.delete(listener);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function startProxy({ command, args, input, output, errorOutput, readyTimeoutMs = READY_TIMEOUT_MS, startupGraceMs = STARTUP_GRACE_MS }) {
|
|
135
|
+
const server = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
136
|
+
const readiness = createReadinessTracker({ startupGraceMs });
|
|
137
|
+
const pending = new Map();
|
|
138
|
+
let clientWantsProgress = false;
|
|
139
|
+
let clientWantsServerStatus = false;
|
|
140
|
+
|
|
141
|
+
const toClient = (message) => output.write(frameMessage(message));
|
|
142
|
+
const toServer = (message) => {
|
|
143
|
+
if (server.stdin.writable) {
|
|
144
|
+
server.stdin.write(frameMessage(message));
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
server.stderr.pipe(errorOutput);
|
|
149
|
+
server.on("error", (error) => {
|
|
150
|
+
errorOutput.write(`vcm-lsp-proxy: cannot start ${command}: ${error.message}\n`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
output.end?.();
|
|
153
|
+
});
|
|
154
|
+
server.on("exit", (code, signal) => {
|
|
155
|
+
for (const entry of pending.values()) {
|
|
156
|
+
entry.dispose();
|
|
157
|
+
}
|
|
158
|
+
process.exitCode = code ?? (signal ? 1 : 0);
|
|
159
|
+
output.end?.();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
function releaseWhenReady(message) {
|
|
163
|
+
const key = String(message.id);
|
|
164
|
+
let timer;
|
|
165
|
+
let graceTimer;
|
|
166
|
+
let unsubscribe = () => undefined;
|
|
167
|
+
const dispose = () => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
clearTimeout(graceTimer);
|
|
170
|
+
unsubscribe();
|
|
171
|
+
pending.delete(key);
|
|
172
|
+
};
|
|
173
|
+
const attempt = () => {
|
|
174
|
+
const grace = readiness.graceRemaining();
|
|
175
|
+
if (grace > 0) {
|
|
176
|
+
clearTimeout(graceTimer);
|
|
177
|
+
graceTimer = setTimeout(attempt, grace);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!readiness.isBusy()) {
|
|
181
|
+
dispose();
|
|
182
|
+
toServer(message);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
pending.set(key, {
|
|
186
|
+
dispose,
|
|
187
|
+
cancel() {
|
|
188
|
+
dispose();
|
|
189
|
+
toClient({ jsonrpc: "2.0", id: message.id, error: { code: REQUEST_CANCELLED, message: "Request cancelled." } });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
unsubscribe = readiness.onChange(attempt);
|
|
193
|
+
timer = setTimeout(() => {
|
|
194
|
+
dispose();
|
|
195
|
+
toClient({
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id: message.id,
|
|
198
|
+
error: {
|
|
199
|
+
code: REQUEST_FAILED,
|
|
200
|
+
message: `Language server is still indexing (${readiness.busyReason()}) after ${Math.round(readyTimeoutMs / 1000)}s; ${message.method} results would be incomplete. Retry after indexing finishes, or record the relationship as unresolved.`
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}, readyTimeoutMs);
|
|
204
|
+
attempt();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
input.on("data", createMessageReader((message) => {
|
|
208
|
+
if (message.method === "initialize" && message.params) {
|
|
209
|
+
const capabilities = message.params.capabilities ?? {};
|
|
210
|
+
clientWantsProgress = capabilities.window?.workDoneProgress === true;
|
|
211
|
+
clientWantsServerStatus = capabilities.experimental?.serverStatusNotification === true;
|
|
212
|
+
message.params.capabilities = {
|
|
213
|
+
...capabilities,
|
|
214
|
+
window: { ...capabilities.window, workDoneProgress: true },
|
|
215
|
+
experimental: { ...capabilities.experimental, serverStatusNotification: true }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (message.method === "initialized") {
|
|
219
|
+
readiness.markInitialized();
|
|
220
|
+
}
|
|
221
|
+
if (message.method === "$/cancelRequest" && pending.has(String(message.params?.id))) {
|
|
222
|
+
pending.get(String(message.params.id)).cancel();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (message.id !== undefined && NAVIGATION_METHODS.has(message.method)) {
|
|
226
|
+
releaseWhenReady(message);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
toServer(message);
|
|
230
|
+
}));
|
|
231
|
+
input.on("end", () => server.stdin.end());
|
|
232
|
+
|
|
233
|
+
server.stdout.on("data", createMessageReader((message) => {
|
|
234
|
+
if (message.method === "window/workDoneProgress/create") {
|
|
235
|
+
if (!clientWantsProgress) {
|
|
236
|
+
toServer({ jsonrpc: "2.0", id: message.id, result: null });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
} else if (message.method === "$/progress") {
|
|
240
|
+
const value = message.params?.value;
|
|
241
|
+
if (value?.kind === "begin") {
|
|
242
|
+
readiness.progressBegin(message.params.token, value.title);
|
|
243
|
+
} else if (value?.kind === "end") {
|
|
244
|
+
readiness.progressEnd(message.params.token);
|
|
245
|
+
}
|
|
246
|
+
if (!clientWantsProgress) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
} else if (message.method === "experimental/serverStatus") {
|
|
250
|
+
readiness.serverStatus(message.params?.quiescent !== false);
|
|
251
|
+
if (!clientWantsServerStatus) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
toClient(message);
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
return { server, readiness };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const separator = process.argv.indexOf("--");
|
|
262
|
+
if (separator >= 0 && process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
263
|
+
const [command, ...args] = process.argv.slice(separator + 1);
|
|
264
|
+
if (!command) {
|
|
265
|
+
process.stderr.write("usage: vcm-lsp-proxy.mjs -- <server-command> [args...]\n");
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
const { server } = startProxy({ command, args, input: process.stdin, output: process.stdout, errorOutput: process.stderr });
|
|
269
|
+
// Exit with the server so Claude Code sees the crash and restarts it; an open
|
|
270
|
+
// stdin would otherwise keep the proxy alive with no server behind it.
|
|
271
|
+
server.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
272
|
+
server.on("error", () => process.exit(1));
|
|
273
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Check that the architecture plan's Docs Impact covers every module the task
|
|
4
|
+
// touched. Without .ai/generated/module-index.json the repository is one module
|
|
5
|
+
// named "." whose owning doc is docs/ARCHITECTURE.md (or its docs/architecture/
|
|
6
|
+
// area docs).
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// .ai/tools/check-docs-sync [--base <commit>] [--json]
|
|
10
|
+
//
|
|
11
|
+
// The check compares the task's commits (base..HEAD, excluding [VCM Harness]
|
|
12
|
+
// commits) with .ai/generated/module-index.json and the Docs Impact table of
|
|
13
|
+
// .ai/vcm/handoffs/architecture-plan.md:
|
|
14
|
+
// - every module touched by a code change had its owning architecture doc
|
|
15
|
+
// changed in the task or has a Docs Impact row for that doc;
|
|
16
|
+
// - a Docs Impact row that records no change gives its reason;
|
|
17
|
+
// - every document Docs Impact records as changed was changed.
|
|
18
|
+
// A task without a complete architecture plan (an Architect Debug Flow or an
|
|
19
|
+
// Architecture Diagnosis Flow) is skipped.
|
|
20
|
+
// The base defaults to the merge-base of HEAD with the base repository's HEAD.
|
|
21
|
+
|
|
22
|
+
const childProcess = require("node:child_process");
|
|
23
|
+
const fs = require("node:fs");
|
|
24
|
+
const path = require("node:path");
|
|
25
|
+
|
|
26
|
+
const HARNESS_COMMIT_PREFIX = "[VCM Harness] ";
|
|
27
|
+
const PLAN_PATH = ".ai/vcm/handoffs/architecture-plan.md";
|
|
28
|
+
const MODULE_INDEX_PATH = ".ai/generated/module-index.json";
|
|
29
|
+
const PROJECT_DOC = "docs/ARCHITECTURE.md";
|
|
30
|
+
const AREA_DOC_DIR = "docs/architecture/";
|
|
31
|
+
const USAGE = "usage: check-docs-sync [-h] [--base BASE] [--json]";
|
|
32
|
+
|
|
33
|
+
function usageError(message) {
|
|
34
|
+
process.stderr.write(USAGE + "\ncheck-docs-sync: error: " + message + "\n");
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const args = { base: undefined, json: false };
|
|
40
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
41
|
+
const argument = argv[index];
|
|
42
|
+
if (argument === "-h" || argument === "--help") {
|
|
43
|
+
process.stdout.write(USAGE + "\n\nCheck that the architecture plan's Docs Impact covers every touched module.\n");
|
|
44
|
+
process.exit(0);
|
|
45
|
+
} else if (argument === "--json") {
|
|
46
|
+
args.json = true;
|
|
47
|
+
} else if (argument === "--base") {
|
|
48
|
+
if (index + 1 >= argv.length) {
|
|
49
|
+
usageError("argument " + argument + ": expected one argument");
|
|
50
|
+
}
|
|
51
|
+
index += 1;
|
|
52
|
+
args.base = argv[index];
|
|
53
|
+
} else {
|
|
54
|
+
usageError("unrecognized arguments: " + argument);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function git(root, args) {
|
|
61
|
+
const result = childProcess.spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
62
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function repositoryRoot() {
|
|
66
|
+
return git(process.cwd(), ["rev-parse", "--show-toplevel"]) || process.cwd();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The task branch forks from the base repository's checked-out branch, which
|
|
70
|
+
// is the main worktree sharing this repository's git directory.
|
|
71
|
+
function resolveBase(root, explicit) {
|
|
72
|
+
if (explicit) {
|
|
73
|
+
return git(root, ["rev-parse", "--verify", explicit + "^{commit}"]) || undefined;
|
|
74
|
+
}
|
|
75
|
+
const fromEnv = (process.env.VCM_BASE_COMMIT || "").trim();
|
|
76
|
+
if (fromEnv && git(root, ["rev-parse", "--verify", fromEnv + "^{commit}"])) {
|
|
77
|
+
return fromEnv;
|
|
78
|
+
}
|
|
79
|
+
const commonDir = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
80
|
+
const baseHead = commonDir ? git(root, ["--git-dir", commonDir, "rev-parse", "HEAD"]) : "";
|
|
81
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
82
|
+
if (baseHead && baseHead !== head) {
|
|
83
|
+
return git(root, ["merge-base", "HEAD", baseHead]) || undefined;
|
|
84
|
+
}
|
|
85
|
+
const upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
|
86
|
+
return upstream ? git(root, ["merge-base", "HEAD", upstream]) || undefined : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function taskCommits(root, base) {
|
|
90
|
+
const lines = git(root, ["log", "--format=%H%x09%s", base + "..HEAD"]).split("\n").filter(Boolean);
|
|
91
|
+
return lines
|
|
92
|
+
.map((line) => {
|
|
93
|
+
const separator = line.indexOf("\t");
|
|
94
|
+
return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
|
|
95
|
+
})
|
|
96
|
+
.filter((commit) => !commit.subject.startsWith(HARNESS_COMMIT_PREFIX))
|
|
97
|
+
.map((commit) => ({
|
|
98
|
+
...commit,
|
|
99
|
+
files: git(root, ["show", "--format=", "--name-only", "--no-renames", commit.sha]).split("\n").filter(Boolean)
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isArchitectureDoc(file) {
|
|
104
|
+
return file === PROJECT_DOC || file.startsWith(AREA_DOC_DIR) || path.posix.basename(file) === "ARCHITECTURE.md";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Files that describe or derive from code rather than change it.
|
|
108
|
+
function isDocumentationFile(file) {
|
|
109
|
+
return isArchitectureDoc(file) || file.startsWith("docs/") || file.startsWith(".ai/") || /\.md$/i.test(file);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The repository as one module, for projects without a module index (the
|
|
113
|
+
// generator supports Cargo and npm workspaces only).
|
|
114
|
+
const ROOT_MODULE = { name: ".", path: ".", doc: PROJECT_DOC };
|
|
115
|
+
|
|
116
|
+
function loadModules(root) {
|
|
117
|
+
const target = path.join(root, MODULE_INDEX_PATH);
|
|
118
|
+
if (!fs.existsSync(target)) {
|
|
119
|
+
return [ROOT_MODULE];
|
|
120
|
+
}
|
|
121
|
+
const index = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
122
|
+
return (index.layers || []).flatMap((layer) => layer.modules || []).map((module) => ({
|
|
123
|
+
name: module.name,
|
|
124
|
+
path: module.path || ".",
|
|
125
|
+
doc: module.path && module.path !== "." ? module.architectureDoc || module.path + "/ARCHITECTURE.md" : PROJECT_DOC
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function owningModule(modules, file) {
|
|
130
|
+
let best;
|
|
131
|
+
for (const module of modules) {
|
|
132
|
+
const prefix = module.path === "." ? "" : module.path + "/";
|
|
133
|
+
if ((prefix === "" || file.startsWith(prefix)) && (!best || prefix.length > best.prefix.length)) {
|
|
134
|
+
best = { module, prefix };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return best && best.module;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sectionContent(content, heading) {
|
|
141
|
+
const match = new RegExp("^#{1,6}\\s+" + heading.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&") + "\\s*$", "im").exec(content);
|
|
142
|
+
if (!match) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const after = content.slice(match.index + match[0].length);
|
|
146
|
+
const next = /\n#{1,6}\s+\S/.exec(after);
|
|
147
|
+
return (next ? after.slice(0, next.index) : after).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tableRows(section) {
|
|
151
|
+
const lines = (section || "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("|"));
|
|
152
|
+
return lines.slice(2).map((line) => line.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim().replace(/^`|`$/g, "")));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function finding(code, subject, message) {
|
|
156
|
+
return { code, subject, message };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function planIsComplete(content) {
|
|
160
|
+
return /^Planning Result:\s*complete\s*$/im.test(content);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A module's doc is the project doc or one of its area docs for the root module.
|
|
164
|
+
function documentsModule(module, document) {
|
|
165
|
+
return module.doc === PROJECT_DOC
|
|
166
|
+
? document === PROJECT_DOC || document.startsWith(AREA_DOC_DIR)
|
|
167
|
+
: document === module.doc;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function check(root, planContent, base) {
|
|
171
|
+
const findings = [];
|
|
172
|
+
const modules = loadModules(root);
|
|
173
|
+
const commits = taskCommits(root, base);
|
|
174
|
+
const changed = new Set(commits.flatMap((commit) => commit.files));
|
|
175
|
+
|
|
176
|
+
const touched = new Map();
|
|
177
|
+
for (const file of changed) {
|
|
178
|
+
if (isDocumentationFile(file)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const module = owningModule(modules, file);
|
|
182
|
+
if (module) {
|
|
183
|
+
touched.set(module.path, module);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const rows = tableRows(sectionContent(planContent, "Docs Impact")).map(([documentCell, change, reason]) => ({
|
|
188
|
+
document: (documentCell || "").split(/\s+/)[0],
|
|
189
|
+
change: change || "",
|
|
190
|
+
reason: reason || ""
|
|
191
|
+
}));
|
|
192
|
+
const docChanged = (module) => [...changed].some((file) => documentsModule(module, file));
|
|
193
|
+
|
|
194
|
+
for (const module of touched.values()) {
|
|
195
|
+
if (!docChanged(module) && !rows.some((row) => documentsModule(module, row.document))) {
|
|
196
|
+
findings.push(finding("DS_MODULE_NOT_COVERED", module.path,
|
|
197
|
+
"Module " + module.name + " (" + module.path + ") changed in the task, but neither its owning doc " + module.doc
|
|
198
|
+
+ " changed nor does Docs Impact list it with the reason no owned fact changed."));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
if (!/\.md$/i.test(row.document)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const unchanged = !row.change || /^(none|unchanged|no change)\b/i.test(row.change);
|
|
207
|
+
if (unchanged && !/\w/.test(row.reason.replace(/^(none|tbd)\.?$/i, ""))) {
|
|
208
|
+
findings.push(finding("DS_UNCHANGED_WITHOUT_REASON", row.document,
|
|
209
|
+
"Docs Impact records no change to " + row.document + " without the reason no owned fact changed."));
|
|
210
|
+
} else if (!unchanged && !changed.has(row.document)) {
|
|
211
|
+
findings.push(finding("DS_PLANNED_DOC_NOT_CHANGED", row.document,
|
|
212
|
+
"Docs Impact records a change to " + row.document + ", but the task did not change it."));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return findings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function main() {
|
|
219
|
+
const args = parseArgs(process.argv.slice(2));
|
|
220
|
+
const root = repositoryRoot();
|
|
221
|
+
const planPath = path.join(root, PLAN_PATH);
|
|
222
|
+
const plan = fs.existsSync(planPath) ? fs.readFileSync(planPath, "utf8") : "";
|
|
223
|
+
if (!planIsComplete(plan)) {
|
|
224
|
+
const reason = "No complete " + PLAN_PATH + "; the task has no Docs Impact to check.";
|
|
225
|
+
process.stdout.write(args.json ? JSON.stringify({ skipped: reason, findings: [] }, null, 2) + "\n" : reason + "\n");
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
const base = resolveBase(root, args.base);
|
|
229
|
+
if (!base) {
|
|
230
|
+
process.stderr.write("check-docs-sync: cannot determine the task base commit; pass --base <commit>.\n");
|
|
231
|
+
return 2;
|
|
232
|
+
}
|
|
233
|
+
const findings = check(root, plan, base);
|
|
234
|
+
if (args.json) {
|
|
235
|
+
process.stdout.write(JSON.stringify({ base, findings }, null, 2) + "\n");
|
|
236
|
+
} else if (findings.length === 0) {
|
|
237
|
+
process.stdout.write("Docs sync check passed.\n");
|
|
238
|
+
} else {
|
|
239
|
+
for (const item of findings) {
|
|
240
|
+
process.stdout.write(item.code + " " + item.subject + ": " + item.message + "\n");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return findings.length === 0 ? 0 : 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
process.exitCode = main();
|
|
@@ -303,6 +303,29 @@ function checkGeneratedArchitectureFacts(root, findings) {
|
|
|
303
303
|
});
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// Area docs under docs/architecture/ split the project doc's detailed design,
|
|
307
|
+
// so docs/ARCHITECTURE.md must link each one.
|
|
308
|
+
function checkAreaDocLinks(root, findings) {
|
|
309
|
+
const areaDir = path.join(root, "docs/architecture");
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
entries = fs.readdirSync(areaDir, { withFileTypes: true });
|
|
313
|
+
} catch {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const projectDoc = path.join(root, "docs/ARCHITECTURE.md");
|
|
317
|
+
const projectText = isFile(projectDoc) ? fs.readFileSync(projectDoc, "utf8") : "";
|
|
318
|
+
for (const entry of entries.sort((left, right) => (left.name < right.name ? -1 : 1))) {
|
|
319
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!projectText.includes("architecture/" + entry.name)) {
|
|
323
|
+
findings.push(finding("DD_AREA_DOC_UNLINKED", "docs/architecture/" + entry.name, 1,
|
|
324
|
+
"Area architecture doc is not linked from docs/ARCHITECTURE.md."));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
306
329
|
function main(argv) {
|
|
307
330
|
const args = parseArgs(argv);
|
|
308
331
|
const root = repositoryRoot();
|
|
@@ -312,6 +335,7 @@ function main(argv) {
|
|
|
312
335
|
checkArchitectureHistory(root, findings);
|
|
313
336
|
checkTestingHistory(root, findings);
|
|
314
337
|
checkGeneratedArchitectureFacts(root, findings);
|
|
338
|
+
checkAreaDocLinks(root, findings);
|
|
315
339
|
findings.sort((left, right) =>
|
|
316
340
|
left.path < right.path ? -1 : left.path > right.path ? 1
|
|
317
341
|
: left.line !== right.line ? left.line - right.line
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// wrappers are rejected because they can hide the validation command's exit code.
|
|
10
10
|
//
|
|
11
11
|
// The command runs in a detached worker process group. The worker itself
|
|
12
|
-
// enforces the job ceiling (--timeout, max
|
|
12
|
+
// enforces the job ceiling (--timeout, max 100m) and a supervision lease: when
|
|
13
13
|
// no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
|
|
14
14
|
// the command process group and records the job as orphaned. Watch the job
|
|
15
15
|
// with .ai/tools/watch-job in the same turn.
|
|
@@ -22,7 +22,7 @@ const fs = require("node:fs");
|
|
|
22
22
|
const os = require("node:os");
|
|
23
23
|
const path = require("node:path");
|
|
24
24
|
|
|
25
|
-
const MAX_TIMEOUT_SECONDS =
|
|
25
|
+
const MAX_TIMEOUT_SECONDS = 100 * 60;
|
|
26
26
|
const DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS;
|
|
27
27
|
const QUEUED_STALE_SECONDS = 60;
|
|
28
28
|
const ACTIVE_STATUSES = new Set(["queued", "starting", "running"]);
|
|
@@ -584,7 +584,7 @@ async function main(argv) {
|
|
|
584
584
|
return 2;
|
|
585
585
|
}
|
|
586
586
|
if (timeoutSeconds > MAX_TIMEOUT_SECONDS) {
|
|
587
|
-
process.stderr.write("error: timeout exceeds maximum
|
|
587
|
+
process.stderr.write("error: timeout exceeds maximum 100m; split the work or get user approval\n");
|
|
588
588
|
return 2;
|
|
589
589
|
}
|
|
590
590
|
|
|
@@ -7,7 +7,9 @@ const requiredFiles = [
|
|
|
7
7
|
"package.json",
|
|
8
8
|
"scripts/fix-node-pty-spawn-helper.mjs",
|
|
9
9
|
"scripts/claude-plugins/vcm-lsp-bridge/.claude-plugin/plugin.json",
|
|
10
|
+
"scripts/claude-plugins/vcm-lsp-bridge/vcm-lsp-proxy.mjs",
|
|
10
11
|
"scripts/harness-tools/check-durable-docs",
|
|
12
|
+
"scripts/harness-tools/check-docs-sync",
|
|
11
13
|
"scripts/harness-tools/generate-module-index",
|
|
12
14
|
"scripts/harness-tools/generate-public-surface",
|
|
13
15
|
"scripts/harness-tools/run-long-check",
|