@tea-agent/loop-agent 0.38.0 → 0.39.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/build-stamp.json +3 -3
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/task/config-types.js +21 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-prewrite-gate.js +230 -17
- package/dist/workflows/dag/init-hybrid.js +127 -6
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +16 -6
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/retry-policy.js +11 -0
- package/dist/workflows/dag/types.js +24 -5
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/package.json +1 -1
- package/skills/frontend-implementation/references/node-contracts.md +12 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@
|
|
|
4
4
|
|
|
5
5
|
### 新增
|
|
6
6
|
|
|
7
|
+
- `frontend-prewrite-gate` 的 OpenSpec 读取校验支持 `task.json.frontendOpenspec` 配置:`policy` 缺省 `cited`(候选 = `requiredReadPaths` ∪ 任务源显式引用的 openspec 路径),`scan-strict` 保留止血任务后的全量必读语义;`requiredReadPaths` 中非 openspec 支持路径在生成期确定性失败
|
|
8
|
+
- 确定性引用块协议:生效 plan 输出在 fenced `json` 契约块后追加 `openspec-citations` fenced 块(每行一个 JSON `{"path","section","line"}`);gate 解析并与生效 plan/review 节点的成功 read 事件核验,新增 `openspec-citation-block-unparseable` / `openspec-not-cited` / `openspec-citation-not-read` 三个 retryable-invalid failureCode(防捏造、防遗漏)
|
|
9
|
+
- `src/shared/openspec-spec.ts` 新增 `extractTaskSourceOpenspecPaths` 纯函数(反引号内联、markdown 链接目标、裸路径 token → 归一化 → spec 过滤 → 治理子树排除 → 去重排序)
|
|
10
|
+
- 生成期把 `openspecPolicy` 与候选来源元数据(`openspecCandidateSources.declared` / `taskSourceCited` / `scanStrict`)冻结进 prewrite gate 配置;`task advance` 的 advisories 说明当前模式与候选来源
|
|
11
|
+
|
|
12
|
+
### 文档
|
|
13
|
+
|
|
14
|
+
- 新增 `docs/decisions/0014-openspec-gate-claim-verification.md`,并同步 `docs/runtime/frontend-implementation-workflow.md`、`docs/templates/frontend-task-constraints.md`、`docs/templates/frontend-design-contract.md`、`skills/frontend-implementation/references/node-contracts.md`
|
|
15
|
+
|
|
16
|
+
## [0.36.4-beta.0] - 2026-08-16
|
|
17
|
+
|
|
18
|
+
> 前端 DAG 规范上下文确定性落地(beta,发布到 `beta` dist-tag)。
|
|
19
|
+
|
|
20
|
+
### 新增
|
|
21
|
+
|
|
22
|
+
- `discoverFrontendProjectCapability` 产出 `designEvidence.classified` 语义归类:按通用目录约定(`openspec/schemas/**`、`openspec/project-specs/templates/**`、`openspec/project-specs/rules/**`、`openspec/project-specs/ui/**`、`ai_workspace/**`)把规范路径分入稳定键 `schemas` / `codeTemplate` / `rule.*` / `theme` / `component` / `uiOther` / `advisoryOther`,并进入 zod schema 校验(分区恒等式:归类值集合 == `normativePaths` 集合),重复探测稳定,不硬编码具体项目文件名
|
|
23
|
+
- 前端 contract/scout/plan/design-review 节点的 `subtask_prompt` 注入归类后的规范路径(含角色语义)并明确要求报告适用规则、命中路径/章节/行号、冲突与缺失
|
|
24
|
+
|
|
25
|
+
### 修复
|
|
26
|
+
|
|
27
|
+
- `frontend-prewrite-gate` 在 `openspecCandidatePaths` 非空时强制生效 plan/review 节点实际读取候选路径:未读 → `retryable-invalid` + 新增 `openspec-not-read` failureCode,错误信息列出未读路径;空候选(greenfield)不阻断。此前读取证据仅记录不阻断(advisory),本次真正 fail-closed
|
|
28
|
+
- OpenSpec 候选发现止血:默认排除治理子树(`harness.json.governanceRoot` → 缺省 `ai_workspace/loop-agent`),移除固定 `slice(0, 40)` 截断,`openspec/**` 排序优先于 `ai_workspace/**`,仅保留 ≥200 的显式安全上限(命中时写入 `DagSpec.advisories`,非静默丢弃);`openspec-not-read` 失败原因增加实际读取路径/候选总数/排除统计与「重新生成 DAG」归因,生成后已删除/改名的候选改为 `candidate-missing-drift` 诊断而非误报「未读」
|
|
29
|
+
|
|
30
|
+
### 文档
|
|
31
|
+
|
|
32
|
+
- `docs/templates/frontend-task-constraints.md` 把裸 `TODO` 改写为「探索协议 + 结果 schema + 缺失/冲突 fail-closed」,不再出现具体业务组件名或项目专属路径/命令
|
|
33
|
+
- `docs/templates/frontend-task-requirement.md` 与 `docs/templates/frontend-design-contract.md` 把裸 `TODO` 改写为「编写协议」三要素(要写什么 / DAG 如何消费 / 缺失/冲突时 fail-closed),并新增 `test/frontend-template-no-todo.test.ts` 扫描三个前端模板断言无裸 `TODO`
|
|
34
|
+
- `docs/runtime/frontend-implementation-workflow.md` 的项目规范归类清单改为通用目录约定 + 归类规则,删除写死的示例项目文件名
|
|
35
|
+
|
|
36
|
+
## [0.36.3-beta.0] - 2026-08-16
|
|
37
|
+
|
|
38
|
+
> 前端 DAG Mock 治理收紧(beta,发布到 `beta` dist-tag)。
|
|
39
|
+
|
|
40
|
+
### 改进
|
|
41
|
+
|
|
42
|
+
- 前端 DAG 的 Mock 策略约束注入设计评审链:`mockContextBlock`(含冻结 allowlist 的「HARD CONSTRAINT」与修复路径)现在也注入 `frontend-design-review-pi` / `frontend-plan-revision-pi` / `frontend-final-design-review-pi`,消除「design-review 按项目规范要求 native、但生成期 allowlist 冻结为 not-needed、prewrite 硬阻断」的策略断环——评审链会明确提示这是生成期契约缺口(需声明 `frontendMock.verifyCommands` 或 `policy: "required"` 后重新生成 DAG)
|
|
43
|
+
- 前端任务生成期 Mock 预警:新增 `DagSpec.advisories`,任务源提到接口/API/Mock 但 auto 模式把策略收窄为 `not-needed` 时,`task advance` 的 `warnings` 会给出可操作提示(声明 `frontendMock.verifyCommands` 或 `policy: required` 后重新生成 DAG),把契约缺口提前到生成期暴露
|
|
44
|
+
|
|
45
|
+
### 新增
|
|
46
|
+
|
|
7
47
|
- 版本发布改为三渠道:main 自动发布 npm `next`(`x.y.0-next.N`)、release 分支自动发布正式 patch 到 `release-x.y`、`latest` 仅由人工 GitHub Actions promotion 移动(支持 dry-run 与 CAS 校验)
|
|
8
48
|
- 新增 `next-release.yml` / `cut-release-line.yml` / `release-line-publish.yml` / `promote-npm-latest.yml`,删除旧每日自动 latest 发布入口
|
|
9
49
|
- 发布策略改为 branch + version + channel 矩阵(`scripts/release-channel-policy.mjs`),fail closed
|
|
@@ -49,6 +89,27 @@
|
|
|
49
89
|
- move interrupt eligibility to observability to honor kernel import boundary
|
|
50
90
|
- retry semantic intake across Pi tiers with 300s timeout
|
|
51
91
|
|
|
92
|
+
## [0.36.1-beta.0] - 2026-08-16
|
|
93
|
+
|
|
94
|
+
> 标准 DAG planner 输出超长紧凑重试(beta,发布到 `beta` dist-tag)。
|
|
95
|
+
|
|
96
|
+
### 改进
|
|
97
|
+
|
|
98
|
+
- 标准 DAG 的 planner 节点(`contract-pi` / `plan-pi`)输出超长(`output-too-large`)时自动紧凑重试:新增 `PLANNER_OUTPUT_LIMIT_RETRY_POLICY`(默认重试集合 + `output-too-large`),紧凑重试指令按节点类型区分——结构化契约节点只返回单个紧凑 JSON,普通计划节点返回最小计划(有序步骤、writeSet 边界、验证命令),并丢弃上游原文引用与长证据摘录
|
|
99
|
+
|
|
100
|
+
## [0.35.4-beta.0] - 2026-08-15
|
|
101
|
+
|
|
102
|
+
> frontend prewrite 阻断误路由修正 + Mock 契约文档落地(beta,发布到 `beta` dist-tag)。
|
|
103
|
+
|
|
104
|
+
### 改进
|
|
105
|
+
|
|
106
|
+
- prewrite 阻断导致的 writer `SKIPPED`(`skippedReason=frontend-prewrite-not-authorized`)失败路由从 `DependencyFailure`/`unblock dependency` 修正为 `ContractMismatch`/`fix-frontend-task-contract-and-regenerate-dag`;`dag report` / `dag doctor` 透传 `skippedReason`,对「Mock 策略契约缺陷需改 task.json + 重新生成 DAG」这类场景给出可操作建议,而非误导性的「解除依赖」
|
|
107
|
+
|
|
108
|
+
### 文档
|
|
109
|
+
|
|
110
|
+
- `docs/templates/frontend-task-constraints.md` 的 Mock 约束从 TODO 改为可落地契约:要求从项目**探索**实际存在的确定性 Mock 验证命令(不硬编码命令名)并声明到 `task.json.frontendMock.verifyCommands`,说明生成期 `allowedMockStrategies` 来源、`mock-strategy-outside-allowed` 是生成期契约问题(修复 = 补契约 + 重新生成 DAG,plan-revision 无法修复)
|
|
111
|
+
- `docs/runtime/frontend-implementation-workflow.md` 的 task.json 输入标准补充 Mock 契约声明、命令探索与失败恢复指引
|
|
112
|
+
|
|
52
113
|
## [0.36.0] - 2026-08-15
|
|
53
114
|
|
|
54
115
|
### 新增
|
package/dist/build-stamp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitSha": "
|
|
5
|
-
"builtAt": "2026-08-
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"gitSha": "4e502c40653f25c38302f1104e367d49bc950791",
|
|
5
|
+
"builtAt": "2026-08-17T08:09:21.899Z"
|
|
6
6
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { assertHarnessWriteAllowed, } from "./completed-facts-guard.js";
|
|
4
|
+
let atomicWriteSequence = 0;
|
|
4
5
|
async function writeAtomic(targetPath, content, options) {
|
|
5
6
|
assertHarnessWriteAllowed(targetPath, options);
|
|
6
7
|
const resolved = options?.repoRoot && !path.isAbsolute(targetPath)
|
|
7
8
|
? path.resolve(options.repoRoot, targetPath)
|
|
8
9
|
: path.resolve(targetPath);
|
|
9
10
|
await mkdir(path.dirname(resolved), { recursive: true });
|
|
10
|
-
const tempPath = `${resolved}.${process.pid}.tmp`;
|
|
11
|
+
const tempPath = `${resolved}.${process.pid}.${(atomicWriteSequence += 1)}.tmp`;
|
|
11
12
|
try {
|
|
12
13
|
await writeFile(tempPath, content, "utf-8");
|
|
13
14
|
try {
|
|
@@ -14,6 +14,30 @@ export const OPENSPEC_SPEC_DIRS = [
|
|
|
14
14
|
];
|
|
15
15
|
/** Allowed spec file extensions (case-insensitive). */
|
|
16
16
|
export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* Default governance subtree excluded from candidate discovery. Aligned with
|
|
19
|
+
* init.ts's DEFAULT_GOVERNANCE_ROOT; discovery derives the real root from
|
|
20
|
+
* harness.json.governanceRoot and falls back to this value.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_OPENSPEC_GOVERNANCE_ROOT = "ai_workspace/loop-agent";
|
|
23
|
+
/**
|
|
24
|
+
* Explicit safety ceiling for auto-discovered openspec candidates (>=200).
|
|
25
|
+
* Reaching the cap is reported via DagSpec.advisories, never silently dropped.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_OPENSPEC_CANDIDATE_PATHS = 200;
|
|
28
|
+
/**
|
|
29
|
+
* Returns `true` when `filePath` is the governance subtree itself or any path
|
|
30
|
+
* under it. `filePath` must be a repo-relative POSIX path (backslashes are
|
|
31
|
+
* normalised internally). Comparison is case-sensitive, matching the other
|
|
32
|
+
* predicates in this module.
|
|
33
|
+
*/
|
|
34
|
+
export function isGovernanceSubtreePath(filePath, governanceRoot) {
|
|
35
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
36
|
+
const root = governanceRoot.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
37
|
+
if (!root)
|
|
38
|
+
return false;
|
|
39
|
+
return normalized === root || normalized.startsWith(root + "/");
|
|
40
|
+
}
|
|
17
41
|
/**
|
|
18
42
|
* Returns `true` when `filePath` is inside one of the canonical frontend spec
|
|
19
43
|
* directories. `filePath` must be a repo-relative POSIX path (backslashes are
|
|
@@ -47,3 +71,55 @@ export function isOpenspecSpecSearchTarget(value) {
|
|
|
47
71
|
const normalized = value.replaceAll("\\", "/");
|
|
48
72
|
return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
|
|
49
73
|
}
|
|
74
|
+
/** Normalizes a free-form path token to a repo-relative POSIX path or null. */
|
|
75
|
+
function normalizeCitationToken(raw) {
|
|
76
|
+
let token = raw.trim();
|
|
77
|
+
token = token.replace(/^[<"'`(\[\s]+|[>"'`)\]\s]+$/g, "");
|
|
78
|
+
// Strip trailing punctuation that is not part of a filename.
|
|
79
|
+
token = token.replace(/[),.;:!?]+$/g, "");
|
|
80
|
+
const hashIndex = token.indexOf("#");
|
|
81
|
+
if (hashIndex >= 0)
|
|
82
|
+
token = token.slice(0, hashIndex);
|
|
83
|
+
token = token.replace(/\\/g, "/");
|
|
84
|
+
if (!token)
|
|
85
|
+
return null;
|
|
86
|
+
return token;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Deterministically extracts repo-relative openspec spec paths explicitly cited
|
|
90
|
+
* in task-source markdown (requirement + execution constraint). Pure function,
|
|
91
|
+
* no IO.
|
|
92
|
+
*
|
|
93
|
+
* Scanned sources, in order:
|
|
94
|
+
* - backtick inline code spans (`path`)
|
|
95
|
+
* - markdown link targets ([text](path))
|
|
96
|
+
* - bare path tokens under openspec/ or ai_workspace/
|
|
97
|
+
*
|
|
98
|
+
* Every token is normalized to POSIX, stripped of a trailing `#fragment`,
|
|
99
|
+
* filtered through `isOpenspecSpecFilePath`, and excluded when it falls inside
|
|
100
|
+
* the governance subtree (matching `listOpenspecDiscovery`'s exclusion口径).
|
|
101
|
+
* Results are deduped and sorted.
|
|
102
|
+
*/
|
|
103
|
+
export function extractTaskSourceOpenspecPaths(markdown, governanceRoot = DEFAULT_OPENSPEC_GOVERNANCE_ROOT) {
|
|
104
|
+
if (!markdown)
|
|
105
|
+
return [];
|
|
106
|
+
const raw = new Set();
|
|
107
|
+
const collect = (token) => {
|
|
108
|
+
const normalized = normalizeCitationToken(token);
|
|
109
|
+
if (normalized)
|
|
110
|
+
raw.add(normalized);
|
|
111
|
+
};
|
|
112
|
+
for (const match of markdown.matchAll(/`([^`\n]+)`/g)) {
|
|
113
|
+
collect(match[1] ?? "");
|
|
114
|
+
}
|
|
115
|
+
for (const match of markdown.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
|
|
116
|
+
collect(match[1] ?? "");
|
|
117
|
+
}
|
|
118
|
+
for (const match of markdown.matchAll(/(?:^|[\s"'([>])((?:openspec|ai_workspace)\/[A-Za-z0-9._/-]+)/g)) {
|
|
119
|
+
collect(match[1] ?? "");
|
|
120
|
+
}
|
|
121
|
+
return [...raw]
|
|
122
|
+
.filter((token) => isOpenspecSpecFilePath(token) &&
|
|
123
|
+
!isGovernanceSubtreePath(token, governanceRoot))
|
|
124
|
+
.sort();
|
|
125
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { isOpenspecSpecFilePath } from "../shared/openspec-spec.js";
|
|
2
3
|
export const taskFlowSchema = z.enum([
|
|
3
4
|
"auto",
|
|
4
5
|
"micro",
|
|
@@ -65,6 +66,24 @@ export const frontendMockConfigSchema = z.object({
|
|
|
65
66
|
serviceRoot: z.string().min(1).optional(),
|
|
66
67
|
verifyCommands: z.array(frontendMockVerifyCommandSchema).optional().default([]),
|
|
67
68
|
});
|
|
69
|
+
/** OpenSpec gate policy for frontend prewrite: cited (default) vs scan-strict. */
|
|
70
|
+
export const frontendOpenspecPolicySchema = z.enum(["cited", "scan-strict"]);
|
|
71
|
+
/**
|
|
72
|
+
* Frontend OpenSpec gate configuration. `cited` (the task-config default) makes
|
|
73
|
+
* the prewrite gate verify contract-declared requiredReadPaths plus task-source
|
|
74
|
+
* citations instead of the full generation-time directory snapshot; `scan-strict`
|
|
75
|
+
* keeps the auto-discovered full-read semantics.
|
|
76
|
+
*/
|
|
77
|
+
export const frontendOpenspecConfigSchema = z.object({
|
|
78
|
+
policy: frontendOpenspecPolicySchema.optional().default("cited"),
|
|
79
|
+
requiredReadPaths: z
|
|
80
|
+
.array(z
|
|
81
|
+
.string()
|
|
82
|
+
.refine((candidate) => !candidate.includes("\\") &&
|
|
83
|
+
isOpenspecSpecFilePath(candidate), "frontendOpenspec.requiredReadPaths entries must be repo-relative supported files under openspec/schemas/, openspec/project-specs/, or ai_workspace/"))
|
|
84
|
+
.optional()
|
|
85
|
+
.default([]),
|
|
86
|
+
});
|
|
68
87
|
/** Batch limits and gate profile for the browser-driven frontend test DAG.
|
|
69
88
|
* Token fields are post-case stop thresholds, not model-provider hard caps. */
|
|
70
89
|
export const frontendTestConfigSchema = z.object({
|
|
@@ -175,6 +194,8 @@ const taskConfigObjectSchema = z.object({
|
|
|
175
194
|
frontendMock: frontendMockConfigSchema.optional(),
|
|
176
195
|
/** Frontend browser-test batch and post-case token-stop configuration. */
|
|
177
196
|
frontendTest: frontendTestConfigSchema.optional(),
|
|
197
|
+
/** Frontend OpenSpec gate policy and contract-declared required read paths. */
|
|
198
|
+
frontendOpenspec: frontendOpenspecConfigSchema.optional(),
|
|
178
199
|
notes: z.string().optional().default(""),
|
|
179
200
|
});
|
|
180
201
|
export const taskConfigSchema = z.preprocess((raw) => {
|
|
@@ -5,7 +5,73 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import {
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { DEFAULT_OPENSPEC_GOVERNANCE_ROOT, MAX_OPENSPEC_CANDIDATE_PATHS, OPENSPEC_SPEC_DIRS, OPENSPEC_SPEC_EXT_RE, isGovernanceSubtreePath, isOpenspecSpecFilePath, } from "../shared/openspec-spec.js";
|
|
10
|
+
const classifiedPathSchema = z
|
|
11
|
+
.string()
|
|
12
|
+
.refine((candidate) => isOpenspecSpecFilePath(candidate), "classified path must be a repo-relative supported spec file under openspec/schemas/, openspec/project-specs/, or ai_workspace/");
|
|
13
|
+
/**
|
|
14
|
+
* Stable semantic partition of `designEvidence.normativePaths`. Keys are a
|
|
15
|
+
* generic directory convention (not project-specific file names); the same
|
|
16
|
+
* repository therefore yields the same classification on repeated probes.
|
|
17
|
+
*/
|
|
18
|
+
export const frontendDesignClassifiedSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
schemas: z.array(classifiedPathSchema),
|
|
21
|
+
codeTemplate: z.array(classifiedPathSchema),
|
|
22
|
+
rule: z
|
|
23
|
+
.object({
|
|
24
|
+
api: z.array(classifiedPathSchema),
|
|
25
|
+
mock: z.array(classifiedPathSchema),
|
|
26
|
+
router: z.array(classifiedPathSchema),
|
|
27
|
+
hooks: z.array(classifiedPathSchema),
|
|
28
|
+
utils: z.array(classifiedPathSchema),
|
|
29
|
+
components: z.array(classifiedPathSchema),
|
|
30
|
+
other: z.array(classifiedPathSchema),
|
|
31
|
+
})
|
|
32
|
+
.strict(),
|
|
33
|
+
theme: z.array(classifiedPathSchema),
|
|
34
|
+
component: z.array(classifiedPathSchema),
|
|
35
|
+
uiOther: z.array(classifiedPathSchema),
|
|
36
|
+
advisoryOther: z.array(classifiedPathSchema),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
export const frontendDesignEvidenceSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
normativePaths: z.array(z.string()),
|
|
42
|
+
advisoryPaths: z.array(z.string()),
|
|
43
|
+
conflicts: z.array(z.string()),
|
|
44
|
+
classified: frontendDesignClassifiedSchema,
|
|
45
|
+
})
|
|
46
|
+
.strict()
|
|
47
|
+
.superRefine((evidence, ctx) => {
|
|
48
|
+
const classified = [
|
|
49
|
+
...evidence.classified.schemas,
|
|
50
|
+
...evidence.classified.codeTemplate,
|
|
51
|
+
...evidence.classified.rule.api,
|
|
52
|
+
...evidence.classified.rule.mock,
|
|
53
|
+
...evidence.classified.rule.router,
|
|
54
|
+
...evidence.classified.rule.hooks,
|
|
55
|
+
...evidence.classified.rule.utils,
|
|
56
|
+
...evidence.classified.rule.components,
|
|
57
|
+
...evidence.classified.rule.other,
|
|
58
|
+
...evidence.classified.theme,
|
|
59
|
+
...evidence.classified.component,
|
|
60
|
+
...evidence.classified.uiOther,
|
|
61
|
+
...evidence.classified.advisoryOther,
|
|
62
|
+
];
|
|
63
|
+
const classifiedSet = new Set(classified);
|
|
64
|
+
const normativeSet = new Set(evidence.normativePaths);
|
|
65
|
+
const onlyClassified = [...classifiedSet].filter((candidate) => !normativeSet.has(candidate));
|
|
66
|
+
const onlyNormative = [...normativeSet].filter((candidate) => !classifiedSet.has(candidate));
|
|
67
|
+
if (onlyClassified.length > 0 || onlyNormative.length > 0) {
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: z.ZodIssueCode.custom,
|
|
70
|
+
message: `classified must partition normativePaths exactly; extra in classified: ${onlyClassified.join(", ") || "(none)"}; missing from classified: ${onlyNormative.join(", ") || "(none)"}`,
|
|
71
|
+
path: ["classified"],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
9
75
|
async function exists(filePath) {
|
|
10
76
|
try {
|
|
11
77
|
await stat(filePath);
|
|
@@ -15,39 +81,68 @@ async function exists(filePath) {
|
|
|
15
81
|
return false;
|
|
16
82
|
}
|
|
17
83
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the governance subtree root from harness.json.governanceRoot.
|
|
86
|
+
* Falls back to DEFAULT_OPENSPEC_GOVERNANCE_ROOT when the manifest is missing,
|
|
87
|
+
* unreadable, malformed, or lacks a string governanceRoot. This intentionally
|
|
88
|
+
* does NOT apply init.ts's "docs" -> default migration (init-only behavior).
|
|
89
|
+
*/
|
|
90
|
+
export async function resolveOpenspecGovernanceRoot(repoRoot) {
|
|
91
|
+
const manifest = await readJson(path.join(repoRoot, "harness.json"));
|
|
92
|
+
if (manifest && typeof manifest === "object") {
|
|
93
|
+
const candidate = manifest.governanceRoot;
|
|
94
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
95
|
+
return candidate.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
96
|
+
}
|
|
24
97
|
}
|
|
98
|
+
return DEFAULT_OPENSPEC_GOVERNANCE_ROOT;
|
|
25
99
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
100
|
+
export async function listOpenspecDiscovery(repoRoot, options = {}) {
|
|
101
|
+
const governanceRoot = options.governanceRootOverride ??
|
|
102
|
+
(await resolveOpenspecGovernanceRoot(repoRoot));
|
|
103
|
+
const extraExcludedPrefixes = (options.extraExcludedPrefixes ?? [])
|
|
104
|
+
.map((prefix) => prefix.replace(/\\/g, "/").replace(/\/+$/, ""))
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
const collected = [];
|
|
107
|
+
let excludedGovernanceCount = 0;
|
|
108
|
+
const isExcluded = (rel) => {
|
|
109
|
+
if (isGovernanceSubtreePath(rel, governanceRoot))
|
|
110
|
+
return true;
|
|
111
|
+
return extraExcludedPrefixes.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"));
|
|
31
112
|
};
|
|
32
|
-
}
|
|
33
|
-
function hasDep(deps, name) {
|
|
34
|
-
return Object.hasOwn(deps, name);
|
|
35
|
-
}
|
|
36
|
-
async function listOpenspec(repoRoot) {
|
|
37
|
-
const out = [];
|
|
38
113
|
for (const specDir of OPENSPEC_SPEC_DIRS) {
|
|
39
114
|
const root = path.join(repoRoot, specDir);
|
|
40
115
|
if (!(await exists(root)))
|
|
41
116
|
continue;
|
|
42
117
|
await walk(root, 0);
|
|
43
118
|
}
|
|
44
|
-
|
|
119
|
+
// openspec/schemas/** and openspec/project-specs/** sort first; remaining
|
|
120
|
+
// ai_workspace/** business files follow. Dedupe preserves discovery order.
|
|
121
|
+
const unique = [...new Set(collected)];
|
|
122
|
+
const openspecPaths = unique
|
|
123
|
+
.filter((candidate) => candidate.startsWith("openspec/"))
|
|
124
|
+
.sort();
|
|
125
|
+
const aiWorkspacePaths = unique
|
|
126
|
+
.filter((candidate) => candidate.startsWith("ai_workspace/"))
|
|
127
|
+
.sort();
|
|
128
|
+
const deduped = [...openspecPaths, ...aiWorkspacePaths];
|
|
129
|
+
const truncated = deduped.length > MAX_OPENSPEC_CANDIDATE_PATHS;
|
|
130
|
+
const paths = truncated
|
|
131
|
+
? deduped.slice(0, MAX_OPENSPEC_CANDIDATE_PATHS)
|
|
132
|
+
: deduped;
|
|
133
|
+
return {
|
|
134
|
+
paths,
|
|
135
|
+
governanceRoot,
|
|
136
|
+
excludedGovernanceCount,
|
|
137
|
+
truncated,
|
|
138
|
+
maxCandidates: MAX_OPENSPEC_CANDIDATE_PATHS,
|
|
139
|
+
};
|
|
45
140
|
async function walk(dir, depth) {
|
|
46
141
|
if (depth > 4)
|
|
47
142
|
return;
|
|
48
143
|
let entries = [];
|
|
49
144
|
try {
|
|
50
|
-
entries = await readdir(dir);
|
|
145
|
+
entries = (await readdir(dir)).sort();
|
|
51
146
|
}
|
|
52
147
|
catch {
|
|
53
148
|
return;
|
|
@@ -59,7 +154,13 @@ async function listOpenspec(repoRoot) {
|
|
|
59
154
|
if (info.isDirectory())
|
|
60
155
|
await walk(full, depth + 1);
|
|
61
156
|
else if (OPENSPEC_SPEC_EXT_RE.test(name)) {
|
|
62
|
-
|
|
157
|
+
const rel = path.relative(repoRoot, full).replace(/\\/g, "/");
|
|
158
|
+
if (isExcluded(rel)) {
|
|
159
|
+
excludedGovernanceCount++;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
collected.push(rel);
|
|
163
|
+
}
|
|
63
164
|
}
|
|
64
165
|
}
|
|
65
166
|
catch {
|
|
@@ -68,6 +169,164 @@ async function listOpenspec(repoRoot) {
|
|
|
68
169
|
}
|
|
69
170
|
}
|
|
70
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Count spec-extension files under a governance subtree that candidate
|
|
174
|
+
* discovery would exclude. Bounded, read-only diagnostic helper used by the
|
|
175
|
+
* prewrite gate to reproduce exclusion stats at runtime.
|
|
176
|
+
*/
|
|
177
|
+
export async function countExcludedGovernanceSpecFiles(repoRoot, governanceRoot) {
|
|
178
|
+
const root = path.join(repoRoot, governanceRoot);
|
|
179
|
+
if (!(await exists(root)))
|
|
180
|
+
return 0;
|
|
181
|
+
let count = 0;
|
|
182
|
+
await walkGovernance(root, 0);
|
|
183
|
+
return count;
|
|
184
|
+
async function walkGovernance(dir, depth) {
|
|
185
|
+
if (depth > 4)
|
|
186
|
+
return;
|
|
187
|
+
let entries = [];
|
|
188
|
+
try {
|
|
189
|
+
entries = (await readdir(dir)).sort();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const name of entries) {
|
|
195
|
+
const full = path.join(dir, name);
|
|
196
|
+
try {
|
|
197
|
+
const info = await stat(full);
|
|
198
|
+
if (info.isDirectory())
|
|
199
|
+
await walkGovernance(full, depth + 1);
|
|
200
|
+
else if (OPENSPEC_SPEC_EXT_RE.test(name))
|
|
201
|
+
count++;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// skip
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function readJson(filePath) {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function allDeps(pkg) {
|
|
218
|
+
return {
|
|
219
|
+
...(pkg.dependencies ?? {}),
|
|
220
|
+
...(pkg.devDependencies ?? {}),
|
|
221
|
+
...(pkg.peerDependencies ?? {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function hasDep(deps, name) {
|
|
225
|
+
return Object.hasOwn(deps, name);
|
|
226
|
+
}
|
|
227
|
+
function emptyClassified() {
|
|
228
|
+
return {
|
|
229
|
+
schemas: [],
|
|
230
|
+
codeTemplate: [],
|
|
231
|
+
rule: { api: [], mock: [], router: [], hooks: [], utils: [], components: [], other: [] },
|
|
232
|
+
theme: [],
|
|
233
|
+
component: [],
|
|
234
|
+
uiOther: [],
|
|
235
|
+
advisoryOther: [],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Classify discovered openspec paths by generic directory convention. Only
|
|
240
|
+
* `isOpenspecSpecFilePath` paths enter a bucket; the classifier never invents
|
|
241
|
+
* project-specific file names. Basename stems use word-boundary matching so
|
|
242
|
+
* e.g. `*api*` (case-insensitive) maps to `rule.api` without hardcoding names.
|
|
243
|
+
*/
|
|
244
|
+
function classifyOpenspecPaths(paths) {
|
|
245
|
+
const classified = emptyClassified();
|
|
246
|
+
const ruleStem = (basename) => {
|
|
247
|
+
const lower = basename.toLowerCase();
|
|
248
|
+
const stems = [
|
|
249
|
+
["api", /\bapi\b/],
|
|
250
|
+
["mock", /\bmock\b/],
|
|
251
|
+
["router", /\brouter\b/],
|
|
252
|
+
["hooks", /\bhooks?\b/],
|
|
253
|
+
["utils", /\butils?\b/],
|
|
254
|
+
["components", /\bcomponents?\b/],
|
|
255
|
+
];
|
|
256
|
+
for (const [key, re] of stems) {
|
|
257
|
+
if (re.test(lower))
|
|
258
|
+
return key;
|
|
259
|
+
}
|
|
260
|
+
return "other";
|
|
261
|
+
};
|
|
262
|
+
for (const candidate of paths) {
|
|
263
|
+
if (!isOpenspecSpecFilePath(candidate))
|
|
264
|
+
continue;
|
|
265
|
+
const segments = candidate.split("/");
|
|
266
|
+
const basename = segments[segments.length - 1] ?? "";
|
|
267
|
+
if (candidate.startsWith("openspec/schemas/")) {
|
|
268
|
+
classified.schemas.push(candidate);
|
|
269
|
+
}
|
|
270
|
+
else if (candidate.startsWith("openspec/project-specs/templates/")) {
|
|
271
|
+
classified.codeTemplate.push(candidate);
|
|
272
|
+
}
|
|
273
|
+
else if (candidate.startsWith("openspec/project-specs/rules/")) {
|
|
274
|
+
classified.rule[ruleStem(basename)].push(candidate);
|
|
275
|
+
}
|
|
276
|
+
else if (candidate.startsWith("openspec/project-specs/ui/")) {
|
|
277
|
+
const lower = basename.toLowerCase();
|
|
278
|
+
if (/\btheme\b/.test(lower))
|
|
279
|
+
classified.theme.push(candidate);
|
|
280
|
+
else if (/\bcomponents?\b/.test(lower))
|
|
281
|
+
classified.component.push(candidate);
|
|
282
|
+
else
|
|
283
|
+
classified.uiOther.push(candidate);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
classified.advisoryOther.push(candidate);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const sortBucket = (bucket) => bucket.sort();
|
|
290
|
+
classified.schemas = sortBucket(classified.schemas);
|
|
291
|
+
classified.codeTemplate = sortBucket(classified.codeTemplate);
|
|
292
|
+
for (const key of [
|
|
293
|
+
"api",
|
|
294
|
+
"mock",
|
|
295
|
+
"router",
|
|
296
|
+
"hooks",
|
|
297
|
+
"utils",
|
|
298
|
+
"components",
|
|
299
|
+
"other",
|
|
300
|
+
]) {
|
|
301
|
+
classified.rule[key] = sortBucket(classified.rule[key]);
|
|
302
|
+
}
|
|
303
|
+
classified.theme = sortBucket(classified.theme);
|
|
304
|
+
classified.component = sortBucket(classified.component);
|
|
305
|
+
classified.uiOther = sortBucket(classified.uiOther);
|
|
306
|
+
classified.advisoryOther = sortBucket(classified.advisoryOther);
|
|
307
|
+
return classified;
|
|
308
|
+
}
|
|
309
|
+
function buildDesignEvidence(openspec) {
|
|
310
|
+
// Preserve discovery priority order (openspec first, then ai_workspace)
|
|
311
|
+
// instead of re-sorting; the exact-partition schema identity is set-based.
|
|
312
|
+
const normativePaths = [...new Set(openspec)];
|
|
313
|
+
return frontendDesignEvidenceSchema.parse({
|
|
314
|
+
normativePaths,
|
|
315
|
+
advisoryPaths: [],
|
|
316
|
+
conflicts: [],
|
|
317
|
+
classified: classifyOpenspecPaths(normativePaths),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function toOpenspecDiscovery(discovery, designEvidence) {
|
|
321
|
+
return {
|
|
322
|
+
source: "auto-discovered",
|
|
323
|
+
governanceRoot: discovery.governanceRoot,
|
|
324
|
+
candidateCount: designEvidence.normativePaths.length,
|
|
325
|
+
excludedGovernanceCount: discovery.excludedGovernanceCount,
|
|
326
|
+
truncated: discovery.truncated,
|
|
327
|
+
maxCandidates: discovery.maxCandidates,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
71
330
|
export function buildAdapterGuidance(capability) {
|
|
72
331
|
const lines = [
|
|
73
332
|
"## Frontend project capability (generation-time)",
|
|
@@ -108,7 +367,8 @@ export async function discoverFrontendProjectCapability(repoRoot) {
|
|
|
108
367
|
const pkgPath = path.join(repoRoot, "package.json");
|
|
109
368
|
const pkgRaw = await readJson(pkgPath);
|
|
110
369
|
if (!pkgRaw || typeof pkgRaw !== "object") {
|
|
111
|
-
const
|
|
370
|
+
const discovery = await listOpenspecDiscovery(repoRoot);
|
|
371
|
+
const designEvidence = buildDesignEvidence(discovery.paths);
|
|
112
372
|
const base = {
|
|
113
373
|
schemaVersion: 1,
|
|
114
374
|
framework: "unknown",
|
|
@@ -119,16 +379,13 @@ export async function discoverFrontendProjectCapability(repoRoot) {
|
|
|
119
379
|
testRunner: [],
|
|
120
380
|
mock: [],
|
|
121
381
|
a11y: { status: "unknown", tools: [], evidencePaths: [] },
|
|
122
|
-
evidencePaths:
|
|
382
|
+
evidencePaths: discovery.paths.slice(0, 5),
|
|
123
383
|
reasons: ["package.json missing or unreadable"],
|
|
124
|
-
designEvidence
|
|
125
|
-
|
|
126
|
-
advisoryPaths: [],
|
|
127
|
-
conflicts: [],
|
|
128
|
-
},
|
|
384
|
+
designEvidence,
|
|
385
|
+
openspecDiscovery: toOpenspecDiscovery(discovery, designEvidence),
|
|
129
386
|
};
|
|
130
|
-
if (
|
|
131
|
-
base.reasons.push(`openspec spec files discovered: ${
|
|
387
|
+
if (discovery.paths.length) {
|
|
388
|
+
base.reasons.push(`openspec spec files discovered: ${discovery.paths.slice(0, 5).join(", ")}`);
|
|
132
389
|
}
|
|
133
390
|
return { ...base, adapterGuidance: buildAdapterGuidance(base) };
|
|
134
391
|
}
|
|
@@ -283,14 +540,10 @@ export async function discoverFrontendProjectCapability(repoRoot) {
|
|
|
283
540
|
router = router ?? "next-router";
|
|
284
541
|
if (hasDep(deps, "vue-router"))
|
|
285
542
|
router = "vue-router";
|
|
286
|
-
const
|
|
287
|
-
const designEvidence =
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
conflicts: [],
|
|
291
|
-
};
|
|
292
|
-
if (openspec.length)
|
|
293
|
-
evidencePaths.push(...openspec.slice(0, 5));
|
|
543
|
+
const discovery = await listOpenspecDiscovery(repoRoot);
|
|
544
|
+
const designEvidence = buildDesignEvidence(discovery.paths);
|
|
545
|
+
if (discovery.paths.length)
|
|
546
|
+
evidencePaths.push(...discovery.paths.slice(0, 5));
|
|
294
547
|
const base = {
|
|
295
548
|
schemaVersion: 1,
|
|
296
549
|
framework,
|
|
@@ -311,6 +564,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
|
|
|
311
564
|
evidencePaths: [...new Set(evidencePaths)],
|
|
312
565
|
reasons,
|
|
313
566
|
designEvidence,
|
|
567
|
+
openspecDiscovery: toOpenspecDiscovery(discovery, designEvidence),
|
|
314
568
|
};
|
|
315
569
|
return { ...base, adapterGuidance: buildAdapterGuidance(base) };
|
|
316
570
|
}
|
|
@@ -44,7 +44,9 @@ function routeToProductLine(input) {
|
|
|
44
44
|
case "decision-envelope":
|
|
45
45
|
return "NeedsHuman";
|
|
46
46
|
case "skipped":
|
|
47
|
-
return "
|
|
47
|
+
return input.skippedReason === "frontend-prewrite-not-authorized"
|
|
48
|
+
? "ContractMismatch"
|
|
49
|
+
: "DependencyFailure";
|
|
48
50
|
case "shell-command":
|
|
49
51
|
if (/\b(ENOENT|PATH|command not found|No such file|not found in PATH|bash: .*: No such file)\b/i.test(raw)) {
|
|
50
52
|
return "EnvFailure";
|
|
@@ -90,9 +92,12 @@ export function routeDagFailure(input) {
|
|
|
90
92
|
if (!productLineFailureCategory)
|
|
91
93
|
return {};
|
|
92
94
|
const recommendedFollowUp = productLineFailureCategory === "ContractMismatch" &&
|
|
93
|
-
|
|
94
|
-
? "frontend-
|
|
95
|
-
:
|
|
95
|
+
input.skippedReason === "frontend-prewrite-not-authorized"
|
|
96
|
+
? "fix-frontend-task-contract-and-regenerate-dag"
|
|
97
|
+
: productLineFailureCategory === "ContractMismatch" &&
|
|
98
|
+
isFrontendDesignGateNode(input.nodeId ?? "")
|
|
99
|
+
? "frontend-plan-revision-and-rerun"
|
|
100
|
+
: FOLLOW_UP_BY_PRODUCT_LINE[productLineFailureCategory];
|
|
96
101
|
return {
|
|
97
102
|
productLineFailureCategory,
|
|
98
103
|
recommendedFollowUp,
|