@tea-agent/loop-agent 0.34.2 → 0.34.4

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 (29) hide show
  1. package/AGENTS.md +7 -2
  2. package/CHANGELOG.md +65 -22
  3. package/dist/application/task-lifecycle/advance.js +5 -1
  4. package/dist/task/source-prepare/index.js +1 -0
  5. package/dist/task/source-prepare/placeholder-paths.js +77 -0
  6. package/dist/task/source-prepare/semantic-intake.js +192 -27
  7. package/dist/worker/console/app-data.js +2 -0
  8. package/dist/worker/console/chat/chat-event-store.js +85 -3
  9. package/dist/worker/console/chat/pi-runtime.js +230 -62
  10. package/dist/worker/console/chat/resource-preferences-store.js +152 -0
  11. package/dist/worker/console/chat/routes.js +401 -18
  12. package/dist/worker/console/chat/turn-execution-registry.js +82 -0
  13. package/dist/worker/console/dag-execution-receipt.js +14 -1
  14. package/dist/worker/console/prd-intake-bridge.js +51 -10
  15. package/dist/worker/console/server.js +4 -0
  16. package/dist/worker/console/static/assets/index-B6Qdbk8V.js +29 -0
  17. package/dist/worker/console/static/assets/index-Bt0NUxcQ.css +1 -0
  18. package/dist/worker/console/static/index.html +2 -2
  19. package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
  20. package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
  21. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
  22. package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
  23. package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
  24. package/dist/workflows/dag/init-hybrid.js +2 -0
  25. package/docs/operations/local-development-environment.md +4 -2
  26. package/docs/templates/branch-merge-report.md +9 -0
  27. package/package.json +3 -2
  28. package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
  29. package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
package/AGENTS.md CHANGED
@@ -74,7 +74,9 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
74
74
 
75
75
  权威源:`docs/governance/verification-matrix.md`。
76
76
 
77
- 默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push` `bash scripts/ci.sh`(全量 typecheck + `npm test`)。每个 clone 安装一次 hooks:`bash scripts/install-git-hooks.sh`。
77
+ 默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push` `node scripts/pre-push-verify.mjs` 在 receipt 命中时复用、miss 时跑 full `bash scripts/ci.sh`(全量 typecheck + `npm test`)。每个 clone 安装一次 hooks:`bash scripts/install-git-hooks.sh`。
78
+
79
+ 连续 source→target 交付遵循**单一最终树全量验证**:同一 Git tree、相同环境与命令合同在 receipt TTL 内只选一个本地 full authority(有效 receipt / target pre-push / 可选 `npm run verify:tree`),不按分支名策略化,也不机械双跑 full CI。细节见 verification-matrix 与 branch-merge-guideline。
78
80
 
79
81
  常用:
80
82
 
@@ -83,7 +85,10 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
83
85
  npm run typecheck
84
86
  npx vitest run <相关测试路径>
85
87
 
86
- # push / 交付前(或依赖 pre-push
88
+ # 可选:提前验证当前 clean HEAD 并写 receipt(随后同 tree push 可复用)
89
+ npm run verify:tree
90
+
91
+ # push / 交付前(或依赖 pre-push;receipt miss 时安全回退 full)
87
92
  bash scripts/ci.sh
88
93
  npm run build
