@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi/sessions/index.js +6 -0
  4. package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
  5. package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
  6. package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
  7. package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
  8. package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
  9. package/dist/executors/dag-pi-executor.js +12 -2075
  10. package/dist/executors/shell-executor.js +39 -11
  11. package/dist/worker/console/routes.js +5 -0
  12. package/dist/worker/console/static/app-icon.svg +39 -0
  13. package/dist/worker/console/static/index.html +3 -0
  14. package/dist/worker/console/static/manifest.webmanifest +19 -0
  15. package/dist/worker/observe/static/console-theme.js +38 -0
  16. package/dist/workflows/dag/checkpoint.js +686 -0
  17. package/dist/workflows/dag/frontend-repair.js +24 -0
  18. package/dist/workflows/dag/frontend-review-scopes.js +10 -2
  19. package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
  20. package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
  21. package/dist/workflows/dag/hybrid/sources.js +1812 -0
  22. package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
  23. package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
  24. package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
  25. package/dist/workflows/dag/hybrid/templates/index.js +8 -0
  26. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
  27. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
  28. package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
  29. package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
  30. package/dist/workflows/dag/hybrid/types.js +32 -0
  31. package/dist/workflows/dag/init-hybrid.js +42 -7162
  32. package/dist/workflows/dag/runner.js +11 -1178
  33. package/dist/workflows/dag/terminal-status.js +502 -0
  34. package/docs/architecture/dag-execution.md +4 -4
  35. package/package.json +1 -1
