@tea-agent/loop-agent 0.16.19 → 0.16.20

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 (30) hide show
  1. package/CHANGELOG.md +22 -5
  2. package/README.md +8 -0
  3. package/dist/cli/command-definitions.js +4 -2
  4. package/dist/cli/program.js +2 -1
  5. package/dist/cli/update/init-surface-notifier.js +167 -0
  6. package/dist/cli/update/policy.js +36 -1
  7. package/dist/cli/update/runtime-activity.js +29 -0
  8. package/dist/cli.js +14 -2
  9. package/dist/commands/init.js +85 -2
  10. package/dist/executors/shell-executor.js +92 -66
  11. package/dist/executors/shell-write-guard.js +5 -0
  12. package/dist/shared/runtime-activity.js +6 -0
  13. package/dist/worker/observability/read-model.js +10 -7
  14. package/dist/worker/observe/static/views/session-timeline.js +1 -1
  15. package/dist/workflows/dag/backend-test-case-manifest.js +13 -3
  16. package/dist/workflows/dag/backend-test-classification-contract.js +38 -0
  17. package/dist/workflows/dag/backend-test-contract-envelope.js +167 -0
  18. package/dist/workflows/dag/backend-test-semantic-review-contract.js +2 -2
  19. package/dist/workflows/dag/init-hybrid.js +47 -408
  20. package/dist/workflows/dag/node-execution.js +4 -3
  21. package/dist/workflows/dag/types.js +1 -5
  22. package/docs/README.md +1 -0
  23. package/docs/templates/agent-dag.schema.json +1 -1
  24. package/docs/templates/backend-test-case-manifest.schema.json +35 -2
  25. package/docs/templates/backend-test-dag.json +39 -340
  26. package/docs/templates/backend-test-dag.review-cases.prompt.md +4 -4
  27. package/package.json +1 -1
  28. package/skills/loop-agent/references/command-reference.md +4 -0
  29. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  30. package/dist/workflows/dag/backend-test-repair-contract.js +0 -94
package/CHANGELOG.md CHANGED
@@ -2,27 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### 修复
6
+
7
+ - Backend-test 节点 10 `backend-test-semantic-gate-shell` 不再用 Markdown `first-verdict-line` 读取 raw Pi 输出;改为同一 `semantic-initial` pipeline 按 task id 只读 `contracts/backend-test-semantic-review.json`,仅 `verdict=pass` 放行 pytest,`request-revision`/缺失/malformed 清晰 fail-closed;节点 09 仍只负责 materialize+trace,15 节点与单次 pytest 不变。
8
+ - 公开 Case Manifest schema(`docs/templates/backend-test-case-manifest.schema.json`)对齐 materializer dual-scope coverage:`coverageSummary` 在 `additionalProperties:false` 下 required 并声明 `global`/`inScope`(`total`/`covered`/`gapped`/`ratio`)与 `crossDomainGapCount`;回归测试以无依赖轻量 contract 锁定 schema↔canonical 一致性;不改 materializer/15 节点。
9
+ - Backend-test Case Manifest materializer 将模型顶层 `coverageSummary` 视为非权威:secret 扫描后剥离,再 strict parse 其余字段,并始终 `computeCaseManifestCoverageSummary` 写入 canonical summary;my-webapp 自定义/部分 summary 不再导致 invalid-output;prompt 禁止模型输出 coverageSummary;15 节点与单次 pytest 不变。
10
+ - Backend-test 裸 object 路径现消费 scanner 的 `unclosedObject`:当文本含一个完整裸 JSON object,且其前或后仍有未闭合裸 object 时一律 fail-closed,不再因 `spans.length === 1` 错误接受完整对象;Semantic Review / Classification / contracts envelope 共用该行为。
11
+ - Backend-test contracts composite pipeline (`validate-backend-test-contracts-shell`) 使用唯一确定性 envelope 提取:接受 pure JSON、唯一 fenced json、prose+唯一 fence、prose+唯一括号平衡裸 object;对无 JSON、多候选、未闭合、非 object、缺 `analysis`/`execution` fail-closed。提取后仍走 Analysis v2 / Execution v1 schema、source-binding 与 secret 门禁;不引入全局 Pi output rewrite,不将 `invalid-output` 加入自动 retry,不改 15 节点拓扑。
12
+ - Backend-test Semantic Review(08→09)与 Classification(12→13)materializer 复用同一 `extractUniqueJsonObject` scanner:接受 pure / whole-fence / prose+唯一 fence / prose+唯一裸 object;多候选、未闭合、非 object 与 strict schema 错误继续 fail-closed;不改 Case Manifest、Pi SDK/retry 或 15 节点拓扑。
13
+
5
14
  ### 改进
6
15
 
7
16
  - 前端测试 DAG 收紧体验:execution preflight 仅硬校验绝对非生产 `baseUrl`;用例 map 缩短为优先用 `playwright-cli` 执行;复盘合并执行证据审查且不依赖 outcome=pass,失败也能出报告。