89
94
  node bin/loop-agent.js --help
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.4] - 2026-08-12
6
+
7
+ ### 重点更新
8
+
9
+ - 本地连续交付改为按精确 Git tree 复用验证结果,避免提交、推送和分支合并之间机械重复全量测试,同时保持不同 tree、不同环境和远程 CI 的独立质量门槛
10
+ - 全面加固 Operator Chat 的 Turn 所有权、停止与异常恢复流程,解决浏览器与服务端状态错位时的重复提交、停止失效和错误提示问题
11
+ - Operator Chat 新增按仓库保存的 Skills 自动使用偏好,用户可控制技能是否进入模型自动发现范围,同时保留手动调用能力
12
+ - 修复控制台任务导航无法自主执行后端测试与前端测试 DAG 的问题,现可正确识别 pytest、playwright 等多种测试框架的验证命令
13
+
14
+ ### 新增
15
+
16
+ - 新增可选的 `npm run verify:tree`,对当前严格 clean HEAD 执行完整验证并写入 receipt,随后推送同一 tree 时可直接复用
17
+ - 新增只读的当前 tree receipt 诊断,可区分匹配、缺失、过期、损坏和工作区不合格等状态,不执行测试或写入 receipt
18
+ - Operator Chat 引入完整的 Turn ownership 协议和服务端真实中止接口,并在状态接口中提供 active/latest Turn 与 runtime busy 事实
19
+ - Operator Chat Skills 新增「允许自动使用」开关,偏好按仓库持久化;关闭后仍可通过 `/skill:<name>` 手动调用
20
+
21
+ ### 改进
22
+
23
+ - source→target 连续交付统一选择一个本地全量验证权威;默认 commit/push 不增加步骤,远程 CI 与发布环境不复用本地 receipt
24
+ - pre-push receipt 诊断和执行日志改用有界命令摘要,并严格校验 tree、环境、命令合同、有效期及 receipt 内容一致性
25
+ - Skills 运行时快照改为使用服务端资源标识和独立的自动调用状态,资源变更仅接受当前 inventory 中的合法条目
26
+ - Turn 已占用时返回可恢复的中文状态提示和真实 active Turn,不再向用户暴露裸露的 `chat turn already active` 错误
27
+
28
+ ### 修复
29
+
30
+ - 修复浏览器已认为对话结束、服务端却仍持有 active Turn 时的重复提交竞态;提交现在支持幂等请求标识、boot epoch orphan 隔离和 durable Turn/runtime busy 联合对账
31
+ - 修复创建 Turn 的响应不确定时状态被过早释放的问题;客户端会保留 ambiguous 状态,通过 `/state` 绑定或释放真实 ownership,不会重新生成请求标识
32
+ - 修复停止请求在 Turn 尚未绑定时丢失的问题;绑定成功后只发送一次真实 abort,且服务端缺少 abort 能力时不会通过 dispose 会话伪造停止
33
+ - 修复 active bind、terminal SSE 与 React streaming 状态交错时提交锁未释放的问题,并统一 adopt、停止中和 abort-pending 为非致命提示
34
+ - 修复控制台任务导航执行 backend-test 和 frontend-test DAG 时,因结构化验证门禁未识别 pytest/playwright 等命令而始终被阻断的问题
35
+
36
+ ## [0.34.3] - 2026-08-12
37
+
38
+ ### 重点更新
39
+
40
+ - 修复 PRD 导入时 grill-me 模板占位符路径污染写入集的问题,确保仅处理真实需求路径
41
+
42
+ ### 改进
43
+
44
+ - 语义化导入现可作为兜底方案推荐工程边界,提升需求解析的准确性
45
+ - 将语义约束与验证信息合并至控制台草稿,避免表单默认值覆盖精确的验证命令
46
+
47
+ ### 修复
48
+
49
+ - 修复 grill-me 模板占位符路径未被过滤即进入写入集,导致占位内容污染实际工程数据的问题
50
+
5
51
  ## [0.34.2] - 2026-08-12
6
52
 
7
53
  ### 重点更新
@@ -70,36 +116,33 @@
70
116
 
71
117
  ## [0.33.6] - 2026-08-11
72
118
 
119
+ ### 重点更新
120
+
121
+ - 加固 PRD 导入与 Semantic Intake,在结构化解析不足时提供安全回退,并增强 writeSet 审查与运行结果摘要
122
+ - Operator Chat 接入单一事件流、启动 epoch 隔离、实时运行时控制与开放资源加载,提升长会话恢复和自主 DAG 监督能力
123
+ - 统一 Windows 等环境下只读 Git 命令的有界恢复机制,同时保留用户自有的 Pi 模型路由配置
124
+
73
125
  ### 新增