@@ -0,0 +1,1812 @@
1
+ /** DAG hybrid 输入准备:来源装载、Mock discovery、verification 规划与 source binding。禁止 import 模板分发入口。 */
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { access, readFile, readdir, realpath } from "node:fs/promises";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION } from "../types.js";
7
+ import { planMavenVerification } from "../../../verification/maven/index.js";
8
+ import { pathMatchesPattern } from "../../../shared/git-progress.js";
9
+ import { BASELINE_FORBIDDEN_PATHS } from "../governance-constants.js";
10
+ import { resolveAdapter } from "../../../adapters/index.js";
11
+ import { loadHarnessManifest } from "../../../governance/harness.js";
12
+ import { mergeDocumentIndexCompanions } from "../../../governance/document-index-closure.js";
13
+ import { probeRepoLocalSddSkills } from "../sdd-embedded.js";
14
+ import { discoverProjectGovernancePresence } from "../project-governance-context.js";
15
+ import { CANONICAL_TASK_ID_PATTERN, getTaskPaths, loadTaskConfig } from "../../../task/runtime.js";
16
+ import { materializeTaskReferenceDocs } from "../../../task/source-references.js";
17
+ import { extractRequirementFactsFromMarkdown } from "../../../task/source-prepare/parse-intent.js";
18
+ import { computeLedgerInputDigest, parseLedgerJson, recoverLedgerInputContract, validateRequirementLedger } from "../../../task/source-prepare/ledger.js";
19
+ import { REQUIREMENT_LEDGER_FILE_NAME } from "../../../task/contract/constants.js";
20
+ import { resolveVerifyPreset } from "../../../executors/shell-verification.js";
21
+ import { resolveExecutorModelMatrices } from "../../../executors/model-routing.js";
22
+ import { normalizeTaskRequirementText } from "../task-demand-routing.js";
23
+ import { classifyFrontendRisk } from "../frontend-risk.js";
24
+ import { discoverFrontendProjectCapability } from "../frontend-project-capability.js";
25
+ import { classifyFrontendVerifyCommandText, isRecognizedFrontendVerifyCommandText } from "../frontend-implementation-contract.js";
26
+ import { computeFrontendShapeSourceDigest } from "../frontend-shape.js";
27
+ import { discoverLatestCommittedFrontendShapeCapsule } from "../frontend-shape-capsule-store.js";
28
+ import { serializeDagTaskSourcePath } from "../../../task/dag-source-paths.js";
29
+ import { resolveEnabledExecutors, } from "./types.js";
30
+ export const HYBRID_DEFAULTS = {
31
+ executor: "pi",
32
+ piBackend: "sdk-first",
33
+ contextProfile: "slim",
34
+ skills: ["ai-engineering-context"],
35
+ writePolicy: "read-only",
36
+ };
37
+ export const HYBRID_SKILLS_BY_ROLE = {
38
+ planner: ["loop-agent"],
39
+ scout: ["ai-engineering-context", "codebase-scout"],
40
+ implementer: ["test-driven-development", "verification-before-completion"],
41
+ reviewer: ["requesting-code-review", "code-review-core"],
42
+ verifier: ["verification-before-completion", "systematic-debugging"],
43
+ closeout: ["loop-agent", "verification-before-completion"],
44
+ };
45
+ export const FRONTEND_DEFAULTS = {
46
+ ...HYBRID_DEFAULTS,
47
+ skills: [],
48
+ };
49
+ export const FRONTEND_SKILLS_BY_ROLE = {
50
+ planner: [],
51
+ scout: [],
52
+ implementer: [],
53
+ reviewer: [],
54
+ supervisor: [],
55
+ verifier: [],
56
+ closeout: [],
57
+ };
58
+ export const FRONTEND_CONTRACT_SKILLS = ["frontend-contract"];
59
+ export const FRONTEND_SCOUT_SKILLS = ["frontend-scout"];
60
+ export const FRONTEND_PLAN_SKILLS = ["frontend-plan"];
61
+ export const FRONTEND_BOUNDED_IMPLEMENT_SKILLS = ["frontend-bounded-implement"];
62
+ export const FRONTEND_DESIGN_REVIEW_SKILLS = ["frontend-design-review"];
63
+ export const FRONTEND_REVIEW_SKILLS = ["frontend-review"];
64
+ export const FRONTEND_VERIFICATION_SKILLS = ["frontend-verification"];
65
+ export const IMPLEMENT_WRITESET_PLACEHOLDER = "REPLACE/WITH/NARROW/IMPLEMENT/PATHS/**";
66
+ export const GENERATED_DAG_RUNTIME_CONTRACT = {
67
+ schemaVersion: DAG_RUNTIME_CONTRACT_SCHEMA_VERSION,
68
+ agentRuntime: DAG_AGENT_RUNTIME_PI_ONLY,
69
+ repairWriterProtocol: DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
70
+ };
71
+ export const STANDARD_GLOBAL_CONSTRAINTS = [
72
+ "Do not commit runtime traces under .harness/dag-runs/",
73
+ "Do not enable cross-node Pi runtime reuse by default",
74
+ "Preserve CODE_AGENT_PI_BACKEND=cli-only rollback for Pi nodes",
75
+ "Same-rank exclusive writeSet entries must be disjoint",
76
+ "Every task must explicitly declare executor; defaults.executor is schema-only and not a runtime fallback",
77
+ "Prefer same-rank parallel read-only scouts over serial chains when outputs are independent",
78
+ "If the DAG is a single linear chain, challenge whether read-only work can run in parallel ranks",
79
+ "Read-only DAG nodes must not write repository files, including root artifacts/**; return findings in node output only",
80
+ "Root artifacts/ is reserved for explicit exclusive write nodes, not read-only scout/reviewer output",
81
+ "exclusive implementer nodes must use narrow, concrete writeSet paths; never keep ** or repo root",
82
+ `Replace ${IMPLEMENT_WRITESET_PLACEHOLDER} with concrete paths before executing the implementation writer`,
83
+ ];
84
+ export async function packageJsonHasMockScript(repoRoot) {
85
+ try {
86
+ const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
87
+ const pkg = JSON.parse(raw);
88
+ const scripts = pkg.scripts ?? {};
89
+ const mockScripts = Object.keys(scripts).filter((name) => name === "mock" ||
90
+ name.startsWith("mock:") ||
91
+ name.startsWith("dev:mock") ||
92
+ /mock/i.test(name));
93
+ const verifyCommands = mockScripts
94
+ .filter((name) => /(?:test|check|verify|contract)/i.test(name))
95
+ .map((name) => ({
96
+ label: `npm run ${name}`,
97
+ args: ["npm", "run", name],
98
+ cwd: repoRoot,
99
+ }));
100
+ return {
101
+ hasScript: mockScripts.length > 0,
102
+ scriptNames: mockScripts,
103
+ verifyCommands,
104
+ };
105
+ }
106
+ catch {
107
+ return { hasScript: false, scriptNames: [], verifyCommands: [] };
108
+ }
109
+ }
110
+ export async function hasDirectDependency(repoRoot, depName) {
111
+ try {
112
+ const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
113
+ const pkg = JSON.parse(raw);
114
+ const deps = {
115
+ ...(pkg.dependencies ?? {}),
116
+ ...(pkg.devDependencies ?? {}),
117
+ };
118
+ return depName in deps;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ export async function collectDeclaredDependencies(repoRoot) {
125
+ try {
126
+ const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
127
+ const pkg = JSON.parse(raw);
128
+ return [
129
+ ...Object.keys(pkg.dependencies ?? {}),
130
+ ...Object.keys(pkg.devDependencies ?? {}),
131
+ ];
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ }
137
+ export async function discoverMockHandlerFiles(repoRoot, serviceRoot) {
138
+ const exactCandidates = [
139
+ ...[
140
+ "src/mocks/handlers.ts",
141
+ "src/mocks/handlers.js",
142
+ "src/mocks/browser.ts",
143
+ "src/mocks/browser.js",
144
+ "src/mocks/server.ts",
145
+ "src/mocks/server.js",
146
+ "mocks/handlers.ts",
147
+ "mocks/handlers.js",
148
+ "mocks/browser.ts",
149
+ "mocks/browser.js",
150
+ ].map((candidatePath) => ({ framework: "msw", path: candidatePath })),
151
+ ...["db.json", "mock/db.json", "src/mock/db.json"].map((candidatePath) => ({
152
+ framework: "json-server",
153
+ path: candidatePath,
154
+ })),
155
+ ];
156
+ const directoryCandidates = [
157
+ ...(serviceRoot ? [{ path: serviceRoot }] : []),
158
+ { framework: "mockjs", path: "src/mock" },
159
+ { framework: "mockjs", path: "mock" },
160
+ { framework: "msw", path: "src/mocks" },
161
+ { framework: "msw", path: "mocks" },
162
+ { framework: "mirage", path: "src/mirage" },
163
+ { framework: "mirage", path: "mirage" },
164
+ ];
165
+ const foundPaths = new Set();
166
+ let foundFramework;
167
+ for (const candidate of exactCandidates) {
168
+ try {
169
+ await access(path.join(repoRoot, candidate.path));
170
+ foundPaths.add(candidate.path);
171
+ foundFramework ??= candidate.framework;
172
+ }
173
+ catch {
174
+ // Exact candidate does not exist.
175
+ }
176
+ }
177
+ async function collectFiles(directory, relativeDirectory) {
178
+ let entries;
179
+ try {
180
+ entries = await readdir(directory, { withFileTypes: true });
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ for (const entry of entries) {
186
+ if (foundPaths.size >= 24)
187
+ return;
188
+ const absoluteEntry = path.join(directory, entry.name);
189
+ const relativeEntry = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
190
+ if (entry.isDirectory()) {
191
+ await collectFiles(absoluteEntry, relativeEntry);
192
+ }
193
+ else if (entry.isFile()) {
194
+ foundPaths.add(relativeEntry);
195
+ }
196
+ }
197
+ }
198
+ for (const candidate of directoryCandidates) {
199
+ const before = foundPaths.size;
200
+ await collectFiles(path.join(repoRoot, candidate.path), candidate.path);
201
+ if (foundPaths.size > before && candidate.framework) {
202
+ foundFramework ??= candidate.framework;
203
+ }
204
+ }
205
+ return { framework: foundFramework, paths: [...foundPaths].sort() };
206
+ }
207
+ export async function discoverMockBootstrapImports(repoRoot) {
208
+ const entryCandidates = [
209
+ "src/main.ts",
210
+ "src/main.tsx",
211
+ "src/main.js",
212
+ "src/main.jsx",
213
+ "src/index.ts",
214
+ "src/index.tsx",
215
+ "src/index.js",
216
+ "src/index.jsx",
217
+ "src/setupTests.ts",
218
+ "src/setupTests.js",
219
+ "test/setup.ts",
220
+ "test/setup.js",
221
+ ];
222
+ const imports = [];
223
+ for (const candidate of entryCandidates) {
224
+ try {
225
+ const content = await readFile(path.join(repoRoot, candidate), "utf-8");
226
+ if (/(?:from\s*|import\s*)["'][^"']*(?:mock|msw|mirage)[^"']*["']/i.test(content)) {
227
+ imports.push(candidate);
228
+ }
229
+ }
230
+ catch {
231
+ // Candidate entry does not exist or is unreadable.
232
+ }
233
+ }
234
+ return imports;
235
+ }
236
+ export async function discoverFrontendMockCapability(repoRoot, taskConfig) {
237
+ const safetyViolation = await frontendMockServiceRootSafetyViolation(repoRoot, taskConfig);
238
+ if (safetyViolation) {
239
+ return {
240
+ status: "ambiguous",
241
+ serviceRoot: taskConfig.frontendMock?.serviceRoot,
242
+ safetyViolation,
243
+ evidencePaths: [],
244
+ verifyCommands: [],
245
+ reasons: [safetyViolation],
246
+ };
247
+ }
248
+ const evidencePaths = [];
249
+ const reasons = [];
250
+ let framework;
251
+ let serviceRoot;
252
+ let strongEvidenceCount = 0;
253
+ let ambiguousSignals = 0;
254
+ // 1. Check package.json mock scripts
255
+ const scriptResult = await packageJsonHasMockScript(repoRoot);
256
+ if (scriptResult.hasScript) {
257
+ reasons.push(`package.json has mock scripts: ${scriptResult.scriptNames.join(", ")}`);
258
+ evidencePaths.push("package.json");
259
+ // A script alone is not strong evidence unless we also find config/entry
260
+ }
261
+ // 2. Check for direct mock framework dependencies
262
+ const mockDeps = [
263
+ "msw",
264
+ "mockjs",
265
+ "miragejs",
266
+ "json-server",
267
+ "vite-plugin-mock",
268
+ ];
269
+ const foundDeps = [];
270
+ for (const dep of mockDeps) {
271
+ if (await hasDirectDependency(repoRoot, dep)) {
272
+ foundDeps.push(dep);
273
+ evidencePaths.push(`package.json (${dep})`);
274
+ }
275
+ }
276
+ // 3. Check for handler/fixture/bootstrap files
277
+ const serviceRootHint = taskConfig.frontendMock?.serviceRoot;
278
+ const handlerResult = await discoverMockHandlerFiles(repoRoot, serviceRootHint);
279
+ const bootstrapImports = await discoverMockBootstrapImports(repoRoot);
280
+ if (handlerResult.framework) {
281
+ framework = handlerResult.framework;
282
+ }
283
+ if (handlerResult.paths.length > 0) {
284
+ evidencePaths.push(...handlerResult.paths);
285
+ reasons.push(`Mock handler/fixture paths found: ${handlerResult.paths.join(", ")}`);
286
+ }
287
+ if (bootstrapImports.length > 0) {
288
+ evidencePaths.push(...bootstrapImports);
289
+ reasons.push(`Application/test bootstrap imports Mock code: ${bootstrapImports.join(", ")}`);
290
+ }
291
+ // Evaluate strong evidence
292
+ // Case: direct dep + handler files
293
+ if (foundDeps.length > 0 && handlerResult.paths.length > 0) {
294
+ strongEvidenceCount++;
295
+ reasons.push(`Direct mock dependency (${foundDeps.join(", ")}) with handler files`);
296
+ }
297
+ // Case: mock script in package.json + corresponding config/entry
298
+ if (scriptResult.hasScript && handlerResult.paths.length > 0) {
299
+ strongEvidenceCount++;
300
+ reasons.push("Mock scripts and handler files both present");
301
+ }
302
+ if (bootstrapImports.length > 0 && handlerResult.paths.length > 0) {
303
+ strongEvidenceCount++;
304
+ reasons.push("Application/test bootstrap and project Mock files both present");
305
+ }
306
+ // Case: project specs define mock service root
307
+ if (taskConfig.frontendMock?.serviceRoot && handlerResult.paths.length > 0) {
308
+ strongEvidenceCount++;
309
+ serviceRoot = taskConfig.frontendMock.serviceRoot;
310
+ reasons.push(`task config specifies serviceRoot=${serviceRoot}`);
311
+ }
312
+ // Handle ambiguous: some signals but not enough for "present"
313
+ if (strongEvidenceCount === 0 &&
314
+ (foundDeps.length > 0 ||
315
+ scriptResult.hasScript ||
316
+ handlerResult.paths.length > 0 ||
317
+ bootstrapImports.length > 0)) {
318
+ ambiguousSignals++;
319
+ if (foundDeps.length > 0 && handlerResult.paths.length === 0) {
320
+ reasons.push(`Mock dependency found (${foundDeps.join(", ")}) but no handler/bootstrap files detected`);
321
+ }
322
+ if (scriptResult.hasScript &&
323
+ handlerResult.paths.length === 0 &&
324
+ foundDeps.length === 0) {
325
+ reasons.push("Mock scripts exist but no handler files or direct mock dependencies found");
326
+ }
327
+ }
328
+ // Build verify commands from task config
329
+ const configuredVerifyCommands = (taskConfig.frontendMock?.verifyCommands ?? []).map((cmd) => ({
330
+ label: cmd.label,
331
+ args: ["bash", "-lc", cmd.command],
332
+ cwd: repoRoot,
333
+ timeoutMs: cmd.timeoutMs,
334
+ }));
335
+ const verifyCommands = [
336
+ ...configuredVerifyCommands,
337
+ ...scriptResult.verifyCommands,
338
+ ].filter((command, index, commands) => commands.findIndex((candidate) => candidate.label === command.label) ===
339
+ index);
340
+ if (strongEvidenceCount > 0) {
341
+ return {
342
+ status: "present",
343
+ framework,
344
+ serviceRoot: serviceRoot ?? taskConfig.frontendMock?.serviceRoot,
345
+ evidencePaths,
346
+ verifyCommands,
347
+ reasons,
348
+ };
349
+ }
350
+ if (ambiguousSignals > 0) {
351
+ return {
352
+ status: "ambiguous",
353
+ framework,
354
+ serviceRoot: taskConfig.frontendMock?.serviceRoot,
355
+ evidencePaths,
356
+ verifyCommands,
357
+ reasons,
358
+ };
359
+ }
360
+ return {
361
+ status: "absent",
362
+ evidencePaths,
363
+ verifyCommands,
364
+ reasons: ["No mock service evidence found in project"],
365
+ };
366
+ }
367
+ export function frontendMockServiceRootAllowed(taskConfig) {
368
+ const serviceRoot = taskConfig.frontendMock?.serviceRoot;
369
+ if (!serviceRoot)
370
+ return true;
371
+ const normalized = serviceRoot.replace(/\\/g, "/").replace(/^\.\//, "");
372
+ if (normalized === "." ||
373
+ normalized === ".." ||
374
+ normalized.startsWith("../") ||
375
+ path.isAbsolute(serviceRoot) ||
376
+ /[?*]/.test(normalized)) {
377
+ return false;
378
+ }
379
+ const allowed = taskConfig.allowedPaths.some((allowedPath) => pathMatchesPattern(normalized, allowedPath) ||
380
+ pathMatchesPattern(`${normalized}/_probe_`, allowedPath));
381
+ if (!allowed)
382
+ return false;
383
+ return !mergeForbiddenPaths(taskConfig).some((forbiddenPath) => {
384
+ const forbiddenPrefix = patternStaticPrefix(forbiddenPath);
385
+ return (pathMatchesPattern(normalized, forbiddenPath) ||
386
+ (forbiddenPrefix.length > 0 &&
387
+ pathMatchesPattern(forbiddenPrefix, normalized)));
388
+ });
389
+ }
390
+ export async function frontendMockServiceRootSafetyViolation(repoRoot, taskConfig) {
391
+ const serviceRoot = taskConfig.frontendMock?.serviceRoot;
392
+ if (!serviceRoot)
393
+ return undefined;
394
+ if (!frontendMockServiceRootAllowed(taskConfig)) {
395
+ return `frontendMock.serviceRoot is outside allowedPaths or overlaps forbiddenPaths: ${serviceRoot}`;
396
+ }
397
+ try {
398
+ const [repoRealPath, serviceRealPath] = await Promise.all([
399
+ realpath(repoRoot),
400
+ realpath(path.resolve(repoRoot, serviceRoot)),
401
+ ]);
402
+ const relativeRealPath = path.relative(repoRealPath, serviceRealPath);
403
+ if (relativeRealPath === ".." ||
404
+ relativeRealPath.startsWith(`..${path.sep}`) ||
405
+ path.isAbsolute(relativeRealPath)) {
406
+ return `frontendMock.serviceRoot resolves outside the repository: ${serviceRoot}`;
407
+ }
408
+ const normalizedRealPath = relativeRealPath.split(path.sep).join("/") || ".";
409
+ if (!frontendMockServiceRootAllowed({
410
+ ...taskConfig,
411
+ frontendMock: {
412
+ ...(taskConfig.frontendMock ?? {
413
+ policy: "auto",
414
+ verifyCommands: [],
415
+ }),
416
+ serviceRoot: normalizedRealPath,
417
+ },
418
+ })) {
419
+ return `frontendMock.serviceRoot resolves outside its allowed boundary: ${serviceRoot}`;
420
+ }
421
+ }
422
+ catch (error) {
423
+ // A missing configured root is capability absence, not a path escape. The
424
+ // discovery pass below will report it without traversing another location.
425
+ if (error.code !== "ENOENT") {
426
+ return `frontendMock.serviceRoot safety could not be verified: ${serviceRoot}`;
427
+ }
428
+ }
429
+ return undefined;
430
+ }
431
+ export function hasApiDependency(sources) {
432
+ if (sources.taskConfig.frontendMock?.policy === "required") {
433
+ return true;
434
+ }
435
+ const requirement = normalizeTaskRequirementText(sources.requirementMarkdown).replace(/`[^`\n]*`/g, " ");
436
+ const dependencyPatterns = [
437
+ /(?:接口文档|接口定义|接口协议|后端接口|服务端接口|接口联调|请求|响应|远程数据|异步数据|数据获取|模拟接口|模拟数据)/,
438
+ /(?<![A-Za-z0-9_])API(?![A-Za-z0-9_])/i,
439
+ /\b(?:endpoint|request|response|fetch|axios|schema|mock|backend\s+api|server\s+api)\b/i,
440
+ ];
441
+ const negationPatterns = [
442
+ /(?:不涉及|无需|不需要|不依赖|不调用|不请求|没有|禁止|不得).{0,16}(?:接口|后端|服务端|远程数据|异步数据|API)/i,
443
+ /\b(?:no|without|does\s+not|do\s+not|must\s+not)\b.{0,24}\b(?:api|endpoint|request|backend|server)\b/i,
444
+ ];
445
+ return requirement
446
+ .split(/[。!?!?;;,,\r\n]+/)
447
+ .map((clause) => clause.trim())
448
+ .filter(Boolean)
449
+ .some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
450
+ dependencyPatterns.some((pattern) => pattern.test(clause)));
451
+ }
452
+ export function frontendSourceMentionsMock(sources) {
453
+ const text = [sources.requirementMarkdown, sources.constraintMarkdown]
454
+ .filter((entry) => typeof entry === "string" && entry.trim().length > 0)
455
+ .map((entry) => normalizeTaskRequirementText(entry).replace(/`[^`\n]*`/g, " "))
456
+ .join("\n");
457
+ const signalPatterns = [
458
+ /mock/i,
459
+ /模拟服务/,
460
+ /接口桩/,
461
+ /\bmsw\b/i,
462
+ /(?<![A-Za-z0-9_])API(?![A-Za-z0-9_])/i,
463
+ /接口/,
464
+ ];
465
+ const negationPatterns = [
466
+ /(?:不涉及|无需|不需要|不依赖|不调用|不请求|不实现|没有|禁止|不得).{0,16}(?:接口|后端|服务端|远程数据|异步数据|API|mock|模拟|桩)/i,
467
+ /\b(?:no|without|does\s+not|do\s+not|must\s+not)\b.{0,24}\b(?:api|endpoint|request|backend|server|mock)\b/i,
468
+ ];
469
+ return text
470
+ .split(/[。!?!?;;,,\r\n]+/)
471
+ .map((clause) => clause.trim())
472
+ .filter(Boolean)
473
+ .some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
474
+ signalPatterns.some((pattern) => pattern.test(clause)));
475
+ }
476
+ export function resolveFrontendMockMode(capability, taskConfig, hasApiDep) {
477
+ const policy = taskConfig.frontendMock?.policy ?? "auto";
478
+ const hasDeterministicMockVerification = capability.verifyCommands.length > 0;
479
+ if (capability.safetyViolation ||
480
+ !frontendMockServiceRootAllowed(taskConfig)) {
481
+ return "blocked";
482
+ }
483
+ // disabled policy: must respect project mock rules (can't override spec)
484
+ if (policy === "disabled") {
485
+ // When disabled but the project spec mandates mock, it's blocked
486
+ if (capability.status === "present") {
487
+ // Project has mock service; disabled is an explicit override that still allows not-required
488
+ return "not-required";
489
+ }
490
+ return "not-required";
491
+ }
492
+ // required policy
493
+ if (policy === "required") {
494
+ if (capability.status === "present" && hasDeterministicMockVerification) {
495
+ return "required";
496
+ }
497
+ return "blocked";
498
+ }
499
+ // auto policy
500
+ if (!hasApiDep) {
501
+ return "not-required";
502
+ }
503
+ // In auto mode, frontend Mock is optional. Only enter Mock-backed "required"
504
+ // mode when the project has a confirmed native Mock capability AND
505
+ // deterministic Mock verification commands. Without executable verification
506
+ // commands a non-not-needed strategy could never be verified, so auto falls
507
+ // back to not-required; the prewrite gate then forces not-needed and the
508
+ // plan records the Real Integration Gap instead of inventing commands.
509
+ return capability.status === "present" && hasDeterministicMockVerification
510
+ ? "required"
511
+ : "not-required";
512
+ }
513
+ export function excerptMarkdown(content, options = {}) {
514
+ const maxChars = options.maxChars ?? MAX_SOURCE_EXCERPT_CHARS;
515
+ const trimmed = content.trim();
516
+ if (trimmed.length <= maxChars) {
517
+ return {
518
+ text: trimmed,
519
+ truncated: false,
520
+ originalChars: trimmed.length,
521
+ maxChars,
522
+ };
523
+ }
524
+ const omitted = trimmed.length - maxChars;
525
+ const ref = options.sourceRef?.trim();
526
+ const pointer = ref
527
+ ? [
528
+ `...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`,
529
+ `Full source (authoritative, do not invent missing content): ${ref}`,
530
+ "When the omitted tail may affect acceptance/non-goals/constraints, re-read that file before deciding.",
531
+ ].join("\n")
532
+ : `...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`;
533
+ return {
534
+ text: `${trimmed.slice(0, maxChars)}\n\n${pointer}`,
535
+ truncated: true,
536
+ originalChars: trimmed.length,
537
+ maxChars,
538
+ };
539
+ }
540
+ export function extractObjective(requirementMarkdown, title) {
541
+ const sectionMatchers = [
542
+ /##\s*(目标|Objective|Goals?)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
543
+ /##\s*Description\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
544
+ ];
545
+ for (const matcher of sectionMatchers) {
546
+ const match = requirementMarkdown.match(matcher);
547
+ if (!match)
548
+ continue;
549
+ const body = (match[2] ?? match[1] ?? "").trim();
550
+ for (const line of body.split("\n")) {
551
+ const trimmed = line
552
+ .replace(/^[-*]\s*\[[ xX]\]\s*/, "")
553
+ .replace(/^[-*]\s*/, "")
554
+ .trim();
555
+ if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
556
+ continue;
557
+ return trimmed.slice(0, 500);
558
+ }
559
+ }
560
+ const lines = requirementMarkdown.split("\n");
561
+ for (const line of lines) {
562
+ const trimmed = line.trim();
563
+ if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
564
+ continue;
565
+ return trimmed.slice(0, 500);
566
+ }
567
+ return `Implement task: ${title}`;
568
+ }
569
+ export function extractSuccessCriteria(requirementMarkdown, taskId) {
570
+ const criteria = [];
571
+ const sectionMatch = requirementMarkdown.match(SUCCESS_CRITERIA_SECTION);
572
+ if (sectionMatch) {
573
+ for (const line of (sectionMatch[2] ?? "").split("\n")) {
574
+ const trimmed = line.replace(/^[-*]\s*\[[ xX]\]\s*/, "").trim();
575
+ if (trimmed.startsWith("-") || trimmed.startsWith("*")) {
576
+ criteria.push(trimmed.replace(/^[-*]\s*/, "").trim());
577
+ }
578
+ else if (trimmed.length > 0 && !trimmed.startsWith("#")) {
579
+ criteria.push(trimmed);
580
+ }
581
+ }
582
+ }
583
+ if (criteria.length > 0)
584
+ return criteria.slice(0, 8);
585
+ return [
586
+ `contract-pi produces an auditable read-only implementation contract for ${taskId}`,
587
+ "scout-src and scout-tests complete parallel read-only reconnaissance without file writes",
588
+ "plan-pi returns a read-only implementation plan aligned with constraints",
589
+ "the implementation writer changes only paths declared in writeSet",
590
+ "verify-pi returns read-only verification strategy and residual risks",
591
+ "closeout-pi returns read-only handoff summary without writing artifacts/docs",
592
+ ];
593
+ }
594
+ export function resolveImplementPaths(taskConfig, options = {}) {
595
+ if (taskConfig.allowedPaths.length > 0) {
596
+ const allowed = [...taskConfig.allowedPaths];
597
+ const forbidden = mergeForbiddenPaths(taskConfig);
598
+ const repoRoot = options.repoRoot;
599
+ let companions = [];
600
+ // Only consult the catalog when writer paths can touch docs/* so non-docs
601
+ // temp fixtures without a catalog keep working; docs writers still fail closed.
602
+ const mayNeedDocIndex = allowed.some((entry) => {
603
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
604
+ return normalized === "docs" || normalized.startsWith("docs/");
605
+ });
606
+ if (repoRoot && mayNeedDocIndex) {
607
+ const closure = mergeDocumentIndexCompanions({
608
+ repoRoot,
609
+ paths: allowed,
610
+ forbiddenPaths: forbidden,
611
+ });
612
+ companions = closure.companions;
613
+ const explicitAllowedPaths = new Set(allowed.map((entry) => entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")));
614
+ const missingCompanions = closure.companions.filter((companion) => !explicitAllowedPaths.has(companion));
615
+ if (missingCompanions.length > 0) {
616
+ throw new Error(`document-index-closure: catalog companion paths must be explicitly authorized in task allowedPaths: ${missingCompanions.map((companion) => `"${companion}"`).join(", ")}. Add each missing path with task advance --allowed-path <path>; required command(s): ${missingCompanions.map((companion) => `task advance --allowed-path ${companion}`).join("; ")}`);
617
+ }
618
+ }
619
+ return {
620
+ allowedPaths: allowed,
621
+ writeSet: [...allowed],
622
+ docIndexCompanions: companions,
623
+ };
624
+ }
625
+ return {
626
+ allowedPaths: ["**"],
627
+ writeSet: [IMPLEMENT_WRITESET_PLACEHOLDER],
628
+ docIndexCompanions: [],
629
+ };
630
+ }
631
+ export function applyMavenVerificationPlanning(input) {
632
+ if (!input.repoRoot || !input.commands || input.commands.length === 0) {
633
+ return { commands: input.commands };
634
+ }
635
+ const implement = resolveImplementPaths(input.taskConfig, {
636
+ repoRoot: input.repoRoot,
637
+ });
638
+ const planned = planMavenVerification({
639
+ repoRoot: input.repoRoot,
640
+ commands: input.commands,
641
+ scope: {
642
+ allowedPaths: input.taskConfig.allowedPaths ?? [],
643
+ writeSet: implement.writeSet,
644
+ sourcePaths: input.taskConfig.sourceFiles ?? [],
645
+ },
646
+ });
647
+ if (!planned.plan) {
648
+ return { commands: planned.commands };
649
+ }
650
+ // Freeze shell command digests against the serialized shell form used at runtime.
651
+ const shellCommands = buildVerifyShellCommands({
652
+ repoRoot: input.repoRoot,
653
+ commands: planned.commands,
654
+ fallbackCommands: [],
655
+ });
656
+ return {
657
+ commands: planned.commands,
658
+ mavenVerificationPlan: toDagMavenVerificationPlan(planned.plan, shellCommands),
659
+ };
660
+ }
661
+ export function extractFrontendVerifyCommandsFromMarkdown(input) {
662
+ if (!input.repoRoot)
663
+ return { staticCommands: [], behaviorCommands: [] };
664
+ const markdown = [
665
+ input.requirementMarkdown,
666
+ input.constraintMarkdown ?? "",
667
+ ].join("\n");
668
+ const commands = new Set();
669
+ for (const line of markdown.split(/\r?\n/)) {
670
+ const bulletless = line
671
+ .trim()
672
+ .replace(/^[-*]\s*(?:\[[ xX]\]\s*)?/, "")
673
+ .trim();
674
+ const codeSpanCommands = Array.from(bulletless.matchAll(/`([^`]+)`/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
675
+ const candidates = codeSpanCommands.length > 0 ? codeSpanCommands : [bulletless];
676
+ for (const candidate of candidates) {
677
+ if (isSupportedMarkdownVerifyCommand(candidate)) {
678
+ commands.add(candidate);
679
+ }
680
+ }
681
+ }
682
+ const staticCommands = [];
683
+ const behaviorCommands = [];
684
+ for (const command of commands) {
685
+ const verifyCommand = markdownVerifyCommand(input.repoRoot, command);
686
+ if (!verifyCommand)
687
+ continue;
688
+ // Markdown is untrusted prose: admit only commands with known verification
689
+ // semantics before using the shared classifier. Explicit task verifyCommands
690
+ // are operator-authorized separately and do not pass through this filter.
691
+ if (!isRecognizedFrontendVerifyCommandText(command))
692
+ continue;
693
+ // Single shared classifier: never fork lane regexes here. The old
694
+ // inline static-first regex misclassified behavior commands whose
695
+ // arguments mention build paths (e.g. `npx vitest run
696
+ // test/build.test.ts`).
697
+ if (classifyFrontendVerifyCommandText(command) === "static") {
698
+ staticCommands.push(verifyCommand);
699
+ }
700
+ else {
701
+ behaviorCommands.push(verifyCommand);
702
+ }
703
+ }
704
+ return { staticCommands, behaviorCommands };
705
+ }
706
+ export function extractFrontendMockVerifyCommandsFromMarkdown(input) {
707
+ const commands = [];
708
+ const markdown = [
709
+ input.requirementMarkdown,
710
+ input.constraintMarkdown ?? "",
711
+ ].join("\n");
712
+ for (const line of markdown.split(/\r?\n/)) {
713
+ if (!/(?:mock|模拟服务|接口桩)/i.test(line))
714
+ continue;
715
+ for (const match of line.matchAll(/`([^`]+)`/g)) {
716
+ const commandText = match[1]?.trim();
717
+ if (!commandText || !isSupportedMarkdownVerifyCommand(commandText))
718
+ continue;
719
+ const command = markdownVerifyCommand(input.repoRoot, commandText);
720
+ if (command)
721
+ commands.push(command);
722
+ }
723
+ }
724
+ return commands.filter((command, index, all) => all.findIndex((candidate) => candidate.args.join("\0") === command.args.join("\0")) === index);
725
+ }
726
+ export function buildExplicitFrontendVerifyCommands(taskConfig, repoRoot) {
727
+ const staticCommands = [];
728
+ const behaviorCommands = [];
729
+ if (!repoRoot)
730
+ return { staticCommands, behaviorCommands };
731
+ for (const command of taskConfig.verifyCommands) {
732
+ const verifyCommand = {
733
+ args: ["bash", "-lc", command.command],
734
+ cwd: repoRoot,
735
+ label: command.label,
736
+ timeoutMs: command.timeoutMs,
737
+ };
738
+ // An explicit `mode` declaration wins over automatic classification. The
739
+ // operator knows the command's nature; the classifier only guesses from
740
+ // text. This matters because the behavior lane requires test-observation
741
+ // capability at writer admission, so a governance/lint script that text
742
+ // classification calls "behavior" blocks admission deterministically and
743
+ // no model-side fix can clear it. A declared command still runs and its
744
+ // exit code is still checked in whichever lane it lands.
745
+ // An undeclared command is classified by text, and
746
+ // `classifyFrontendVerifyCommand` fails at generation when that text
747
+ // carries no recognizable verification semantics. Failing there keeps the
748
+ // decision with the operator, where it is cheap to fix, instead of
749
+ // silently entering the test-observed behavior lane and surfacing much
750
+ // later as an unactionable `FRONTEND_TEST_OBSERVATION_INVALID`.
751
+ const mode = command.mode ??
752
+ classifyFrontendVerifyCommand(command.command, command.label);
753
+ if (mode === "static") {
754
+ staticCommands.push(verifyCommand);
755
+ }
756
+ else {
757
+ behaviorCommands.push(verifyCommand);
758
+ }
759
+ }
760
+ return { staticCommands, behaviorCommands };
761
+ }
762
+ export function tokenizeSimpleShellCommand(command) {
763
+ const tokens = [];
764
+ let token = "";
765
+ let quote = null;
766
+ let escaping = false;
767
+ let tokenStarted = false;
768
+ for (const char of command.trim()) {
769
+ if (escaping) {
770
+ token += char;
771
+ escaping = false;
772
+ tokenStarted = true;
773
+ continue;
774
+ }
775
+ if (char === "\\" && quote !== "'") {
776
+ escaping = true;
777
+ tokenStarted = true;
778
+ continue;
779
+ }
780
+ if (quote) {
781
+ if (quote === '"' && (char === "$" || char === "`"))
782
+ return undefined;
783
+ if (char === quote)
784
+ quote = null;
785
+ else
786
+ token += char;
787
+ tokenStarted = true;
788
+ continue;
789
+ }
790
+ if (char === "'" || char === '"') {
791
+ quote = char;
792
+ tokenStarted = true;
793
+ continue;
794
+ }
795
+ if (/\s/.test(char)) {
796
+ if (tokenStarted)
797
+ tokens.push(token);
798
+ token = "";
799
+ tokenStarted = false;
800
+ continue;
801
+ }
802
+ if (/[;&|<>`\r\n]/.test(char) || char === "$" || char === "(") {
803
+ return undefined;
804
+ }
805
+ token += char;
806
+ tokenStarted = true;
807
+ }
808
+ if (escaping || quote)
809
+ return undefined;
810
+ if (tokenStarted)
811
+ tokens.push(token);
812
+ return tokens.length > 0 ? tokens : undefined;
813
+ }
814
+ export function canonicalizeVerificationCommands(commands) {
815
+ const byKey = new Map();
816
+ const merged = new Map();
817
+ for (const command of commands) {
818
+ const canonicalKey = verifyCommandKey(command);
819
+ const retained = byKey.get(canonicalKey);
820
+ if (!retained) {
821
+ byKey.set(canonicalKey, {
822
+ ...command,
823
+ env: command.env ? { ...command.env } : undefined,
824
+ });
825
+ merged.set(canonicalKey, {
826
+ canonicalKey,
827
+ keptLabel: command.label,
828
+ mergedLabels: [],
829
+ effectiveTimeoutMs: command.timeoutMs ?? null,
830
+ });
831
+ continue;
832
+ }
833
+ const retainedTimeout = retained.timeoutMs ?? 0;
834
+ const candidateTimeout = command.timeoutMs ?? 0;
835
+ if (candidateTimeout > retainedTimeout)
836
+ retained.timeoutMs = command.timeoutMs;
837
+ const mergedEntry = merged.get(canonicalKey);
838
+ mergedEntry.effectiveTimeoutMs = retained.timeoutMs ?? null;
839
+ mergedEntry.mergedLabels.push(command.label);
840
+ }
841
+ const retained = [...byKey.values()];
842
+ const aggregate = retained.find(isCheckRepoVerifyCommand);
843
+ const covered = [];
844
+ const commandsAfterCoverage = aggregate
845
+ ? retained.filter((command) => {
846
+ if (!isCheckRepoCoveredCommand(command))
847
+ return true;
848
+ covered.push({
849
+ aggregateLabel: aggregate.label,
850
+ coveredLabel: command.label,
851
+ });
852
+ return false;
853
+ })
854
+ : retained;
855
+ return {
856
+ commands: commandsAfterCoverage,
857
+ merged: [...merged.values()].filter((entry) => entry.mergedLabels.length > 0),
858
+ covered,
859
+ };
860
+ }
861
+ export function assertVerificationPlanPreflight(input) {
862
+ const root = path.resolve(input.repoRoot);
863
+ const statuses = [];
864
+ for (const command of input.commands) {
865
+ const cwd = path.resolve(command.cwd);
866
+ if (!isWithinRepo(root, cwd) || !existsSync(cwd)) {
867
+ throw new Error(`verification preflight rejected cwd for ${command.label}: ${command.cwd}`);
868
+ }
869
+ const vitestStatus = verifyVitestCommandPreflight({
870
+ command,
871
+ root,
872
+ taskConfig: input.taskConfig,
873
+ });
874
+ if (vitestStatus) {
875
+ statuses.push({ label: command.label, status: vitestStatus });
876
+ continue;
877
+ }
878
+ const script = localScriptTarget(command);
879
+ if (!script) {
880
+ statuses.push({ label: command.label, status: "ok" });
881
+ continue;
882
+ }
883
+ const scriptPath = path.resolve(cwd, script);
884
+ if (existsSync(scriptPath)) {
885
+ statuses.push({ label: command.label, status: "ok" });
886
+ continue;
887
+ }
888
+ const writerMayCreate = input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(script, allowed.replace(/^\.\//, "")));
889
+ if (!writerMayCreate) {
890
+ throw new Error(`verification preflight missing local script for ${command.label}: ${script}`);
891
+ }
892
+ statuses.push({ label: command.label, status: "future-delivery-script" });
893
+ }
894
+ return statuses;
895
+ }
896
+ export function verifyVitestCommandPreflight(input) {
897
+ const args = normalizeVerifyCommandArgs(input.command.args);
898
+ const vitestIndex = args.findIndex((arg) => path.basename(arg).replace(/\.(?:exe|cmd)$/i, "") === "vitest");
899
+ if (vitestIndex < 0)
900
+ return undefined;
901
+ const configArgIndex = args.findIndex((arg, index) => index > vitestIndex &&
902
+ (arg === "--config" || arg === "-c" || arg.startsWith("--config=")));
903
+ let configPath;
904
+ if (configArgIndex >= 0) {
905
+ const arg = args[configArgIndex];
906
+ configPath = arg.startsWith("--config=")
907
+ ? arg.slice("--config=".length)
908
+ : args[configArgIndex + 1];
909
+ }
910
+ const cwd = path.resolve(input.command.cwd);
911
+ const configAbsolute = configPath
912
+ ? path.resolve(cwd, configPath)
913
+ : findRootVitestConfig(input.root);
914
+ const configRelative = configAbsolute
915
+ ? path.relative(input.root, configAbsolute).replace(/\\/g, "/")
916
+ : undefined;
917
+ if (configAbsolute && !existsSync(configAbsolute)) {
918
+ const writerMayCreate = Boolean(configRelative &&
919
+ input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(configRelative, allowed.replace(/^\.\//, ""))));
920
+ if (!writerMayCreate) {
921
+ throw new Error(`verification preflight missing Vitest config for ${input.command.label}: ${configRelative ?? configPath}`);
922
+ }
923
+ }
924
+ const targetPaths = vitestTargetPaths(args.slice(vitestIndex + 1));
925
+ if (targetPaths.length === 0)
926
+ return "ok";
927
+ const inheritedConfig = !configPath && configAbsolute && existsSync(configAbsolute);
928
+ if (inheritedConfig && vitestConfigExcludesDogfood(configAbsolute, targetPaths)) {
929
+ throw new Error(`verification preflight Vitest target ${targetPaths.join(", ")} is excluded by ${path.relative(input.root, configAbsolute).replace(/\\/g, "/")}`);
930
+ }
931
+ const futureTargets = targetPaths.filter((target) => {
932
+ const absolute = path.resolve(cwd, target);
933
+ if (existsSync(absolute))
934
+ return false;
935
+ return !input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(target, allowed.replace(/^\.\//, "")));
936
+ });
937
+ if (futureTargets.length > 0) {
938
+ throw new Error(`verification preflight missing Vitest target for ${input.command.label}: ${futureTargets.join(", ")}`);
939
+ }
940
+ const missingAllowedTargets = targetPaths.filter((target) => !existsSync(path.resolve(cwd, target)));
941
+ return missingAllowedTargets.length > 0 ? "future-test-target" : "ok";
942
+ }
943
+ export function isFullSuiteVerifyCommand(command) {
944
+ const args = normalizeVerifyCommandArgs(command.args);
945
+ let index = 0;
946
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(args[index] ?? ""))
947
+ index += 1;
948
+ const executable = path
949
+ .basename(args[index] ?? "")
950
+ .toLowerCase()
951
+ .replace(/\.(?:exe|cmd)$/, "");
952
+ const rest = args.slice(index + 1);
953
+ if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
954
+ return ((rest.length === 1 && rest[0] === "test") ||
955
+ (rest.length === 2 && rest[0] === "run" && rest[1] === "test"));
956
+ }
957
+ if (["bash", "sh"].includes(executable) && rest.length === 1) {
958
+ return /(?:^|\/)scripts\/(?:ci|check-repo)\.sh$/i.test((rest[0] ?? "").replace(/\\/g, "/"));
959
+ }
960
+ return (rest.length === 0 &&
961
+ /(?:^|\/)scripts\/(?:ci|check-repo)\.sh$/i.test((args[index] ?? "").replace(/\\/g, "/")));
962
+ }
963
+ export function buildVerifyEvidence(input) {
964
+ const selectedCommands = input.commands && input.commands.length > 0 ? input.commands : undefined;
965
+ const commandCount = selectedCommands?.length ?? input.fallbackCommands.length;
966
+ return {
967
+ phase: input.phase,
968
+ quota: input.quota,
969
+ commandSource: input.commandSource,
970
+ commandCount,
971
+ commandLabels: selectedCommands?.map((command) => command.label) ??
972
+ input.fallbackCommands,
973
+ commandTexts: input.commandTexts ?? [],
974
+ canonicalKeys: selectedCommands?.map(verifyCommandKey) ?? [],
975
+ mergedCommands: input.plan?.merged ?? [],
976
+ coveredCommands: input.plan?.covered ?? [],
977
+ selectionReasons: selectedCommands?.map((command) => input.phase === "intermediate" && isFullSuiteVerifyCommand(command)
978
+ ? `${command.label}: deferred-heavy`
979
+ : `${command.label}: kept`) ?? [],
980
+ preflight: input.preflight ??
981
+ (selectedCommands?.map((command) => ({
982
+ label: command.label,
983
+ status: "ok",
984
+ })) ?? []),
985
+ commandTimeoutMs: input.commandTimeoutMs,
986
+ totalTimeoutBudgetMs: commandCount * input.commandTimeoutMs,
987
+ finalFullRequired: input.finalFullRequired,
988
+ };
989
+ }
990
+ export function partitionFrontendStaticVerifyCommands(input) {
991
+ const commands = input.commands ?? [];
992
+ // Frontend DAGs deliberately never generate lint verification. Existing
993
+ // project lint debt is allowed to remain outside this workflow, and an
994
+ // explicitly mentioned lint command must not reintroduce the lint gate.
995
+ const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
996
+ return {
997
+ lint: {
998
+ commandSource: input.commandSource,
999
+ },
1000
+ static: {
1001
+ ...(staticCommands.length > 0 ? { commands: staticCommands } : {}),
1002
+ commandSource: input.commandSource,
1003
+ },
1004
+ };
1005
+ }
1006
+ export async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1007
+ const genericFallback = {
1008
+ staticCommands: ["npm run typecheck", "npm run build"],
1009
+ behaviorCommands: ["npm test"],
1010
+ };
1011
+ if (!repoRoot)
1012
+ return genericFallback;
1013
+ let scripts;
1014
+ try {
1015
+ const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
1016
+ if (packageJson.scripts && typeof packageJson.scripts === "object") {
1017
+ scripts = packageJson.scripts;
1018
+ }
1019
+ }
1020
+ catch {
1021
+ // No readable manifest: leave scripts unset and keep probing.
1022
+ }
1023
+ let packageManager = "npm";
1024
+ for (const [lockfile, manager] of [
1025
+ ["pnpm-lock.yaml", "pnpm"],
1026
+ ["yarn.lock", "yarn"],
1027
+ ["bun.lockb", "bun"],
1028
+ ["bun.lock", "bun"],
1029
+ ]) {
1030
+ try {
1031
+ await access(path.join(repoRoot, lockfile));
1032
+ packageManager = manager;
1033
+ break;
1034
+ }
1035
+ catch {
1036
+ // Try the next package-manager marker.
1037
+ }
1038
+ }
1039
+ const commandForScript = (script) => script === "test"
1040
+ ? `${packageManager} test`
1041
+ : `${packageManager} run ${script}`;
1042
+ const hasScript = (script) => typeof scripts?.[script] === "string" &&
1043
+ String(scripts[script]).trim().length > 0;
1044
+ const firstExisting = (names) => {
1045
+ const selected = [];
1046
+ for (const name of names) {
1047
+ if (hasScript(name) && !selected.includes(name))
1048
+ selected.push(name);
1049
+ }
1050
+ return selected.map(commandForScript);
1051
+ };
1052
+ const staticCommands = firstExisting([
1053
+ "typecheck",
1054
+ "check-types",
1055
+ "check",
1056
+ "build",
1057
+ ]);
1058
+ const behaviorCommands = firstExisting([
1059
+ "test:unit",
1060
+ "test:frontend",
1061
+ "test:component",
1062
+ "test",
1063
+ "test:e2e",
1064
+ "e2e",
1065
+ ]);
1066
+ if (staticCommands.length === 0 && behaviorCommands.length === 0) {
1067
+ try {
1068
+ await access(path.join(repoRoot, "node_modules", ".bin", "tsc"));
1069
+ staticCommands.push("npx --no-install tsc --noEmit");
1070
+ }
1071
+ catch {
1072
+ // No local TypeScript binary: leave verification unavailable rather
1073
+ // than invoking a missing script or downloading a package.
1074
+ }
1075
+ }
1076
+ return {
1077
+ staticCommands: staticCommands.length > 0 ? staticCommands : [],
1078
+ behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands : [],
1079
+ };
1080
+ }
1081
+ export function extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
1082
+ const ids = [];
1083
+ const seen = new Set();
1084
+ const collect = (markdown, declarationsOnly) => {
1085
+ if (!markdown)
1086
+ return;
1087
+ const source = declarationsOnly
1088
+ ? markdown
1089
+ .split(/\r?\n/)
1090
+ .filter((line) => isRequirementIdDeclarationLine(line))
1091
+ .join("\n")
1092
+ : markdown;
1093
+ for (const match of source.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1094
+ const id = match[0].toUpperCase();
1095
+ if (isPlaceholderRequirementId(id))
1096
+ continue;
1097
+ if (!seen.has(id)) {
1098
+ seen.add(id);
1099
+ ids.push(id);
1100
+ }
1101
+ }
1102
+ };
1103
+ // The requirement document itself is human-authored acceptance prose:
1104
+ // only declaration lines declare requirements there. If the document has
1105
+ // no declaration-shaped lines at all (minimal free-form tasks like
1106
+ // "covers AC-001"), fall back to its full text so bindings never go
1107
+ // missing for unstructured input. References and constraints are bound
1108
+ // documents (acceptance yaml, analysis docs) whose ids are authoritative
1109
+ // wherever they appear — keep full scan for them.
1110
+ const declarationLines = requirementMarkdown
1111
+ .split(/\r?\n/)
1112
+ .filter((line) => isRequirementIdDeclarationLine(line));
1113
+ collect(requirementMarkdown, declarationLines.length > 0);
1114
+ for (const markdown of fallbackMarkdown) {
1115
+ if (markdown)
1116
+ collect(markdown, false);
1117
+ }
1118
+ return ids;
1119
+ }
1120
+ export function extractTaskScopedRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
1121
+ // Only the explicit TaskSpec-derived section is authoritative for scope.
1122
+ // Without it, preserve legacy full-scan behavior (requirement + references).
1123
+ const sectionMatch = requirementMarkdown.match(/(?:^|\n)##\s*Acceptance References\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i);
1124
+ if (sectionMatch?.[1]) {
1125
+ const fromSection = extractSectionRequirementIds(sectionMatch[1]);
1126
+ if (fromSection.length > 0)
1127
+ return fromSection;
1128
+ }
1129
+ const explicit = extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown);
1130
+ if (explicit.length > 0)
1131
+ return explicit;
1132
+ // Requirements frequently use a numbered acceptance list instead of
1133
+ // writing AC-* identifiers. Give that list a deterministic canonical
1134
+ // namespace so model-generated AC-1 references bind to the same source
1135
+ // facts instead of failing as unknown requirements at the prewrite gate.
1136
+ const acceptanceSection = requirementMarkdown.match(/(?:^|\n)##\s*(?:验收标准|Acceptance Criteria)\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i)?.[1] ?? "";
1137
+ const numbered = [...acceptanceSection.matchAll(/(?:^|\n)\s*(\d+)[.、)]\s+/g)]
1138
+ .map((match) => Number(match[1]))
1139
+ .filter((value, index, values) => Number.isFinite(value) && values.indexOf(value) === index)
1140
+ .sort((a, b) => a - b);
1141
+ return numbered.map((value) => `AC-${value}`);
1142
+ }
1143
+ export function probePathsForWriteSetEntry(entry) {
1144
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
1145
+ if (!normalized.includes("*")) {
1146
+ return [normalized];
1147
+ }
1148
+ const concrete = normalized
1149
+ .replace(/\/\*\*$/, "/_probe_")
1150
+ .replace(/\*\*$/, "_probe_")
1151
+ .replace(/\*/g, "_");
1152
+ return Array.from(new Set([normalized, concrete]));
1153
+ }
1154
+ export function buildDagSourceBindingBase(sources) {
1155
+ const sourceEntries = [
1156
+ {
1157
+ kind: "requirement",
1158
+ path: sources.requirementPath,
1159
+ markdown: sources.requirementMarkdown,
1160
+ },
1161
+ ...(sources.constraintMarkdown
1162
+ ? [
1163
+ {
1164
+ kind: "constraint",
1165
+ path: sources.constraintPath,
1166
+ markdown: sources.constraintMarkdown,
1167
+ },
1168
+ ]
1169
+ : []),
1170
+ ...(sources.referenceDocuments ?? []).map((reference) => ({
1171
+ kind: "reference",
1172
+ path: reference.path,
1173
+ markdown: reference.markdown,
1174
+ })),
1175
+ ];
1176
+ return {
1177
+ schemaVersion: 1,
1178
+ taskId: sources.taskId,
1179
+ sources: sourceEntries.map((source) => ({
1180
+ kind: source.kind,
1181
+ path: toDagSourcePath(sources, source.path),
1182
+ sha256: createHash("sha256")
1183
+ .update(source.markdown, "utf8")
1184
+ .digest("hex"),
1185
+ })),
1186
+ requirementIds: extractTaskScopedRequirementIds(sources.requirementMarkdown, sources.constraintMarkdown, ...(sources.referenceDocuments ?? []).map((reference) => reference.markdown)),
1187
+ };
1188
+ }
1189
+ export function loadPersistedFrontendSourceBindingLedger(sources) {
1190
+ const referenceDocs = (sources.referenceDocuments ?? []).filter((doc) => doc.markdown.trim().length > 0);
1191
+ const ledgerAbsolutePath = path.join(sources.taskDir, "source", REQUIREMENT_LEDGER_FILE_NAME);
1192
+ // Text-created frontend tasks have no imported reference manifest. Their
1193
+ // task-owned projected requirement is nevertheless a valid canonical source
1194
+ // document and is persisted in the v2 ledger by source preparation. Keep it
1195
+ // in the current-input map below instead of silently downgrading to v1.
1196
+ // 存在性守卫(保持既有 v1 回退行为):派生视图无可抽取 requirement/acceptance
1197
+ // 引用时不要求持久化 ledger,直接回退 base binding。此守卫只用于"是否 ledger
1198
+ // 任务"判定,绝不参与 digest 键计算——键输入完全来自持久化 ledger 恢复。
1199
+ const guardFacts = extractRequirementFactsFromMarkdown(sources.requirementMarkdown);
1200
+ if (guardFacts.acceptanceCriteria.length === 0 && referenceDocs.length > 0)
1201
+ return null;
1202
+ if (referenceDocs.length === 0 && !existsSync(ledgerAbsolutePath))
1203
+ return null;
1204
+ let raw;
1205
+ try {
1206
+ raw = readFileSync(ledgerAbsolutePath, "utf8");
1207
+ }
1208
+ catch (error) {
1209
+ throw new Error(`SOURCE_FIDELITY_LEDGER_MISSING: frontend task ${sources.taskId} has resolvable requirement/acceptance references but no persisted ${REQUIREMENT_LEDGER_FILE_NAME} at ${ledgerAbsolutePath}; run task advance (intake) to persist the ledger before dag init-hybrid (${error instanceof Error ? error.message : String(error)})`);
1210
+ }
1211
+ const ledgerSha256 = createHash("sha256").update(raw).digest("hex");
1212
+ let ledger;
1213
+ try {
1214
+ ledger = parseLedgerJson(raw);
1215
+ }
1216
+ catch (error) {
1217
+ throw new Error(`SOURCE_FIDELITY_LEDGER_MISSING: persisted ${REQUIREMENT_LEDGER_FILE_NAME} is not a valid v1 ledger: ${error instanceof Error ? error.message : String(error)}`);
1218
+ }
1219
+ const ledgerErrors = validateRequirementLedger(ledger);
1220
+ if (ledgerErrors.length > 0) {
1221
+ const first = ledgerErrors[0];
1222
+ throw new Error(`${first.code}: persisted ${REQUIREMENT_LEDGER_FILE_NAME} is invalid; refusing to start a frontend DAG with incomplete source bindings (${ledgerErrors
1223
+ .map((error) => error.message)
1224
+ .join("; ")})`);
1225
+ }
1226
+ // canonical 侧:ledger.canonicalRequirements 按 build 顺序原样保留输入 canonical
1227
+ // requirements(id/text 逐字节),过滤物化 REQ-SRC-*(AC-HARD-002 "物化不入键")
1228
+ // 即精确恢复 digest 输入集;派生导航视图 需求.md 投影不参与键计算。
1229
+ const contract = recoverLedgerInputContract(ledger);
1230
+ if (contract.canonicalRequirements.length === 0) {
1231
+ // AC-HARD-003:存在 v2 ledger 但 canonical requirements 为空时 fail closed,
1232
+ // 绝不回退 v1 base 绑定(buildDagSourceBinding 不降级)。空 canonical 的 ledger
1233
+ // 无法提供 requirement→fragment 映射证据,任何 v1 回退都会静默放行 fail-open。
1234
+ throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} has an empty canonical requirement set; refusing to fall back to a v1 base binding; re-run task advance (intake) to rebuild the ledger`);
1235
+ }
1236
+ // source 侧:ledger.sourcePaths(build 时权威 sourceDocuments 的 repo-root
1237
+ // relative POSIX 路径集合)作为权威身份集合,对当前 materialized reference 文件
1238
+ // 做交集过滤;内容取当前字节(漂移检测)。权威路径缺失(删除/清空/重命名)→
1239
+ // fail-closed STALE;新增的非权威 role 文件被正确排除(不入键)。
1240
+ const currentByPath = new Map();
1241
+ for (const doc of referenceDocs) {
1242
+ currentByPath.set(toDagSourcePath(sources, doc.path), doc.markdown);
1243
+ }
1244
+ if (referenceDocs.length === 0) {
1245
+ currentByPath.set(toDagSourcePath(sources, sources.requirementPath),
1246
+ // applyTaskContract appends a derived `## Source ledger` navigation
1247
+ // block after the ledger input has been hashed. Remove that block when
1248
+ // reconstructing the canonical text digest.
1249
+ sources.requirementMarkdown.replace(/\n## Source ledger\b[\s\S]*$/u, ""));
1250
+ }
1251
+ const missingAuthoritativePaths = contract.sourcePaths.filter((sourcePath) => !currentByPath.has(sourcePath));
1252
+ if (missingAuthoritativePaths.length > 0) {
1253
+ throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} was built from authoritative source documents no longer present (${missingAuthoritativePaths.join(", ")}); re-run task advance (intake) to rebuild the ledger`);
1254
+ }
1255
+ // inputDigest 新鲜度:与当前源输入(canonical repo-root relative identity)一致。
1256
+ // digest 只绑定语义权威输入(规范化源文档 + canonical requirements +
1257
+ // schema/reconciler version);派生导航视图 需求.md 投影不参与键计算。
1258
+ const expectedDigest = computeLedgerInputDigest({
1259
+ sourceDocuments: contract.sourcePaths.map((sourcePath) => ({
1260
+ path: sourcePath,
1261
+ content: currentByPath.get(sourcePath),
1262
+ })),
1263
+ canonicalRequirements: contract.canonicalRequirements,
1264
+ });
1265
+ if (ledger.inputDigest !== expectedDigest) {
1266
+ throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} inputDigest ${ledger.inputDigest} does not match current source input ${expectedDigest}; re-run task advance (intake) to rebuild the ledger`);
1267
+ }
1268
+ const requirementToFragments = {};
1269
+ for (const requirement of ledger.canonicalRequirements) {
1270
+ if (requirement.sourceFragmentIds.length > 0) {
1271
+ requirementToFragments[requirement.id] = requirement.sourceFragmentIds;
1272
+ }
1273
+ }
1274
+ return {
1275
+ ledgerPath: toDagSourcePath(sources, ledgerAbsolutePath),
1276
+ ledgerSha256,
1277
+ inputDigest: ledger.inputDigest,
1278
+ requirementIds: ledger.canonicalRequirements.map((requirement) => requirement.id),
1279
+ requirementToFragments,
1280
+ };
1281
+ }
1282
+ export function buildBackendTestAnalysisSourceBindingContract(sources) {
1283
+ const binding = buildDagSourceBinding(sources);
1284
+ const requirement = binding.sources.find((source) => source.kind === "requirement");
1285
+ if (!requirement) {
1286
+ throw new Error("backend-test analysis requires a requirement source binding");
1287
+ }
1288
+ return {
1289
+ taskId: binding.taskId,
1290
+ requirementPath: requirement.path,
1291
+ requirementSha256: requirement.sha256,
1292
+ referencePaths: binding.sources
1293
+ .filter((source) => source.kind === "reference")
1294
+ .map((source) => source.path),
1295
+ requirementIds: binding.requirementIds,
1296
+ };
1297
+ }
1298
+ export function buildSourceContextBlock(sources, options = {}) {
1299
+ const includeRequirementExcerpt = options.includeRequirementExcerpt ?? true;
1300
+ const includeConstraintExcerpt = options.includeConstraintExcerpt ?? true;
1301
+ const includeReferenceDocuments = options.includeReferenceDocuments ?? true;
1302
+ const requirementRef = toDagSourcePath(sources, sources.requirementPath);
1303
+ const requirementExcerpt = excerptMarkdown(sources.requirementMarkdown, {
1304
+ sourceRef: requirementRef,
1305
+ });
1306
+ const boundReadPaths = [`- requirement: ${requirementRef}`];
1307
+ const parts = [
1308
+ `## Task source: 需求.md`,
1309
+ `Bound readPath (use for Pi read-tool calls): ${requirementRef}`,
1310
+ ...(includeRequirementExcerpt ? [requirementExcerpt.text] : []),
1311
+ ];
1312
+ if (sources.constraintMarkdown) {
1313
+ const constraintRef = toDagSourcePath(sources, sources.constraintPath);
1314
+ const constraintExcerpt = excerptMarkdown(sources.constraintMarkdown, {
1315
+ sourceRef: constraintRef,
1316
+ });
1317
+ boundReadPaths.push(`- constraints: ${constraintRef}`);
1318
+ parts.push("## Task source: 执行约束.md", `Bound readPath (use for Pi read-tool calls): ${constraintRef}`, ...(includeConstraintExcerpt ? [constraintExcerpt.text] : []));
1319
+ }
1320
+ for (const reference of (includeReferenceDocuments
1321
+ ? sources.referenceDocuments ?? []
1322
+ : []).slice(0, MAX_INLINE_SOURCE_REFERENCE_DOCUMENTS)) {
1323
+ const relativePath = path
1324
+ .relative(path.join(sources.taskDir, "source"), reference.path)
1325
+ .replaceAll(path.sep, "/");
1326
+ const referenceRef = toDagSourcePath(sources, reference.path);
1327
+ const referenceExcerpt = excerptMarkdown(reference.markdown, {
1328
+ sourceRef: referenceRef,
1329
+ });
1330
+ boundReadPaths.push(`- reference ${relativePath}: ${referenceRef}`);
1331
+ parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, referenceExcerpt.text);
1332
+ }
1333
+ parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
1334
+ if (sources.taskConfig.hardConstraints.length > 0) {
1335
+ parts.push("- hardConstraints:", ...sources.taskConfig.hardConstraints.map((c) => ` - ${c}`));
1336
+ }
1337
+ return parts.join("\n\n");
1338
+ }
1339
+ export async function loadMaterializedSourceReferences(sourceDir) {
1340
+ const referenceDir = path.join(sourceDir, REFERENCE_DIRECTORY);
1341
+ const referencePaths = [];
1342
+ async function collect(dir) {
1343
+ let entries;
1344
+ try {
1345
+ entries = await readdir(dir, { withFileTypes: true });
1346
+ }
1347
+ catch (error) {
1348
+ if (error.code === "ENOENT")
1349
+ return;
1350
+ throw error;
1351
+ }
1352
+ for (const entry of entries) {
1353
+ const entryPath = path.join(dir, entry.name);
1354
+ if (entry.isDirectory()) {
1355
+ await collect(entryPath);
1356
+ }
1357
+ else if (entry.isFile()) {
1358
+ // Skip index/manifest sidecars; keep only user/source reference content.
1359
+ if (entry.name === "index.json" ||
1360
+ entry.name === "source-manifest.json") {
1361
+ continue;
1362
+ }
1363
+ referencePaths.push(entryPath);
1364
+ }
1365
+ }
1366
+ }
1367
+ await collect(referenceDir);
1368
+ referencePaths.sort((left, right) => left.localeCompare(right));
1369
+ return Promise.all(referencePaths.map(async (filePath) => ({
1370
+ path: filePath,
1371
+ markdown: await readFile(filePath, "utf-8"),
1372
+ })));
1373
+ }
1374
+ export async function loadTaskHybridSources(repoRoot, taskId) {
1375
+ const paths = getTaskPaths(repoRoot, taskId);
1376
+ const requirementPath = path.join(paths.sourceDir, REQUIREMENT_FILE);
1377
+ const constraintPath = path.join(paths.sourceDir, CONSTRAINT_FILE);
1378
+ try {
1379
+ await access(paths.taskConfigPath);
1380
+ }
1381
+ catch {
1382
+ throw new Error(`task.json not found for task "${taskId}": expected ${paths.taskConfigPath}`);
1383
+ }
1384
+ const taskConfig = await loadTaskConfig(repoRoot, taskId);
1385
+ // Baseline boundaries are independently knowable from task.json and must
1386
+ // not be masked by source or adapter verification loading failures.
1387
+ assertTaskAllowedPathsPreflight(taskConfig);
1388
+ let requirementMarkdown;
1389
+ try {
1390
+ requirementMarkdown = await readFile(requirementPath, "utf-8");
1391
+ }
1392
+ catch {
1393
+ throw new Error(`required source file missing for task "${taskId}": ${requirementPath}`);
1394
+ }
1395
+ let constraintMarkdown = null;
1396
+ try {
1397
+ constraintMarkdown = await readFile(constraintPath, "utf-8");
1398
+ }
1399
+ catch {
1400
+ // optional
1401
+ }
1402
+ await materializeTaskReferenceDocs({
1403
+ repoRoot,
1404
+ taskId,
1405
+ taskConfig,
1406
+ });
1407
+ const referenceDocuments = await loadMaterializedSourceReferences(paths.sourceDir);
1408
+ const manifest = await loadHarnessManifest(repoRoot);
1409
+ const strategy = resolveDagVerifyStrategy(taskConfig);
1410
+ let verifyCommands;
1411
+ let verificationPreflight = [];
1412
+ try {
1413
+ const adapter = await resolveAdapter(repoRoot);
1414
+ const preset = resolveVerifyPreset(taskConfig.verifyPreset, taskConfig);
1415
+ const adapterIntermediate = adapter.getVerifyCommands(repoRoot, {
1416
+ preset,
1417
+ quota: strategy.intermediateQuota,
1418
+ phase: "intermediate",
1419
+ taskConfig,
1420
+ });
1421
+ const adapterFinal = adapter.getVerifyCommands(repoRoot, {
1422
+ preset,
1423
+ quota: "full",
1424
+ phase: "final",
1425
+ taskConfig,
1426
+ });
1427
+ const intermediatePlan = canonicalizeVerificationCommands([
1428
+ ...taskVerifyCommands(repoRoot, taskConfig),
1429
+ ...adapterIntermediate,
1430
+ ]);
1431
+ const finalPlan = canonicalizeVerificationCommands([
1432
+ ...taskVerifyCommands(repoRoot, taskConfig),
1433
+ ...adapterFinal,
1434
+ ]);
1435
+ verificationPreflight = assertVerificationPlanPreflight({
1436
+ repoRoot,
1437
+ commands: [...intermediatePlan.commands, ...finalPlan.commands],
1438
+ taskConfig,
1439
+ });
1440
+ verifyCommands = {
1441
+ intermediate: [
1442
+ ...taskVerifyCommands(repoRoot, taskConfig),
1443
+ ...adapterIntermediate,
1444
+ ],
1445
+ final: [...taskVerifyCommands(repoRoot, taskConfig), ...adapterFinal],
1446
+ };
1447
+ if (verifyCommands.final.length === 0) {
1448
+ throw new Error("adapter returned no final verification commands");
1449
+ }
1450
+ }
1451
+ catch (error) {
1452
+ throw new Error(`failed to load verification commands for task "${taskId}": ${error instanceof Error ? error.message : String(error)}`);
1453
+ }
1454
+ const frontendShapeTransitionCapsule = taskConfig.taskKind === "frontend-implementation" && CANONICAL_TASK_ID_PATTERN.test(taskId)
1455
+ ? await discoverLatestCommittedFrontendShapeCapsule({
1456
+ repoRoot,
1457
+ taskId,
1458
+ sourceDigest: computeFrontendShapeSourceDigest({
1459
+ requirementMarkdown,
1460
+ constraintMarkdown: constraintMarkdown ?? "",
1461
+ }),
1462
+ })
1463
+ : undefined;
1464
+ const sources = {
1465
+ taskId,
1466
+ repoRoot,
1467
+ taskDir: paths.taskDir,
1468
+ requirementPath,
1469
+ constraintPath,
1470
+ requirementMarkdown,
1471
+ constraintMarkdown,
1472
+ outputLanguage: manifest.workflowPolicy.dag.outputLanguage,
1473
+ referenceDocuments,
1474
+ taskConfig,
1475
+ enabledExecutors: resolveEnabledExecutors(manifest.executors),
1476
+ executorModelMatrix: resolveExecutorModelMatrices(manifest),
1477
+ verifyCommands,
1478
+ verificationPreflight,
1479
+ sddEmbeddedSkills: await probeRepoLocalSddSkills(repoRoot),
1480
+ projectGovernancePresent: await discoverProjectGovernancePresence(repoRoot),
1481
+ ...(frontendShapeTransitionCapsule ? { autoloadedFrontendShapeTransitionCapsule: frontendShapeTransitionCapsule } : {}),
1482
+ };
1483
+ return sources;
1484
+ }
1485
+ export async function prepareFrontendMockSources(sources, discoveredProjectCapability) {
1486
+ const repoRoot = sources.repoRoot ?? process.cwd();
1487
+ const capability = await discoverFrontendMockCapability(repoRoot, sources.taskConfig);
1488
+ const sourceMockVerifyCommands = extractFrontendMockVerifyCommandsFromMarkdown({
1489
+ repoRoot,
1490
+ requirementMarkdown: sources.requirementMarkdown,
1491
+ constraintMarkdown: sources.constraintMarkdown,
1492
+ });
1493
+ for (const command of sourceMockVerifyCommands) {
1494
+ if (!capability.verifyCommands.some((existing) => existing.args.join("\0") === command.args.join("\0"))) {
1495
+ capability.verifyCommands.push(command);
1496
+ }
1497
+ }
1498
+ const projectCapability = discoveredProjectCapability ??
1499
+ (await discoverFrontendProjectCapability(repoRoot, {
1500
+ specRoots: sources.taskConfig.frontendOpenspec?.specRoots,
1501
+ }));
1502
+ const frontendRisk = classifyFrontendRisk({
1503
+ title: sources.taskConfig.title,
1504
+ requirementMarkdown: sources.requirementMarkdown,
1505
+ constraintMarkdown: sources.constraintMarkdown ?? undefined,
1506
+ allowedPaths: sources.taskConfig.allowedPaths,
1507
+ forbiddenPaths: sources.taskConfig.forbiddenPaths,
1508
+ complexity: sources.taskConfig.complexity,
1509
+ manifestEvidence: [
1510
+ projectCapability.framework,
1511
+ projectCapability.frameworkVersion ?? "",
1512
+ projectCapability.adapterGuidance,
1513
+ ].join("\n"),
1514
+ });
1515
+ return {
1516
+ ...sources,
1517
+ frontendMockCapability: capability,
1518
+ frontendMockMode: resolveFrontendMockMode(capability, sources.taskConfig, hasApiDependency(sources)),
1519
+ frontendProjectCapability: projectCapability,
1520
+ frontendRisk,
1521
+ };
1522
+ }
1523
+ export function mergeForbiddenPaths(taskConfig) {
1524
+ const merged = new Set([
1525
+ ".harness/**",
1526
+ ".harness/dag-runs/**",
1527
+ "artifacts/**",
1528
+ ...taskConfig.forbiddenPaths,
1529
+ ]);
1530
+ return [...merged];
1531
+ }
1532
+ export function patternStaticPrefix(pattern) {
1533
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
1534
+ const wildcard = normalized.search(/[?*]/);
1535
+ return (wildcard >= 0 ? normalized.slice(0, wildcard) : normalized).replace(/\/+$/, "");
1536
+ }
1537
+ export function allowedPathHitsBaselineForbidden(allowedPath) {
1538
+ const probes = probePathsForWriteSetEntry(allowedPath);
1539
+ for (const forbidden of BASELINE_FORBIDDEN_PATHS) {
1540
+ if (probes.some((probe) => pathMatchesPattern(probe, forbidden)) ||
1541
+ pathMatchesPattern(allowedPath, forbidden) ||
1542
+ pathMatchesPattern(forbidden, allowedPath)) {
1543
+ return forbidden;
1544
+ }
1545
+ }
1546
+ return undefined;
1547
+ }
1548
+ export function assertTaskAllowedPathsPreflight(taskConfig) {
1549
+ for (const allowedPath of taskConfig.allowedPaths) {
1550
+ const forbidden = allowedPathHitsBaselineForbidden(allowedPath);
1551
+ if (forbidden) {
1552
+ throw new Error(`task allowedPaths entry "${allowedPath}" overlaps baseline forbidden path "${forbidden}"; task source paths are main-session owned and must not be executor writeSet`);
1553
+ }
1554
+ }
1555
+ }
1556
+ export function buildDagSourceBinding(sources, taskKind) {
1557
+ const base = buildDagSourceBindingBase(sources);
1558
+ if (taskKind !== "frontend-implementation")
1559
+ return base;
1560
+ const ledgerBinding = loadPersistedFrontendSourceBindingLedger(sources);
1561
+ if (!ledgerBinding)
1562
+ return base;
1563
+ // v2 is ledger-owned: the persisted canonical set is the only requirement
1564
+ // identity source. In particular, explanatory AC-XXX mentions from a
1565
+ // materialized reference document must not leak in beside ledger ids.
1566
+ return {
1567
+ schemaVersion: 2,
1568
+ taskId: base.taskId,
1569
+ sources: base.sources,
1570
+ requirementIds: ledgerBinding.requirementIds,
1571
+ ledgerPath: ledgerBinding.ledgerPath,
1572
+ ledgerSha256: ledgerBinding.ledgerSha256,
1573
+ inputDigest: ledgerBinding.inputDigest,
1574
+ requirementToFragments: ledgerBinding.requirementToFragments,
1575
+ };
1576
+ }
1577
+ export function buildVerifyShellCommands(input) {
1578
+ if (!input.repoRoot || !input.commands || input.commands.length === 0) {
1579
+ return input.fallbackCommands;
1580
+ }
1581
+ const repoRoot = input.repoRoot;
1582
+ return input.commands.map((command) => verifyCommandToShell(repoRoot, command));
1583
+ }
1584
+ export function classifyFrontendVerifyCommand(command, label) {
1585
+ const mode = classifyFrontendVerifyCommandText(command);
1586
+ if (!mode) {
1587
+ throw new Error(`verifyCommands[${label}]: unrecognized verification command ${JSON.stringify(command)}; declare its lane explicitly with mode: "static" or mode: "behavior", or use a recognized test runner such as \`npx vitest run\``);
1588
+ }
1589
+ return mode;
1590
+ }
1591
+ export const CONSTRAINT_FILE = "执行约束.md";
1592
+ export function extractSectionRequirementIds(sectionMarkdown) {
1593
+ const ids = [];
1594
+ const seen = new Set();
1595
+ for (const match of sectionMarkdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1596
+ const id = match[0].toUpperCase();
1597
+ if (isPlaceholderRequirementId(id))
1598
+ continue;
1599
+ if (!seen.has(id)) {
1600
+ seen.add(id);
1601
+ ids.push(id);
1602
+ }
1603
+ }
1604
+ return ids;
1605
+ }
1606
+ export function findRootVitestConfig(root) {
1607
+ for (const name of ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs", "vitest.config.cjs"]) {
1608
+ const candidate = path.join(root, name);
1609
+ if (existsSync(candidate))
1610
+ return candidate;
1611
+ }
1612
+ return undefined;
1613
+ }
1614
+ export { INTERACTIVE_UI_DELIVERY_CONTRACT } from "./types.js";
1615
+ export function isCheckRepoCoveredCommand(command) {
1616
+ const target = localScriptTarget(command);
1617
+ return target ? CHECK_REPO_COVERED_SCRIPTS.has(target) : false;
1618
+ }
1619
+ export function isCheckRepoVerifyCommand(command) {
1620
+ return normalizeVerifyCommandArgs(command.args)
1621
+ .join(" ")
1622
+ .replaceAll("\\", "/")
1623
+ .includes("scripts/check-repo.sh");
1624
+ }
1625
+ export function isFrontendLintVerifyCommand(command) {
1626
+ const text = `${command.label}\n${command.args.join(" ")}`;
1627
+ return /\b(?:lint|eslint)\b/i.test(text);
1628
+ }
1629
+ export function isHeadingLine(line) {
1630
+ return /^#{1,6}\s+/.test(line.trim());
1631
+ }
1632
+ export function isMetadataLine(line) {
1633
+ const trimmed = line.trim();
1634
+ if (!trimmed)
1635
+ return true;
1636
+ if (/^(TaskSpec business id|Feature id|Task type|Risk level)\s*:/i.test(trimmed)) {
1637
+ return true;
1638
+ }
1639
+ if (/^(权威来源|SHA-256|冲突时以)/.test(trimmed))
1640
+ return true;
1641
+ if (/^>/.test(trimmed) &&
1642
+ /(权威来源|SHA-256|原始 PRD|reference)/i.test(trimmed)) {
1643
+ return true;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ export function isPlaceholderRequirementId(id) {
1648
+ return /(?:^|-)X{2,}$/.test(id);
1649
+ }
1650
+ export function isRequirementIdDeclarationLine(line) {
1651
+ return REQUIREMENT_ID_DECLARATION_LINE.test(line);
1652
+ }
1653
+ export function isSupportedMarkdownVerifyCommand(command) {
1654
+ return (/^(npm|pnpm|yarn|bun)\s+(run\s+)?[a-z0-9:_-]+(?:\s.*)?$/i.test(command) ||
1655
+ /^(npx|pnpm\s+exec|yarn\s+exec|bunx)\s+(vitest|jest|playwright|cypress|tsc|eslint)(?:\s.*)?$/i.test(command));
1656
+ }
1657
+ export function isWithinRepo(root, candidate) {
1658
+ const relative = path.relative(root, candidate);
1659
+ return (relative === "" ||
1660
+ (!relative.startsWith("..") && !path.isAbsolute(relative)));
1661
+ }
1662
+ export function localScriptTarget(command) {
1663
+ const args = normalizeVerifyCommandArgs(command.args);
1664
+ const candidate = args.find((arg) => arg.replaceAll("\\", "/").startsWith("scripts/"));
1665
+ return candidate?.replaceAll("\\", "/");
1666
+ }
1667
+ export function markdownVerifyCommand(repoRoot, command) {
1668
+ const args = command.split(/\s+/).filter(Boolean);
1669
+ if (args.length === 0 ||
1670
+ args.some((arg) => !/^[a-z0-9@._/:=,+-]+$/i.test(arg))) {
1671
+ return null;
1672
+ }
1673
+ return {
1674
+ args,
1675
+ cwd: repoRoot,
1676
+ label: command,
1677
+ };
1678
+ }
1679
+ export const MAX_INLINE_SOURCE_REFERENCE_DOCUMENTS = 8;
1680
+ export const MAX_SOURCE_EXCERPT_CHARS = 2000;
1681
+ export const REFERENCE_DIRECTORY = "references";
1682
+ export const REQUIREMENT_FILE = "需求.md";
1683
+ export function resolveDagVerifyStrategy(taskConfig, defaultIntermediateQuotaWhenFull = "full") {
1684
+ const explicitIntermediateQuota = taskConfig.dagVerifyStrategy?.intermediateQuota;
1685
+ return {
1686
+ intermediateQuota: explicitIntermediateQuota ??
1687
+ (taskConfig.verifyQuota === "full"
1688
+ ? defaultIntermediateQuotaWhenFull
1689
+ : taskConfig.verifyQuota),
1690
+ finalQuota: "full",
1691
+ focusedCommandSource: taskConfig.dagVerifyStrategy?.focusedCommandSource ?? "adapter",
1692
+ };
1693
+ }
1694
+ export const SUCCESS_CRITERIA_SECTION = /##\s*(验收标准|完成标准|Acceptance(?:\s+References)?|Success Criteria)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i;
1695
+ export function taskVerifyCommands(repoRoot, taskConfig) {
1696
+ if (!repoRoot)
1697
+ return [];
1698
+ return taskConfig.verifyCommands.map((command) => ({
1699
+ args: ["bash", "-lc", command.command],
1700
+ cwd: repoRoot,
1701
+ label: command.label,
1702
+ timeoutMs: command.timeoutMs,
1703
+ }));
1704
+ }
1705
+ export function toDagMavenVerificationPlan(plan, shellCommands) {
1706
+ const shellCommandDigests = shellCommands?.map((command) => createHash("sha256").update(command).digest("hex")) ?? plan.shellCommandDigests;
1707
+ return {
1708
+ schemaVersion: plan.schemaVersion,
1709
+ plannerVersion: plan.plannerVersion,
1710
+ fingerprint: plan.fingerprint,
1711
+ inputDigest: plan.inputDigest,
1712
+ targets: plan.targets,
1713
+ commandDigests: plan.commandDigests,
1714
+ plannedCommands: plan.plannedCommands,
1715
+ ...(plan.workspaceManifest
1716
+ ? { workspaceManifest: plan.workspaceManifest }
1717
+ : {}),
1718
+ ...(shellCommandDigests && shellCommandDigests.length > 0
1719
+ ? { shellCommandDigests }
1720
+ : {}),
1721
+ };
1722
+ }
1723
+ export function toDagSourcePath(sources, absolutePath) {
1724
+ return serializeDagTaskSourcePath({
1725
+ repoRoot: sources.repoRoot,
1726
+ taskDir: sources.taskDir,
1727
+ absolutePath,
1728
+ });
1729
+ }
1730
+ export function verifyCommandKey(command) {
1731
+ const normalizedArgs = normalizeVerifyCommandArgs(command.args);
1732
+ const normalizedCwd = path.resolve(command.cwd).replace(/\\/g, "/");
1733
+ const cwdKey = process.platform === "win32" ? normalizedCwd.toLowerCase() : normalizedCwd;
1734
+ const envKey = Object.entries(command.env ?? {})
1735
+ .filter((entry) => entry[1] !== undefined)
1736
+ .sort(([left], [right]) => left.localeCompare(right))
1737
+ .map(([key, value]) => `${key}=${value}`)
1738
+ .join("\0");
1739
+ return [cwdKey, normalizedArgs.join("\0"), envKey].join("\u0001");
1740
+ }
1741
+ export function vitestConfigExcludesDogfood(configPath, targets) {
1742
+ if (!targets.some((target) => target.replace(/\\/g, "/").startsWith("dogfood/")))
1743
+ return false;
1744
+ try {
1745
+ const text = readFileSync(configPath, "utf8");
1746
+ return /exclude\s*:[\s\S]{0,500}dogfood\/\*\*/.test(text);
1747
+ }
1748
+ catch {
1749
+ return false;
1750
+ }
1751
+ }
1752
+ export function vitestTargetPaths(args) {
1753
+ const targets = [];
1754
+ let afterRun = false;
1755
+ for (let index = 0; index < args.length; index += 1) {
1756
+ const arg = args[index];
1757
+ if (arg === "run" || arg === "related") {
1758
+ afterRun = true;
1759
+ continue;
1760
+ }
1761
+ if (!afterRun || arg.startsWith("-")) {
1762
+ if ((arg === "--config" || arg === "-c" || arg === "--reporter") && !arg.includes("="))
1763
+ index += 1;
1764
+ continue;
1765
+ }
1766
+ targets.push(arg);
1767
+ }
1768
+ return targets.filter((target) => target.includes("/") || /\.(?:test|spec)\.[^/]+$/.test(target));
1769
+ }
1770
+ export function normalizeVerifyCommandArgs(args) {
1771
+ const [shell, flag, payload] = args;
1772
+ if (args.length === 3 &&
1773
+ shell !== undefined &&
1774
+ payload !== undefined &&
1775
+ /^(?:bash|sh)(?:\.exe)?$/i.test(path.basename(shell)) &&
1776
+ flag === "-lc") {
1777
+ const parsed = tokenizeSimpleShellCommand(payload);
1778
+ if (parsed)
1779
+ return normalizeVerifyCommandArgs(parsed);
1780
+ }
1781
+ return args.map((arg, index) => {
1782
+ const normalized = arg.replace(/\\/g, "/");
1783
+ return index === 0 && process.platform === "win32"
1784
+ ? normalized.toLowerCase()
1785
+ : normalized;
1786
+ });
1787
+ }
1788
+ export function verifyCommandToShell(repoRoot, command) {
1789
+ const relativeCwd = path.relative(repoRoot, command.cwd);
1790
+ const cwdPrefix = relativeCwd &&
1791
+ !relativeCwd.startsWith("..") &&
1792
+ !path.isAbsolute(relativeCwd)
1793
+ ? `cd ${shellQuote(relativeCwd)} && `
1794
+ : command.cwd !== repoRoot
1795
+ ? `cd ${shellQuote(command.cwd)} && `
1796
+ : "";
1797
+ const envPrefix = [
1798
+ "HARNESS_ALLOW_ACTIVE_DAG_RUNS=1",
1799
+ ...Object.entries(command.env ?? {}).map(([key, value]) => `${key}=${shellQuote(String(value))}`),
1800
+ ].join(" ");
1801
+ const commandText = command.args.map(shellQuote).join(" ");
1802
+ return `${cwdPrefix}${envPrefix ? `${envPrefix} ` : ""}${commandText}`;
1803
+ }
1804
+ export function shellQuote(value) {
1805
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1806
+ }
1807
+ export const CHECK_REPO_COVERED_SCRIPTS = new Set([
1808
+ "scripts/check-doc-links.sh",
1809
+ "scripts/check-skill-entry.sh",
1810
+ "scripts/check-init-surface.sh",
1811
+ ]);
1812
+ export const REQUIREMENT_ID_DECLARATION_LINE = /^\s*(?:[-+*]\s+|\d+[.、))]\s*|\|\s*)?(?:[-A-Z0-9]+\s*[::]\s*)?(?:\*\*)?(?:REQ|BR|AC)-/i;