8
17
  - 前端测试用例生成现在会解析明确 `baseUrl`:优先读取任务源 `config.md` 中的前端 URL,缺失时默认 `http://localhost:5173`,并写入 RAG `context.md`;生成命令不得再保留 `<base-url>` 占位符。
9
18
  - 前端浏览器测试 DAG 的复盘报告现写入 `testcase/frontend/reports/**`,不再要求 `docs/test-reports/**` 权限;任务执行约束可以安全禁止整个 `docs/**`,同时仍保留可审计的测试资产。
10
19
  - `frontend-test` 结果链新增 run-owned `frontend-test-result-v1`、单次用例修订与终审门禁;只有所有浏览器用例和 AC 覆盖通过且结果合同明确为 real/pass 时,Worker 才会投影真实集成。
11
-
12
- - 后端测试 DAG 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
13
- - Backend Test Analysis、Case Manifest、Semantic Review Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
20
+ - 后端测试 DAG 从 24 个收敛为 15 个真实顶层节点;用例与语义 `request-revision` 由确定性 gate 直接失败,pytest 只执行一次,TestBug 保留分类并转为独立 follow-up,不再触发同 run repair 或 final rerun。唯一 initial Result 会确定性复制为 canonical `contracts/backend-test-result.json`,最终 outcome 只认该结构化事实。
21
+ - Backend Test Analysis、Case Manifest、Semantic Review Classification 的严格 JSON 契约进一步对齐,生成节点明确字段白名单、`sourceBinding` evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
22
+ - 新增升级后 init surface notifier:普通安全仓库命令结束后,当目标项目存在有效 `.harness/init-surface.json` `controllerVersion` 与当前包版本不同时自动检测。surface 缺失/损坏、源仓库、未初始化目录与版本一致时直接跳过;`checkInitUpdate().ok` 时仅在无活跃运行时静默刷新 state;非 TTY 只向 stderr 输出 `loop-agent init reconcile --repo-root ...` 提示且不写入目标;TTY 且无 human decisions、无活跃 DAG/Worker 时可经明确 `y/yes` 同意后应用 deterministic safe actions;只有 model merge 时直接输出有边界指引,不询问“应用 0 个动作”。notifier 遵循全局 `--repo-root`、不写 stdout、不改变原命令退出码,CI、禁用环境变量、help/version、`--json`/`--markdown`、`init`、`run-dag`、`dag`、`loop`、`delegate`、`pi-prompt`、`cursor-prompt` 均跳过。
23
+ - 新增统一命令 `loop-agent init reconcile --repo-root <target>`:surface 缺失返回 `needs-baseline` 且零写入,存在 human decisions 返回 `needs-human-decision` 且零写入,活跃 DAG/Worker 或 Worker 状态无法确认时返回 `blocked-active-runtime` 且零写入;其余情况复用现有 `applyInitUpdate({ applySafe: true })` 并复查返回 `clean`/`needs-model-merge`/`needs-safe-update`,支持 `--json`/`--markdown`。
14
24
 
15
25
  ### 修复
16
26
 
27
+ - 节点检查器「执行过程」中的事件时间戳现在同时显示本地日期与 24 小时制 `HH:mm:ss`(例如 `2026/7/16 08:09:10`),不再被裁掉时间部分;缺少时间戳时仍显示「未记录时间」,事件字段优先级(`timestamp` → `at` → `recordedAt`)保持不变。
17
28
  - `frontend-test` 的 case review 现在是 fail-closed browser gate:只有 `VERDICT: pass` 才能物化 manifest 并启动动态 browser map;生成的 case 固定使用默认 browser session,要求每个子场景的 fixture/UI reset 和 fresh snapshot,并由确定性节点校验每个 case 的 `execution.md`、`case-result.json`、`caseId`、`status`、`evidencePaths` 及 blocked `blockedReason`。
18
29
  - 前端实现计划/修订节点会注入当前包内权威 `frontend-implementation-contract-v1` Schema 与固定 source binding,避免模型猜测字段导致契约门禁失败。
19
30
  - 前端 Mock 策略节点的 canonical 输出会把首条 `MOCK_STRATEGY:` 协议行提升为第一行,避免解释性前言触发 `first-non-empty` 门禁误判。
20
31
  - 前端规范回退目录统一为本地 `openspec/`,DAG 能力发现、提示词、Skill 与验证证据检查不再查找大小写不一致的旧目录名。
21
32
  - 后端测试复合 Shell pipeline 现在与普通 Shell 节点共享 Git write guard;即使命令退出成功,只要越过 `read-only`、`allowedPaths` 或 `forbiddenPaths` 边界,节点仍会 fail-closed。
22
- - Observe 现使用实际的 repair safety 节点,并只在修复节点真正开始执行后计入一次 attempt;条件跳过不再误报已修复,安全门禁失败会显示为 `rejected`。
33
+ - Observe 对新 backend-test run 不再伪造 repair attempt/status,只投影 single-run/canonical Result、分类与 coverage;历史 24 节点 run 的 eligibility、repair、final 和 effective evidence 仍可兼容读取。
23
34
  - Pi SDK 对缺少响应 ID 的累计 Token 生命周期事件改为取本次执行最大快照,避免同一响应的匿名 usage 被重复累加。