74
126
 
75
- - stabilize PRD intake + add Semantic Intake fallback
76
- - Operator Chat 单事件流 + boot epoch fence(Task C)
77
- - gate writeSet delta + run summary(操作面增强)
78
- - supervise DAG runs autonomously
79
- - ChatEventStore 事件持久化压缩(coalescing + maxBytes 双限制)
80
- - reconcile(cursor_expired) 自动恢复而非全局致命错误
81
- - 接入 Operator Chat 实时运行时控制
82
- - open operator chat resource loading
83
- - enrich execution report and URL resolution
127
+ - task advance 新增 writeSet delta run summary,直接展示边界扩张来源、节点结果和下一步操作
128
+ - Operator Chat 引入单一事件流与 boot epoch fence,支持 cursor 过期后的自动对账恢复,避免将可恢复问题升级为全局错误
129
+ - ChatEventStore 增加事件合并与容量上限双重压缩,控制长期会话持久化体积
130
+ - Operator Chat 新增实时模型、thinking 和运行时资源控制,并按开放资源加载规则解析 Skills、Extensions 与 Packages
131
+ - Operator Chat 可对安全的 bounded DAG 进行持续监督和恢复,执行报告同步补充运行细节与目标 URL 来源
84
132
 
85
133
  ### 改进
86
134
 
87
- - style(task-advance): biome autoformat 对齐(无功能变化)
88
- - style(console): apply biome formatting from pi-lens autoformat
135
+ - 只读 Git 查询统一使用共享韧性执行器,在 Windows 启动型故障下有界恢复,不扩大 mutation 命令权限
136
+ - 初始化与升级流程保留用户自行配置的 Pi 模型路由,不再因默认配置变化机械覆盖
137
+ - Packages 面板区分配置状态与真实安装元数据,并统一使用品牌图标
89
138
 
90
139
  ### 修复
91
140
 
92
- - unify resilient read-only command execution
93
- - preserve user-owned Pi model routing
94
- - show installed package metadata
95
- - escape preflight backtick matcher
96
- - align chat streaming reliability with pi-web
97
- - split rerun defaults and repair selector escaping
98
- - ignore delimiters inside pytest strings
99
- - resolve target URL from runtime context
100
- - reject priority-only module stems
101
- - stop desiredSha256 mechanical checks from blocking generated-content upgrades
102
- - use mobius header brand icon
141
+ - 修复 Operator Chat 流式状态与 pi-web 可靠性机制未完全对齐的问题,提升重连和终态收敛稳定性
142
+ - 修复前端测试环境预检中的反引号转义错误,以及重跑默认值和 repair 选择器转义不一致的问题
143
+ - 修复后端测试完整性检查误判字符串内部未配对分隔符,以及按优先级生成非法单模块文件的问题
144
+ - 修复前端测试目标 URL 未从运行时上下文解析的问题,执行时现使用可审计的真实地址来源
145
+ - 修复 generated init 内容因 `desiredSha256` 机械比较而阻断安全升级的问题
103
146
 
104
147
  ## [0.33.5] - 2026-08-11
105
148
 
@@ -4,6 +4,7 @@ import { createTask, getTaskPaths, loadTaskConfig, } from "../../task/runtime.js
4
4
  import { importPrdDocument } from "../../task/source-references.js";
5
5
  import { prepareTaskSource } from "../../task/source-prepare/index.js";
6
6
  import { isIntakeSoftGapCode, resolvePrdImportRole, } from "../../task/source-prepare/artifact-meta.js";
7
+ import { sanitizeDraftPlaceholders } from "../../task/source-prepare/placeholder-paths.js";
7
8
  import { evaluateSemanticIntakeEligibility, isSemanticIntakeDisabled, readSemanticIntakeAttempted, runSemanticIntake, } from "../../task/source-prepare/semantic-intake.js";
8
9
  import { validateImportedPrdReferences } from "../../task/source-prepare/reference-integrity.js";