24
35
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
25
- - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为 initial/final Result、repair eligibility、traceability 与 Observe 投影保留结构化运行证据。
36
+ - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为唯一 JUnit/initial Result、canonical Result、traceability 与 Observe 投影保留结构化运行证据。
37
+
38
+ ## [0.16.20] - 2026-07-21
39
+
40
+ ### 修复
41
+
42
+ - Shell write-guard 忽略 playwright-cli 默认会话落盘 `.playwright-cli/**`(console/page dump)。frontend-test 浏览器用例已把权威证据写到 `testcase/frontend/evidence/**` 时,不再因工具缓存误拦 exclusive writer。
26
43
 
27
44
  ## [0.16.19] - 2026-07-20
28
45
 
package/README.md CHANGED
@@ -40,6 +40,14 @@ loop-agent inspect --repo-root .
40
40
 
41
41
  初始化会保留已有用户内容,并补充 `AGENTS.md`、`harness.json`、`ai_workspace/loop-agent/`、`.agents/skills/`、`.harness/` 和验证脚本等治理入口。
42
42
 
43
+ 控制器升级后,旧项目可能仍使用过期的 repo-local skills、模板、managed blocks 和治理说明。普通安全仓库命令结束时会自动检测目标项目是否需要对齐:非 TTY 只向 stderr 输出提示且不写入目标;TTY 且无 human decisions、无活跃 DAG/Worker 时,可经明确 `y/yes` 同意后应用 deterministic safe actions;也可手动运行统一入口:
44
+
45
+ ```bash
46
+ loop-agent init reconcile --repo-root .
47
+ ```
48
+
49
+ `init reconcile` 在 surface 缺失、存在 human decisions、存在活跃 DAG/Worker 或无法确认 Worker 状态时零写入,并返回 `needs-baseline`、`needs-human-decision` 或 `blocked-active-runtime`;其余情况复用 `init update --apply-safe` 的安全动作并复查返回 `clean`、`needs-model-merge` 或 `needs-safe-update`。
50
+
43
51
  完整说明见[初始化目标项目](website/docs/quick-start/init-target-project.md)。
44
52
 
45
53
  ### 运行第一个任务
@@ -22,6 +22,7 @@ import { runWorktreeRemove } from "../commands/worktree-remove.js";
22
22
  import { runDelegate } from "../commands/delegate.js";
23
23
  import { runHarvest } from "../commands/harvest.js";
24
24
  import { runInit } from "../commands/init.js";
25
+ import { readInitRuntimeActivity } from "./update/runtime-activity.js";
25
26
  import { runRunDag } from "../commands/run-dag.js";
26
27
  import { runDagInitHybrid } from "../commands/dag-init-hybrid.js";
27
28
  import { runDagValidate } from "../commands/dag-validate.js";
@@ -75,6 +76,7 @@ const INIT_SUBCOMMANDS = [
75
76
  "doctor",
76
77
  "check-update",
77
78
  "update",
79
+ "reconcile",
78
80
  ];
79
81
  const EXAMPLES_SUBCOMMANDS = ["list", "show", "copy"];
80
82
  const EVAL_SUBCOMMANDS = [
@@ -195,10 +197,10 @@ export const COMMAND_DEFINITIONS = [
195
197
  adapter: "none",
196
198
  tier: "primary",
197
199
  intent: "Initialize a target repository with loop-agent harness capabilities.",
198
- usage: "init [instructions|doctor|check-update|update] [--profile full|minimal] [--merge] [--json|--markdown] [--bootstrap-surface|--apply-safe]",
200
+ usage: "init [instructions|doctor|check-update|update|reconcile] [--profile full|minimal] [--merge] [--json|--markdown] [--bootstrap-surface|--apply-safe]",
199
201
  subcommands: [...INIT_SUBCOMMANDS],
200
202
  handler: async ({ repoRoot, subcommand, rest }) => {
201
- await runInit(repoRoot, [subcommand, ...rest].filter(Boolean));
203
+ await runInit(repoRoot, [subcommand, ...rest].filter(Boolean), { readRuntimeActivity: readInitRuntimeActivity });
202
204
  },
203
205
  },
204
206
  {
@@ -29,6 +29,7 @@ import { runGoal } from "../commands/goal.js";
29
29
  import { runHandoffCheck } from "../commands/handoff-check.js";
30
30
  import { runHarvest } from "../commands/harvest.js";
31
31
  import { runInit } from "../commands/init.js";
32
+ import { readInitRuntimeActivity } from "./update/runtime-activity.js";
32
33
  import { runInspect } from "../commands/inspect.js";
33
34
  import { runInstructions } from "../commands/instructions.js";
34
35
  import { runKnowledge } from "../commands/knowledge.js";
@@ -518,7 +519,7 @@ async function runPiPromptCommand(args) {
518
519
  async function runInitCommand(args, command, defaultRepoRoot) {
519
520
  const initArgs = commandArgsWithOptions(args, command);
520
521
  const quiet = initArgs.includes("--json") || initArgs.includes("--markdown");
521
- await runInit(resolveRepoRootOnly(command, defaultRepoRoot, { quiet }), initArgs);
522
+ await runInit(resolveRepoRootOnly(command, defaultRepoRoot, { quiet }), initArgs, { readRuntimeActivity: readInitRuntimeActivity });
522
523
  }
523
524
  async function runExamplesCommand(args, command, defaultRepoRoot) {
524
525
  await runExamples(resolveRepoRootOnly(command, defaultRepoRoot), commandArgsWithOptions(args, command));
@@ -0,0 +1,167 @@
1
+ import { applyInitUpdate, checkInitUpdate, readRecordedSurfaceControllerVersion, } from "../../commands/init.js";
2
+ import path from "node:path";
3
+ import { isInitRuntimeActive, } from "../../shared/runtime-activity.js";
4
+ import { shouldRunInitSurfaceCheck } from "./policy.js";
5
+ import { readInitRuntimeActivity } from "./runtime-activity.js";
6
+ /**
7
+ * Post-upgrade init surface notifier.
8
+ *
9
+ * Runs only after a normal successful CLI invocation when:
10
+ * - the invocation is not a controller-sensitive / machine-output / `init`
11
+ * command (see {@link shouldRunInitSurfaceCheck}), and
12
+ * - the target repo records a valid `.harness/init-surface.json` whose
13
+ * `controllerVersion` differs from the currently running controller.
14
+ *
15
+ * It never writes to stdout and never changes the host command's exit code.
16
+ * In non-TTY contexts it only emits a stderr pointer to
17
+ * `loop-agent init reconcile --repo-root ...` and writes nothing to the target.
18
+ * In TTY contexts with deterministic actions, no pending human decisions, and
19
+ * no active DAG/Worker runtime, it may ask for an explicit `y/yes` before
20
+ * applying safe actions. Human decisions and active runtimes block all writes.
21
+ */
22
+ export async function runInitSurfaceNotifier(options) {
23
+ if (!shouldRunInitSurfaceCheck(options))
24
+ return;
25
+ // Purity contract: the notifier must never pollute machine stdout.
26
+ void options.writeStdout;
27
+ const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
28
+ try {
29
+ const recorded = await readRecordedSurfaceControllerVersion(options.repoRoot);
30
+ if (!recorded)
31
+ return; // missing / corrupt / uninitialized -> skip silently
32
+ if (recorded.controllerVersion === options.currentVersion)
33
+ return; // already aligned
34
+ const report = await checkInitUpdate({
35
+ repoRoot: options.repoRoot,
36
+ projectName: options.projectName,
37
+ governanceRoot: options.governanceRoot,
38
+ });
39
+ const readRuntime = options.readRuntimeActivity ?? readInitRuntimeActivity;
40
+ // Already aligned to a clean surface; silently refresh recorded state only
41
+ // when no DAG or Worker runtime can observe the state write.
42
+ if (report.ok) {
43
+ const activity = await readRuntime(options.repoRoot);
44
+ if (isInitRuntimeActive(activity)) {
45
+ writeStderr(buildActiveRuntimeNotice(options.repoRoot, activity));
46
+ return;
47
+ }
48
+ await applyInitUpdate({
49
+ repoRoot: options.repoRoot,
50
+ projectName: options.projectName,
51
+ governanceRoot: options.governanceRoot,
52
+ applySafe: true,
53
+ }).catch(() => undefined);
54
+ return;
55
+ }
56
+ const hasHumanDecisions = report.humanDecisions.length > 0;
57
+ if (hasHumanDecisions) {
58
+ writeStderr(buildHumanDecisionNotice(options.repoRoot, report.humanDecisions.length));
59
+ return;
60
+ }
61
+ if (report.deterministicActions.length === 0) {
62
+ writeStderr(buildRemainingNotice(report, options.repoRoot));
63
+ return;
64
+ }
65
+ if (!options.stdoutIsTTY || !options.stdinIsTTY || !options.stderrIsTTY) {
66
+ writeStderr(buildReconcileNotice(options.repoRoot));
67
+ return;
68
+ }
69
+ const activity = await readRuntime(options.repoRoot);
70
+ if (isInitRuntimeActive(activity)) {
71
+ writeStderr(buildActiveRuntimeNotice(options.repoRoot, activity));
72
+ return;
73
+ }
74
+ const prompt = `loop-agent 控制器版本已变化(${recorded.controllerVersion} → ${options.currentVersion}),` +
75
+ `目标项目 init surface 有 ${report.deterministicActions.length} 个待处理安全动作。\n` +
76
+ `现在应用 deterministic safe actions?[y/N] `;
77
+ const answer = await ask(options, prompt);
78
+ if (!isYes(answer)) {
79
+ writeStderr(buildReconcileNotice(options.repoRoot));
80
+ return;
81
+ }
82
+ const update = await applyInitUpdate({
83
+ repoRoot: options.repoRoot,
84
+ projectName: options.projectName,
85
+ governanceRoot: options.governanceRoot,
86
+ applySafe: true,
87
+ });
88
+ writeStderr(`loop-agent init surface safe actions applied to ${options.repoRoot}.\n` +
89
+ buildRemainingNotice(update.report, options.repoRoot));
90
+ }
91
+ catch (error) {
92
+ writeStderr(`loop-agent init surface check skipped: ${error instanceof Error ? error.message : String(error)}\n`);
93
+ }
94
+ }
95
+ function buildReconcileNotice(repoRoot) {
96
+ return (`loop-agent init surface needs reconciliation at ${repoRoot}.\n` +
97
+ `Run: loop-agent init reconcile --repo-root ${repoRoot}\n`);
98
+ }
99
+ function buildHumanDecisionNotice(repoRoot, count) {
100
+ return (`loop-agent init surface has ${count} pending human decision(s) at ${repoRoot}.\n` +
101
+ `Run: loop-agent init reconcile --repo-root ${repoRoot}\n`);
102
+ }
103
+ function buildRemainingNotice(report, repoRoot) {
104
+ const lines = [];
105
+ if (report.modelMergeTasks.length > 0) {
106
+ lines.push(`Remaining model merge tasks: ${report.modelMergeTasks.length}. Run: loop-agent init check-update --repo-root ${repoRoot} --markdown`);
107
+ }
108
+ if (report.deterministicActions.length > 0) {
109
+ lines.push(`Remaining deterministic actions: ${report.deterministicActions.length}. Run: loop-agent init reconcile --repo-root ${repoRoot}`);
110
+ }
111
+ if (lines.length === 0)
112
+ lines.push("init surface is now clean.");
113
+ return lines.join("\n") + "\n";
114
+ }
115
+ function buildActiveRuntimeNotice(repoRoot, activity) {
116
+ const details = [
117
+ activity.activeDagRunIds.length > 0
118
+ ? `active DAG runs: ${activity.activeDagRunIds.join(", ")}`
119
+ : undefined,
120
+ activity.activeWorkerTasks > 0
121
+ ? `active Worker tasks: ${activity.activeWorkerTasks}`
122
+ : undefined,
123
+ activity.activeWorkerBatches > 0
124
+ ? `active Worker batches: ${activity.activeWorkerBatches}`
125
+ : undefined,
126
+ activity.workerProjectionError
127
+ ? `Worker activity unavailable: ${activity.workerProjectionError}`
128
+ : undefined,
129
+ ].filter((value) => Boolean(value));
130
+ return (`loop-agent init reconcile blocked-active-runtime at ${repoRoot}.\n` +
131
+ `${details.join("; ")}\n` +
132
+ "Wait for the active runtime to finish, then run: " +
133
+ `loop-agent init reconcile --repo-root ${repoRoot}\n`);
134
+ }
135
+ export function resolveInitSurfaceRepoRoot(argv, defaultRepoRoot) {
136
+ let selected = defaultRepoRoot;
137
+ for (let index = 0; index < argv.length; index += 1) {
138
+ const arg = argv[index];
139
+ if (arg === "--repo-root") {
140
+ const value = argv[index + 1];
141
+ if (value && !value.startsWith("-"))
142
+ selected = value;
143
+ index += 1;
144
+ }
145
+ else if (arg.startsWith("--repo-root=")) {
146
+ selected = arg.slice("--repo-root=".length) || selected;
147
+ }
148
+ }
149
+ return path.resolve(defaultRepoRoot, selected);
150
+ }
151
+ async function ask(options, prompt) {
152
+ if (options.readLine) {
153
+ options.writeStderr?.(prompt);
154
+ return options.readLine(prompt);
155
+ }
156
+ const { createInterface } = await import("node:readline/promises");
157
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
158
+ try {
159
+ return await rl.question(prompt);
160
+ }
161
+ finally {
162
+ rl.close();
163
+ }
164
+ }
165
+ function isYes(answer) {
166
+ return /^(y|yes)$/i.test(answer.trim());
167
+ }
@@ -8,6 +8,7 @@ const CONTROLLER_SENSITIVE_COMMANDS = new Set([
8
8
  "cursor-prompt",
9
9
  ]);
10
10
  const MACHINE_OUTPUT_FLAGS = new Set(["--json", "--markdown"]);
11
+ const GLOBAL_VALUE_FLAGS = new Set(["--repo-root", "--adapter", "--worktree"]);
11
12
  export function isStrictReleaseVersion(version) {
12
13
  if (!version)
13
14
  return false;
@@ -30,6 +31,35 @@ export function shouldCheckForUpdates(input) {
30
31
  return false;
31
32
  return true;
32
33
  }
34
+ /**
35
+ * Eligibility predicate for the post-upgrade init surface notifier.
36
+ *
37
+ * Mirrors {@link shouldCheckForUpdates}'s skip conditions (CI, disable env,
38
+ * help/version, machine-output flags, controller-sensitive commands) and
39
+ * additionally skips the `init` command family itself so the notifier never
40
+ * re-enters the explicit init surface write path.
41
+ *
42
+ * Unlike {@link shouldCheckForUpdates}, this predicate does NOT require a TTY:
43
+ * non-interactive invocations are still eligible because the notifier emits a
44
+ * read-only stderr pointer (zero target writes) in non-TTY contexts. The TTY
45
+ * distinction is handled inside the notifier.
46
+ */
47
+ export function shouldRunInitSurfaceCheck(input) {
48
+ if (input.env.CI || input.env.LOOP_AGENT_DISABLE_UPDATE_CHECK === "1")
49
+ return false;
50
+ const args = input.argv;
51
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) {
52
+ return false;
53
+ }
54
+ if (args.some((arg) => MACHINE_OUTPUT_FLAGS.has(arg)))
55
+ return false;
56
+ const command = firstCommand(args);
57
+ if (command === "init")
58
+ return false;
59
+ if (command && CONTROLLER_SENSITIVE_COMMANDS.has(command))
60
+ return false;
61
+ return true;
62
+ }
33
63
  export function evaluateLatestVersion(input) {
34
64
  const now = input.now ?? new Date();
35
65
  if (input.retryAfter && Date.parse(input.retryAfter) > now.getTime()) {
@@ -50,7 +80,12 @@ export function evaluateLatestVersion(input) {
50
80
  return { shouldPrompt: true, targetVersion: input.latestVersion };
51
81
  }
52
82
  function firstCommand(argv) {
53
- for (const arg of argv) {
83
+ for (let index = 0; index < argv.length; index += 1) {
84
+ const arg = argv[index];
85
+ if (GLOBAL_VALUE_FLAGS.has(arg)) {
86
+ index += 1;
87
+ continue;
88
+ }
54
89
  if (!arg.startsWith("-"))
55
90
  return arg;
56
91
  }
@@ -0,0 +1,29 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildGlobalSnapshot } from "../../worker/observability/read-model.js";
4
+ /**
5
+ * Read-only runtime guard for init-surface writes.
6
+ *
7
+ * DAG activeness is anchored to real directories in the canonical active
8
+ * lifecycle root. Worker activeness reuses the Observe read model so Task Pool
9
+ * status precedence and composite identity stay owned by the Worker adapter.
10
+ * Projection failures block writes because liveness cannot be proven safely.
11
+ */
12
+ export async function readInitRuntimeActivity(repoRoot) {
13
+ const resolvedRoot = path.resolve(repoRoot);
14
+ const activeDagRoot = path.join(resolvedRoot, ".harness", "dag-runs", "active");
15
+ const entries = await readdir(activeDagRoot, { withFileTypes: true }).catch(() => []);
16
+ const activeDagRunIds = entries
17
+ .filter((entry) => entry.isDirectory() && entry.name !== ".gitkeep")
18
+ .map((entry) => entry.name)
19
+ .sort();
20
+ const snapshot = await buildGlobalSnapshot({ repoRoot: resolvedRoot });
21
+ return {
22
+ activeDagRunIds,
23
+ activeWorkerBatches: snapshot.health.worker.activeBatches,
24
+ activeWorkerTasks: snapshot.health.worker.activeTasks,
25
+ ...(snapshot.projectionError
26
+ ? { workerProjectionError: snapshot.projectionError.message }
27
+ : {}),
28
+ };
29
+ }
package/dist/cli.js CHANGED
@@ -6,9 +6,12 @@ import { fileURLToPath } from "node:url";
6
6
  import { buildLoopAgentProgram } from "./cli/program.js";
7
7
  import { createNpmUpdateClient } from "./cli/update/npm-client.js";
8
8
  import { runSelfUpdateNotifier } from "./cli/update/notifier.js";
9
+ import { resolveInitSurfaceRepoRoot, runInitSurfaceNotifier, } from "./cli/update/init-surface-notifier.js";
9
10
  async function main() {
11
+ const defaultRepoRoot = process.cwd();
12
+ const argv = process.argv.slice(2);
10
13
  const program = buildLoopAgentProgram({
11
- defaultRepoRoot: process.cwd(),
14
+ defaultRepoRoot,
12
15
  });
13
16
  await program.parseAsync(process.argv);
14
17
  if (process.exitCode && process.exitCode !== 0)
@@ -16,7 +19,7 @@ async function main() {
16
19
  const packageRoot = resolvePackageRoot();
17
20
  const currentVersion = readPackageVersion(packageRoot);
18
21
  await runSelfUpdateNotifier({
19
- argv: process.argv.slice(2),
22
+ argv,
20
23
  currentVersion,
21
24
  env: process.env,
22
25
  homeDir: os.homedir(),
@@ -29,6 +32,15 @@ async function main() {
29
32
  env: process.env,
30
33
  }),
31
34
  });
35
+ await runInitSurfaceNotifier({
36
+ argv,
37
+ currentVersion,
38
+ env: process.env,
39
+ repoRoot: resolveInitSurfaceRepoRoot(argv, defaultRepoRoot),
40
+ stdinIsTTY: process.stdin.isTTY === true,
41
+ stdoutIsTTY: process.stdout.isTTY === true,
42
+ stderrIsTTY: process.stderr.isTTY === true,
43
+ });
32
44
  }
33
45
  main().catch((error) => {
34
46
  console.error(error instanceof Error ? error.message : String(error));
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { copyDir } from "../shared/copy-dir.js";
7
+ import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
7
8
  import { loadHarnessManifest } from "../governance/harness.js";
8
9
  const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
9
10
  const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
@@ -687,6 +688,7 @@ function buildManagedAgentsBlock(input) {
687
688
  "|---|---|---|",
688
689
  "| loop-agent 初始化 / loop agent 初始化 / loop agent初始化 / 初始化 loop-agent | 初始化 | 完成确定性初始化闭环 |",
689
690
  "| 初始化更新校验 / 检查初始化更新 / loop-agent 初始化更新校验 / loop agent初始化更新校验 | 更新校验 | 只读报告,不写入 |",
691
+ "| 初始化对齐 / 升级后对齐 / reconcile 初始化 / loop-agent 初始化对齐 | 升级对齐 | 自动应用确定性安全动作;活跃运行或人工决策时零写入 |",
690
692
  "| 初始化安全更新 / 应用初始化更新 / loop-agent 初始化安全更新 / loop agent初始化安全更新 | 安全更新 | check-update 后只执行确定性安全动作 |",
691
693
  "",
692
694
  "**更新校验(只读)**:用户说“初始化更新校验”“检查初始化更新”时,只读执行下面命令,汇报 deterministic actions、model merge、human decisions 和 next steps。**这一步不得自动执行 `apply-safe` 或模型合并。**",
@@ -695,6 +697,8 @@ function buildManagedAgentsBlock(input) {
695
697
  "loop-agent init check-update --repo-root . --markdown",
696
698
  "```",
697
699
  "",
700
+ "**升级对齐(写入型统一入口)**:用户说“初始化对齐”“升级后对齐”时,执行 `loop-agent init reconcile --repo-root .`。该入口不自动 bootstrap surface、不自动模型合并;存在活跃 DAG/Worker、human decisions 或无法确认 Worker 状态时必须零写入并报告状态。",
701
+ "",
698
702
  "**安全更新(写入型)**:用户明确说“初始化安全更新”“应用初始化更新”时,先 check-update,再执行确定性安全动作;surface 缺失时先 bootstrap,存在 human decisions 时停下等用户决策。model merge 任务必须带 allowed/forbidden paths,且不得自动决策。安全更新完成后必须复查 `loop-agent init doctor --repo-root .`、`loop-agent inspect --repo-root .`、`loop-agent docs audit --repo-root .` 与目标项目治理检查(如 `bash scripts/check-repo.sh`)。",
699
703
  "",
700
704
  "```bash",
@@ -1277,6 +1281,26 @@ async function readExistingSurfaceState(repoRoot) {
1277
1281
  return undefined;
1278
1282
  return parsed;
1279
1283
  }
1284
+ /**
1285
+ * Lightweight read-only preflight for the post-upgrade init surface notifier.
1286
+ *
1287
+ * Returns the recorded controller version and state kind only when a valid
1288
+ * `.harness/init-surface.json` exists. Missing or corrupt state, the source
1289
+ * repo, and uninitialized directories all resolve to `undefined`, so the
1290
+ * notifier can cheaply skip without running a full surface diff.
1291
+ */
1292
+ export async function readRecordedSurfaceControllerVersion(repoRoot) {
1293
+ let state;
1294
+ try {
1295
+ state = await readExistingSurfaceState(repoRoot);
1296
+ }
1297
+ catch {
1298
+ return undefined;
1299
+ }
1300
+ if (!state || typeof state.controllerVersion !== "string")
1301
+ return undefined;
1302
+ return { controllerVersion: state.controllerVersion, stateKind: state.stateKind };
1303
+ }
1280
1304
  async function buildCurrentSurfaceState(input) {
1281
1305
  const assetRoot = await findPackageRoot();
1282
1306
  const controllerVersion = await readPackageVersion(assetRoot);
@@ -2754,6 +2778,40 @@ export async function runInitDoctor(input) {
2754
2778
  checks,
2755
2779
  };
2756
2780
  }
2781
+ export async function runInitReconcile(input) {
2782
+ const repoRoot = path.resolve(input.repoRoot);
2783
+ const preReport = await checkInitUpdate({
2784
+ repoRoot,
2785
+ projectName: input.projectName,
2786
+ governanceRoot: input.governanceRoot,
2787
+ });
2788
+ if (preReport.surfaceState === "missing") {
2789
+ return { status: "needs-baseline", report: preReport };
2790
+ }
2791
+ if (preReport.humanDecisions.length > 0) {
2792
+ return { status: "needs-human-decision", report: preReport };
2793
+ }
2794
+ const runtimeActivity = await input.readRuntimeActivity(repoRoot);
2795
+ if (isInitRuntimeActive(runtimeActivity)) {
2796
+ return {
2797
+ status: "blocked-active-runtime",
2798
+ report: preReport,
2799
+ runtimeActivity,
2800
+ };
2801
+ }
2802
+ const update = await applyInitUpdate({
2803
+ repoRoot,
2804
+ projectName: input.projectName,
2805
+ governanceRoot: input.governanceRoot,
2806
+ applySafe: true,
2807
+ });
2808
+ const status = update.report.ok
2809
+ ? "clean"
2810
+ : update.report.modelMergeTasks.length > 0
2811
+ ? "needs-model-merge"
2812
+ : "needs-safe-update";
2813
+ return { status, report: update.report, applied: update.applied, skipped: update.skipped };
2814
+ }
2757
2815
  function parseInitArgs(repoRoot, args) {
2758
2816
  let subcommand;
2759
2817
  let projectName;
@@ -2768,7 +2826,7 @@ function parseInitArgs(repoRoot, args) {
2768
2826
  let applySafe = false;
2769
2827
  for (let i = 0; i < args.length; i += 1) {
2770
2828
  const arg = args[i];
2771
- if ((arg === "instructions" || arg === "doctor" || arg === "check-update" || arg === "update") && !subcommand) {
2829
+ if ((arg === "instructions" || arg === "doctor" || arg === "check-update" || arg === "update" || arg === "reconcile") && !subcommand) {
2772
2830
  subcommand = arg;
2773
2831
  continue;
2774
2832
  }
@@ -2826,7 +2884,7 @@ function parseInitArgs(repoRoot, args) {
2826
2884
  applySafe,
2827
2885
  };
2828
2886
  }
2829
- export async function runInit(repoRoot, rawArgs) {
2887
+ export async function runInit(repoRoot, rawArgs, dependencies) {
2830
2888
  const parsed = parseInitArgs(repoRoot, rawArgs);
2831
2889
  if (parsed.subcommand === "instructions") {
2832
2890
  console.log(buildInitInstructions(parsed));
@@ -2857,6 +2915,14 @@ export async function runInit(repoRoot, rawArgs) {
2857
2915
  console.log(parsed.json ? JSON.stringify(result, null, 2) : formatInitUpdateResult(result));
2858
2916
  return;
2859
2917
  }
2918
+ if (parsed.subcommand === "reconcile") {
2919
+ const result = await runInitReconcile({
2920
+ ...parsed,
2921
+ readRuntimeActivity: dependencies.readRuntimeActivity,
2922
+ });
2923
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : formatReconcileResult(result));
2924
+ return;
2925
+ }
2860
2926
  const result = await initializeLoopAgentProject(parsed);
2861
2927
  console.log(parsed.json ? JSON.stringify(result, null, 2) : formatInitResult(result));
2862
2928
  }
@@ -2871,6 +2937,23 @@ function formatInitUpdateResult(result) {
2871
2937
  ...result.report.recommendedNext.map((item) => `- ${item}`),
2872
2938
  ].join("\n");
2873
2939
  }
2940
+ function formatReconcileResult(result) {
2941
+ const lines = [
2942
+ `loop-agent init reconcile: ${result.status}`,
2943
+ `surfaceState: ${result.report.surfaceState}`,
2944
+ ];
2945
+ if (result.applied && result.skipped) {
2946
+ lines.push(`applied: ${result.applied.length}`, `skipped: ${result.skipped.length}`);
2947
+ }
2948
+ if (result.runtimeActivity) {
2949
+ lines.push(`activeDagRuns: ${result.runtimeActivity.activeDagRunIds.length}`, `activeWorkerTasks: ${result.runtimeActivity.activeWorkerTasks}`, `activeWorkerBatches: ${result.runtimeActivity.activeWorkerBatches}`);
2950
+ if (result.runtimeActivity.workerProjectionError) {
2951
+ lines.push(`workerProjectionError: ${result.runtimeActivity.workerProjectionError}`);
2952
+ }
2953
+ }
2954
+ lines.push(`deterministicActions: ${result.report.deterministicActions.length}`, `modelMergeTasks: ${result.report.modelMergeTasks.length}`, `humanDecisions: ${result.report.humanDecisions.length}`, "recommendedNext:", ...result.report.recommendedNext.map((item) => `- ${item}`));
2955
+ return lines.join("\n");
2956
+ }
2874
2957
  function formatInitResult(result) {
2875
2958
  return [
2876
2959
  `Initialized loop-agent harness at ${result.repoRoot}`,