9
10
  import { logicalRevisionForState, observeTaskContractState, recoverTaskContract, } from "../../task/contract/index.js";
@@ -587,9 +588,12 @@ export async function advanceTaskLifecycle(input) {
587
588
  if (input.fromDraftPath) {
588
589
  try {
589
590
  const draftRaw = await readFile(path.resolve(input.fromDraftPath), "utf-8");
591
+ const parsedDraft = JSON.parse(draftRaw);
592
+ // Strip Console grill-me placeholder strings so they never become
593
+ // writeSet globs / projected 需求.md bullets.
590
594
  draftIntent = {
591
595
  kind: "draft",
592
- draft: JSON.parse(draftRaw),
596
+ draft: sanitizeDraftPlaceholders(parsedDraft),
593
597
  sourcePath: input.fromDraftPath,
594
598
  };
595
599
  }
@@ -2,6 +2,7 @@ export * from "./types.js";
2
2
  export * from "./path-policy.js";
3
3
  export * from "./parse-intent.js";
4
4
  export * from "./artifact-meta.js";
5
+ export * from "./placeholder-paths.js";
5
6
  export * from "./semantic-intake.js";
6
7
  export * from "./reference-integrity.js";
7
8
  export * from "./completeness.js";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared filters for Console / grill-me placeholder text that must never
3
+ * become writeSet paths or contract scope bullets.
4
+ *
5
+ * Placeholders are Chinese cue-words + fullwidth brackets from interview
6
+ * recommendations (e.g. "(工程边界须人类确认;示例:src/**, test/**)").
7
+ * Do not require glob syntax — legitimate paths like `package.json` must pass.
8
+ */
9
+ export const PLACEHOLDER_PATH_RE = /请|示例|模板|(|)|须人类确认|勿套用|暂无|填写|确认|按.*结构/;
10
+ export function isNonPlaceholderPath(value) {
11
+ return (typeof value === "string" &&
12
+ value.trim().length > 0 &&
13
+ !PLACEHOLDER_PATH_RE.test(value));
14
+ }
15
+ export function filterPlaceholderPaths(values) {
16
+ if (!Array.isArray(values))
17
+ return [];
18
+ return values
19
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
20
+ .filter(isNonPlaceholderPath);
21
+ }
22
+ function isPlaceholderText(value) {
23
+ return typeof value === "string" && PLACEHOLDER_PATH_RE.test(value);
24
+ }
25
+ /**
26
+ * Strip grill-me / form placeholder strings from a draft before prepare.
27
+ * Keeps real defaults like `npm run typecheck` and `.harness/**`.
28
+ */
29
+ export function sanitizeDraftPlaceholders(draft) {
30
+ if (!draft || typeof draft !== "object")
31
+ return draft;
32
+ const root = draft;
33
+ const next = { ...root };
34
+ if (root.constraints && typeof root.constraints === "object") {
35
+ const constraints = root.constraints;
36
+ next.constraints = {
37
+ ...constraints,
38
+ allowedPaths: filterPlaceholderPaths(constraints.allowedPaths),
39
+ forbiddenPaths: filterPlaceholderPaths(constraints.forbiddenPaths),
40
+ };
41
+ }
42
+ if (root.requirement && typeof root.requirement === "object") {
43
+ const requirement = root.requirement;
44
+ let scope = requirement.scope;
45
+ if (Array.isArray(requirement.scope)) {
46
+ scope = requirement.scope
47
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
48
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
49
+ }
50
+ let nonGoals = requirement.nonGoals;
51
+ if (Array.isArray(requirement.nonGoals)) {
52
+ nonGoals = requirement.nonGoals
53
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
54
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
55
+ }
56
+ let acceptanceCriteria = requirement.acceptanceCriteria;
57
+ if (Array.isArray(requirement.acceptanceCriteria)) {
58
+ acceptanceCriteria = requirement.acceptanceCriteria.filter((item) => {
59
+ if (typeof item === "string") {
60
+ return item.trim().length > 0 && !isPlaceholderText(item);
61
+ }
62
+ if (item && typeof item === "object") {
63
+ const text = item.text;
64
+ return typeof text === "string" && !isPlaceholderText(text);
65
+ }
66
+ return true;
67
+ });
68
+ }
69
+ next.requirement = {
70
+ ...requirement,
71
+ scope,
72
+ nonGoals,
73
+ acceptanceCriteria,
74
+ };
75
+ }
76
+ return next;
77
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * P2 Semantic Intake: one-shot, read-only Pi structuring of imported PRDs
3
3
  * into TaskContractDraftV1. Does not write source/需求.md directly — caller
4
- * re-enters prepare with kind:"draft". Engineering paths/verify never come
5
- * from the model; only from flags / existing task config.
4
+ * re-enters prepare with kind:"draft".
5
+ *
6
+ * Engineering boundary priority: explicit flags > existing task config >
7
+ * optional model recommendation (repo-layout-aware fallback for Console
8
+ * web-wizard flows that never collect human paths).
6
9
  */
7
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
8
11
  import path from "node:path";
9
12
  import { z } from "zod";
10
13
  import { executePiStep } from "../../executors/pi-executor.js";
@@ -14,6 +17,7 @@ import { loadHarnessManifest } from "../../governance/harness.js";
14
17
  import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
15
18
  import { getTaskPaths } from "../runtime.js";
16
19
  import { detectProductArtifactMeta } from "./artifact-meta.js";
20
+ import { filterPlaceholderPaths } from "./placeholder-paths.js";
17
21
  export const SEMANTIC_INTAKE_ARTIFACT_REL = "artifacts/intake/semantic-draft.json";
18
22
  export const SEMANTIC_INTAKE_ATTEMPT_MARKER = "artifacts/intake/semantic-intake-attempted.json";
19
23
  const productArtifactBlockers = new Set([
@@ -22,6 +26,20 @@ const productArtifactBlockers = new Set([
22
26
  "PRODUCT_REQUIREMENT_PENDING",
23
27
  "PRODUCT_ARTIFACT_NOT_EXECUTABLE",
24
28
  ]);
29
+ const engineeringRecommendationSchema = z
30
+ .object({
31
+ taskKind: z.string().min(1).optional(),
32
+ allowedPaths: z.array(z.string()).optional(),
33
+ forbiddenPaths: z.array(z.string()).optional(),
34
+ verifyCommands: z
35
+ .array(z.object({
36
+ label: z.string().min(1),
37
+ command: z.string().min(1),
38
+ timeoutMs: z.number().int().positive().optional(),
39
+ }))
40
+ .optional(),
41
+ })
42
+ .optional();
25
43
  const requirementDraftSchema = z.object({
26
44
  objective: z.string().min(1),
27
45
  scope: z.array(z.string()).default([]),
@@ -39,6 +57,8 @@ const requirementDraftSchema = z.object({
39
57
  openQuestions: z.array(z.string()).optional(),
40
58
  assumptions: z.array(z.string()).optional(),
41
59
  title: z.string().optional(),
60
+ /** Optional model engineering recommendation (flags/existing still win). */
61
+ engineering: engineeringRecommendationSchema,
42
62
  });
43
63
  export function isSemanticIntakeDisabled(env = process.env) {
44
64
  const raw = (env.LOOP_AGENT_SEMANTIC_INTAKE ?? "").trim().toLowerCase();
@@ -130,26 +150,62 @@ export function assertSemanticDraftAcceptable(draft) {
130
150
  }
131
151
  }
132
152
  export function buildDraftFromSemanticIntake(input) {
133
- const allowedPaths = input.flags.allowedPaths?.length
134
- ? [
135
- ...(input.existingAllowedPaths ?? []),
136
- ...input.flags.allowedPaths,
137
- ]
138
- : (input.existingAllowedPaths ?? input.flags.allowedPaths ?? []);
139
- const forbiddenPaths = input.flags.forbiddenPaths?.length
140
- ? [
141
- ...(input.existingForbiddenPaths ?? []),
142
- ...input.flags.forbiddenPaths,
143
- ]
144
- : (input.existingForbiddenPaths ?? input.flags.forbiddenPaths ?? []);
145
- const verifyCommands = input.flags.verifyCommands?.length
146
- ? input.flags.verifyCommands
147
- : (input.existingVerify ?? []);
153
+ const modelEng = input.semantic.engineering ?? {};
154
+ const modelAllowed = filterPlaceholderPaths(modelEng.allowedPaths);
155
+ const modelForbidden = filterPlaceholderPaths(modelEng.forbiddenPaths);
156
+ const modelVerify = (modelEng.verifyCommands ?? []).filter((c) => c.label?.trim() && c.command?.trim());
157
+ // Priority: explicit flags > existing task config > model recommendation.
158
+ const flagAllowed = input.flags.allowedPaths ?? [];
159
+ const existingAllowed = input.existingAllowedPaths ?? [];
160
+ let allowedPaths;
161
+ if (flagAllowed.length > 0) {
162
+ allowedPaths = [...existingAllowed, ...flagAllowed];
163
+ }
164
+ else if (existingAllowed.length > 0) {
165
+ allowedPaths = existingAllowed;
166
+ }
167
+ else {
168
+ allowedPaths = modelAllowed;
169
+ }
170
+ const flagForbidden = input.flags.forbiddenPaths ?? [];
171
+ const existingForbidden = input.existingForbiddenPaths ?? [];
172
+ let forbiddenPaths;
173
+ if (flagForbidden.length > 0) {
174
+ forbiddenPaths = [...existingForbidden, ...flagForbidden];
175
+ }
176
+ else if (existingForbidden.length > 0) {
177
+ forbiddenPaths = existingForbidden;
178
+ }
179
+ else {
180
+ forbiddenPaths = modelForbidden;
181
+ }
182
+ let verifyCommands;
183
+ if (input.flags.verifyCommands?.length) {
184
+ verifyCommands = input.flags.verifyCommands;
185
+ }
186
+ else if (input.existingVerify?.length) {
187
+ verifyCommands = input.existingVerify;
188
+ }
189
+ else {
190
+ verifyCommands = modelVerify;
191
+ }
192
+ // flags.taskKind is hard; bare caller "standard" is a soft default and must
193
+ // not block a model recommendation for Console web-wizard flows.
194
+ const explicitTaskKind = input.flags.taskKind?.trim();
195
+ const callerTaskKind = input.taskKind?.trim();
196
+ const modelTaskKind = modelEng.taskKind?.trim();
197
+ const taskKind = explicitTaskKind ||
198
+ (callerTaskKind && callerTaskKind !== "standard"
199
+ ? callerTaskKind
200
+ : undefined) ||
201
+ modelTaskKind ||
202
+ callerTaskKind ||
203
+ "standard";
148
204
  const draft = {
149
205
  schemaVersion: TASK_CONTRACT_DRAFT_SCHEMA_VERSION,
150
206
  taskId: input.taskId,
151
207
  title: input.semantic.title?.trim() || input.title,
152
- taskKind: input.flags.taskKind || input.taskKind || "standard",
208
+ taskKind,
153
209
  requirement: {
154
210
  objective: input.semantic.objective.trim(),
155
211
  scope: input.semantic.scope.map((s) => s.trim()).filter(Boolean),
@@ -161,7 +217,9 @@ export function buildDraftFromSemanticIntake(input) {
161
217
  },
162
218
  constraints: {
163
219
  invariants: input.flags.invariants ?? [],
164
- allowedPaths: [...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean))],
220
+ allowedPaths: [
221
+ ...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean)),
222
+ ],
165
223
  forbiddenPaths: [
166
224
  ...new Set(forbiddenPaths.map((p) => p.trim()).filter(Boolean)),
167
225
  ],
@@ -192,7 +250,96 @@ export function buildDraftFromSemanticIntake(input) {
192
250
  }
193
251
  return draft;
194
252
  }
195
- function buildPrompt(input) {
253
+ /**
254
+ * Cheap repo-layout hint injected into the semantic intake prompt so the model
255
+ * can recommend minimal write boundaries and real verify commands when humans
256
+ * never fill Console form engineering fields.
257
+ */
258
+ export async function buildRepoHint(repoRoot) {
259
+ const lines = ["Repo layout hint (best-effort, may be incomplete):"];
260
+ try {
261
+ const top = await readdir(repoRoot, { withFileTypes: true });
262
+ const dirs = top
263
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
264
+ .map((entry) => entry.name)
265
+ .slice(0, 24);
266
+ const files = top
267
+ .filter((entry) => entry.isFile())
268
+ .map((entry) => entry.name)
269
+ .slice(0, 24);
270
+ lines.push(`top-level dirs: ${dirs.join(", ") || "(none)"}`);
271
+ lines.push(`top-level files: ${files.join(", ") || "(none)"}`);
272
+ const packageCandidates = [
273
+ "package.json",
274
+ path.join("source", "package.json"),
275
+ path.join("packages", "package.json"),
276
+ ];
277
+ for (const rel of packageCandidates) {
278
+ const abs = path.join(repoRoot, rel);
279
+ try {
280
+ const raw = await readFile(abs, "utf-8");
281
+ const pkg = JSON.parse(raw);
282
+ const scripts = Object.keys(pkg.scripts ?? {}).slice(0, 20);
283
+ lines.push(`${rel} scripts: ${scripts.join(", ") || "(none)"}`);
284
+ if (pkg.workspaces !== undefined) {
285
+ lines.push(`${rel} workspaces: ${JSON.stringify(pkg.workspaces).slice(0, 200)}`);
286
+ }
287
+ }
288
+ catch {
289
+ // optional
290
+ }
291
+ }
292
+ // Sample test-like files under common roots (bounded).
293
+ const testRoots = ["src", "source", "packages", "test", "tests"].filter((name) => dirs.includes(name));
294
+ const samples = [];
295
+ const counts = new Map();
296
+ const walk = async (dir, depth) => {
297
+ if (depth > 3 || samples.length >= 15)
298
+ return;
299
+ let entries;
300
+ try {
301
+ entries = await readdir(dir, { withFileTypes: true });
302
+ }
303
+ catch {
304
+ return;
305
+ }
306
+ for (const entry of entries) {
307
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
308
+ continue;
309
+ const abs = path.join(dir, entry.name);
310
+ if (entry.isDirectory()) {
311
+ await walk(abs, depth + 1);
312
+ continue;
313
+ }
314
+ if (!entry.isFile())
315
+ continue;
316
+ if (!/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(entry.name))
317
+ continue;
318
+ const rel = path.relative(repoRoot, abs).replace(/\\/g, "/");
319
+ const top = rel.split("/")[0] ?? rel;
320
+ counts.set(top, (counts.get(top) ?? 0) + 1);
321
+ if (samples.length < 15)
322
+ samples.push(rel);
323
+ }
324
+ };
325
+ for (const root of testRoots) {
326
+ await walk(path.join(repoRoot, root), 0);
327
+ }
328
+ const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
329
+ if (total > 0) {
330
+ const byTop = [...counts.entries()]
331
+ .map(([name, n]) => `${name}:${n}`)
332
+ .join(", ");
333
+ lines.push(`test-like files: total=${total} byTop=${byTop}`);
334
+ lines.push(`test samples: ${samples.join(", ")}`);
335
+ }
336
+ }
337
+ catch {
338
+ lines.push("(repo scan failed)");
339
+ }
340
+ return lines.join("\n");
341
+ }
342
+ async function buildPrompt(input) {
196
343
  const bodies = input.documents
197
344
  .map((doc, index) => {
198
345
  const meta = detectProductArtifactMeta(doc.content);
@@ -213,11 +360,18 @@ function buildPrompt(input) {
213
360
  ].join("\n");
214
361
  })
215
362
  .join("\n\n");
363
+ const repoHint = input.repoRoot
364
+ ? await buildRepoHint(input.repoRoot)
365
+ : "(no repo layout hint)";
216
366
  return [
217
367
  "You are a read-only requirement structurer for loop-agent task intake.",
218
368
  "Convert imported product documents into a thin requirement draft JSON.",
219
369
  "Do NOT invent product behavior that is not supported by the documents.",
220
- "Do NOT emit engineering fields (allowedPaths, forbiddenPaths, verifyCommands, taskKind).",
370
+ "You MAY recommend an optional engineering block when the repo layout hint supports it:",
371
+ "- allowedPaths / forbiddenPaths as minimal precise globs (e.g. source/packages/**/*.test.ts)",
372
+ "- verifyCommands as real package scripts (e.g. pnpm -C source test)",
373
+ "- taskKind when clearly implied (backend-test / frontend-test / standard / …)",
374
+ "Prefer smallest correct write boundary; do not invent product paths that are not in the layout hint.",
221
375
  "Do NOT rewrite or suggest editing the original PRD files.",
222
376
  "Prefer explicit text; use provenance derived for rephrasing; use inferred only when clearly implied — if you must invent ACs, leave acceptanceCriteria empty so the system can stop for clarification.",
223
377
  "Return exactly one JSON object, no markdown prose outside optional fence:",
@@ -235,9 +389,18 @@ function buildPrompt(input) {
235
389
  ],
236
390
  openQuestions: ["optional"],
237
391
  assumptions: ["optional"],
392
+ engineering: {
393
+ taskKind: "optional string",
394
+ allowedPaths: ["optional globs"],
395
+ forbiddenPaths: ["optional globs"],
396
+ verifyCommands: [
397
+ { label: "test", command: "pnpm -C source test" },
398
+ ],
399
+ },
238
400
  }, null, 2),
239
401
  `taskId: ${input.taskId}`,
240
402
  `fallbackTitle: ${input.title}`,
403
+ repoHint,
241
404
  "Documents:",
242
405
  bodies,
243
406
  ].join("\n\n");
@@ -314,14 +477,16 @@ export async function runSemanticIntake(input) {
314
477
  const attached = usableDocs
315
478
  .map((d) => d.absolutePath)
316
479
  .filter((p) => Boolean(p));
480
+ const prompt = await buildPrompt({
481
+ taskId: input.taskId,
482
+ title: input.title,
483
+ documents: usableDocs,
484
+ repoRoot: input.repoRoot,
485
+ });
317
486
  const result = await execute({
318
487
  attachedFiles: attached,
319
488
  modelConfig: resolveDagPiModelConfig(model),
320
- prompt: buildPrompt({
321
- taskId: input.taskId,
322
- title: input.title,
323
- documents: usableDocs,
324
- }),
489
+ prompt,
325
490
  repoRoot: input.repoRoot,
326
491
  step: "analyze",
327
492
  toolNames: ["read", "grep", "find", "ls"],
@@ -43,6 +43,7 @@ export function openConsoleAppData(options) {
43
43
  chats: path.join(repoNamespace, "chats"),
44
44
  chatSessions: path.join(repoNamespace, "chat-sessions"),
45
45
  chatEvents: path.join(repoNamespace, "chat-events"),
46
+ resourcePreferences: path.join(repoNamespace, "resource-preferences.json"),
46
47
  };
47
48
  for (const dir of [
48
49
  paths.drafts,
@@ -55,6 +56,7 @@ export function openConsoleAppData(options) {
55
56
  paths.chats,
56
57
  paths.chatSessions,
57
58
  paths.chatEvents,
59
+ path.dirname(paths.resourcePreferences),
58
60
  ]) {
59
61
  ensureSecureDir(dir);
60
62
  }