@tea-agent/loop-agent 0.4.0 → 0.6.0

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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +48 -38
  3. package/README.md +3 -3
  4. package/dist/application/dag/args.js +9 -1
  5. package/dist/application/dag/run-dag.js +16 -2
  6. package/dist/application/dag/validate-dag.js +14 -1
  7. package/dist/cli/command-definitions.js +22 -4
  8. package/dist/cli/help.js +3 -2
  9. package/dist/cli/program.js +7 -5
  10. package/dist/commands/import-prd.js +76 -0
  11. package/dist/commands/init.js +230 -32
  12. package/dist/commands/instructions.js +90 -58
  13. package/dist/executors/config-core.js +3 -2
  14. package/dist/executors/dag-pi-executor.js +1 -0
  15. package/dist/executors/model-routing.js +43 -0
  16. package/dist/executors/pi-sdk-executor.js +63 -1
  17. package/dist/governance/manifest-types.js +9 -1
  18. package/dist/shared/preview.js +39 -0
  19. package/dist/task/source-references.js +221 -0
  20. package/dist/worker/cli.js +62 -1
  21. package/dist/worker/loop-agent/loop-agent-client.js +97 -5
  22. package/dist/worker/materialize/harness-task-materializer.js +162 -5
  23. package/dist/worker/observability/event-store.js +82 -0
  24. package/dist/worker/observability/events.js +79 -0
  25. package/dist/worker/observability/progress-composite.js +33 -0
  26. package/dist/worker/observability/read-model.js +1013 -0
  27. package/dist/worker/observability/snapshot-store.js +43 -0
  28. package/dist/worker/observability/types.js +1 -0
  29. package/dist/worker/observe/paths.js +64 -0
  30. package/dist/worker/observe/routes.js +423 -0
  31. package/dist/worker/observe/server.js +61 -0
  32. package/dist/worker/observe/static/app.js +1419 -0
  33. package/dist/worker/observe/static/index.html +63 -0
  34. package/dist/worker/observe/static/styles.css +613 -0
  35. package/dist/worker/pool/failure-routing.js +41 -6
  36. package/dist/worker/pool/run-store.js +59 -1
  37. package/dist/worker/progress-reporter.js +0 -18
  38. package/dist/worker/run-task/run-task.js +327 -92
  39. package/dist/worker/runner/run-ready.js +112 -4
  40. package/dist/workflows/dag/event-observer.js +132 -0
  41. package/dist/workflows/dag/init-hybrid.js +150 -26
  42. package/dist/workflows/dag/observer-compose.js +52 -0
  43. package/dist/workflows/dag/skill-instructions.js +4 -0
  44. package/dist/workflows/dag/types.js +1 -1
  45. package/dist/workflows/dag/validate.js +3 -2
  46. package/docs/README.md +2 -0
  47. package/docs/architecture/runtime-boundaries.md +18 -3
  48. package/docs/design/README.md +22 -9
  49. package/docs/exec-plans/active/README.md +6 -1
  50. package/docs/exec-plans/completed/README.md +12 -0
  51. package/docs/init-surface.manifest.json +32 -2
  52. package/docs/loop-agent-harness.md +13 -0
  53. package/docs/reports/README.md +4 -0
  54. package/docs/templates/agent-dag.base.json +1 -1
  55. package/docs/templates/agent-dag.final-verification.json +1 -1
  56. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  57. package/docs/templates/hybrid-dag.json +1 -1
  58. package/docs/templates/worker-dogfood-evidence.md +52 -0
  59. package/docs/templates/worker-dogfood-setup.md +48 -0
  60. package/examples/example-dag.json +1 -1
  61. package/examples/hybrid-loop-agent-dag.json +1 -1
  62. package/harness.json +5 -29
  63. package/package.json +6 -6
  64. package/skills/loop-agent/SKILL.md +5 -3
  65. package/skills/loop-agent/references/command-reference.md +12 -3
  66. package/skills/loop-agent/references/harness-policy.md +7 -3
  67. package/skills/loop-agent/references/task-workflow.md +8 -3
@@ -6,6 +6,8 @@ import { copyDir } from "../shared/copy-dir.js";
6
6
  import { loadHarnessManifest } from "../governance/harness.js";
7
7
  const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
8
8
  const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
9
+ const GITIGNORE_BLOCK_START = "# LOOP_AGENT_INIT_START";
10
+ const GITIGNORE_BLOCK_END = "# LOOP_AGENT_INIT_END";
9
11
  const INIT_SURFACE_STATE_PATH = ".harness/init-surface.json";
10
12
  const CORE_DOC_FILES = [
11
13
  "README.md",
@@ -720,17 +722,88 @@ function mergeManagedBlock(existing, block) {
720
722
  }
721
723
  return `${existing.trimEnd()}\n\n${block}\n`;
722
724
  }
725
+ function mergeGitignoreManagedBlock(existing, block) {
726
+ const start = existing.indexOf(GITIGNORE_BLOCK_START);
727
+ const end = existing.indexOf(GITIGNORE_BLOCK_END);
728
+ if (start >= 0 && end > start) {
729
+ return `${existing.slice(0, start).trimEnd()}\n\n${block}\n${existing.slice(end + GITIGNORE_BLOCK_END.length).trimStart()}`.trimEnd() + "\n";
730
+ }
731
+ if (!existing.trim())
732
+ return `${block}\n`;
733
+ return `${existing.trimEnd()}\n\n${block}\n`;
734
+ }
735
+ /** Shared ignore rules for loop-agent runtime facts; keep prompts/ and directory structure shareable. */
736
+ export function buildManagedGitignoreBlock() {
737
+ return [
738
+ GITIGNORE_BLOCK_START,
739
+ "# loop-agent runtime: ignore personal/session facts; keep prompts and directory placeholders shareable",
740
+ "",
741
+ "# tasks (source + runtime state per developer/session)",
742
+ ".harness/tasks/*",
743
+ "!.harness/tasks/.gitkeep",
744
+ "",
745
+ "# DAG runs",
746
+ ".harness/dag-runs/active/*",
747
+ "!.harness/dag-runs/active/.gitkeep",
748
+ ".harness/dag-runs/completed/*",
749
+ "!.harness/dag-runs/completed/.gitkeep",
750
+ ".harness/dag-runs/paused/*",
751
+ "!.harness/dag-runs/paused/.gitkeep",
752
+ "",
753
+ "# one-shot executor runs",
754
+ ".harness/runs/*",
755
+ "!.harness/runs/.gitkeep",
756
+ "",
757
+ "# session / cache / logs / recomputable surface state",
758
+ ".harness/live/",
759
+ ".harness/cache/",
760
+ ".harness/*.log",
761
+ ".harness/init-surface.json",
762
+ "",
763
+ "# worker task pool",
764
+ ".task-pool/",
765
+ "",
766
+ "# multi-worktree parallel mode",
767
+ ".worktrees/",
768
+ GITIGNORE_BLOCK_END,
769
+ ].join("\n");
770
+ }
771
+ function extractGitignoreManagedBlock(content) {
772
+ const start = content.indexOf(GITIGNORE_BLOCK_START);
773
+ const end = content.indexOf(GITIGNORE_BLOCK_END);
774
+ if (start < 0 || end <= start)
775
+ return undefined;
776
+ return content.slice(start, end + GITIGNORE_BLOCK_END.length);
777
+ }
723
778
  function buildHarness(input) {
724
- const modelOverride = input.provider || input.model
725
- ? {
726
- analyze: { provider: input.provider, model: input.model },
727
- plan: { provider: input.provider, model: input.model },
728
- implement: { provider: input.provider, model: input.model },
729
- verify: { provider: input.provider, model: input.model },
730
- retrospective: { provider: input.provider, model: input.model },
731
- }
732
- : undefined;
733
- return {
779
+ const templateExecutors = isRecord(input.template.executors)
780
+ ? { ...input.template.executors }
781
+ : {};
782
+ const existingExecutors = isRecord(input.existing.executors)
783
+ ? input.existing.executors
784
+ : {};
785
+ // Cursor executor is opt-in for package developers; do not project it to target apps.
786
+ // Keep it only when the target already configured executors.cursor.
787
+ if (!isRecord(existingExecutors.cursor)) {
788
+ delete templateExecutors.cursor;
789
+ }
790
+ const mergedExecutors = mergeRecord(templateExecutors, existingExecutors);
791
+ // requiresApiKey is Cursor-facing only; never project it on pi.
792
+ if (isRecord(mergedExecutors.pi) && "requiresApiKey" in mergedExecutors.pi) {
793
+ const pi = { ...mergedExecutors.pi };
794
+ delete pi.requiresApiKey;
795
+ mergedExecutors.pi = pi;
796
+ }
797
+ // Prefer DAG-facing executors.*.defaultModel over legacy top-level models.*.
798
+ // --provider/--model still apply as a convenience override for pi defaultModel.
799
+ if (input.provider || input.model) {
800
+ const piExisting = isRecord(mergedExecutors.pi) ? mergedExecutors.pi : {};
801
+ mergedExecutors.pi = {
802
+ ...piExisting,
803
+ ...(input.model ? { defaultModel: input.model } : {}),
804
+ };
805
+ }
806
+ const harness = {
734
807
  ...input.existing,
735
808
  version: 1,
736
809
  project: input.projectName,
@@ -753,6 +826,7 @@ function buildHarness(input) {
753
826
  progressDir: `${input.governanceRoot}/progress`,
754
827
  reportsDir: `${input.governanceRoot}/reports`,
755
828
  decisionsDir: `${input.governanceRoot}/decisions`,
829
+ taskPoolDir: ".task-pool",
756
830
  }),
757
831
  scripts: mergeRecord(input.existing.scripts, {
758
832
  checkEngineeringStructure: "scripts/check-engineering-structure.sh",
@@ -771,11 +845,30 @@ function buildHarness(input) {
771
845
  standardVerify: "bash scripts/ci-governance.sh",
772
846
  fullVerify: "bash scripts/ci.sh",
773
847
  }),
774
- models: modelOverride ?? input.existing.models ?? input.template.models ?? {},
775
- modelProfiles: input.existing.modelProfiles ?? input.template.modelProfiles ?? {},
776
- modelRouting: input.existing.modelRouting ?? input.template.modelRouting ?? {},
777
- executors: mergeRecord(input.template.executors, input.existing.executors),
848
+ executors: mergedExecutors,
778
849
  };
850
+ // Do not project legacy step-routing models / modelProfiles / modelRouting into
851
+ // new or refreshed harness.json. Keep them only when the target already has them
852
+ // so old files remain loadable until the user migrates.
853
+ if (isRecord(input.existing.models) && Object.keys(input.existing.models).length > 0) {
854
+ harness.models = input.existing.models;
855
+ }
856
+ else {
857
+ delete harness.models;
858
+ }
859
+ if (isRecord(input.existing.modelProfiles) && Object.keys(input.existing.modelProfiles).length > 0) {
860
+ harness.modelProfiles = input.existing.modelProfiles;
861
+ }
862
+ else {
863
+ delete harness.modelProfiles;
864
+ }
865
+ if (isRecord(input.existing.modelRouting) && Object.keys(input.existing.modelRouting).length > 0) {
866
+ harness.modelRouting = input.existing.modelRouting;
867
+ }
868
+ else {
869
+ delete harness.modelRouting;
870
+ }
871
+ return harness;
779
872
  }
780
873
  async function writeTextIfMissing(input) {
781
874
  const target = path.join(input.repoRoot, input.relativePath);
@@ -899,8 +992,11 @@ function inferInitSurfaceMode(relativePath) {
899
992
  relativePath.endsWith("/")) {
900
993
  return "directory";
901
994
  }
902
- if (relativePath === "README.md" || relativePath === "AGENTS.md")
995
+ if (relativePath === "README.md" ||
996
+ relativePath === "AGENTS.md" ||
997
+ relativePath === ".gitignore") {
903
998
  return "managed-block";
999
+ }
904
1000
  if (relativePath === "harness.json" ||
905
1001
  relativePath.startsWith("scripts/") ||
906
1002
  relativePath.startsWith(".harness/prompts/") ||
@@ -934,6 +1030,9 @@ async function buildDesiredSurfaceContent(input) {
934
1030
  }),
935
1031
  };
936
1032
  }
1033
+ if (manifestPath === ".gitignore") {
1034
+ return { content: buildManagedGitignoreBlock() };
1035
+ }
937
1036
  if (manifestPath === "harness.json") {
938
1037
  const template = await readJsonIfExists(path.join(input.assetRoot, "harness.json"));
939
1038
  return {
@@ -953,6 +1052,16 @@ async function buildDesiredSurfaceContent(input) {
953
1052
  const name = path.basename(manifestPath);
954
1053
  return { content: COMPAT_PROMPTS[name] };
955
1054
  }
1055
+ // `.agents/skills/...` is a target-only mirror of the bundled `skills/...`
1056
+ // directory; the package ships `skills/`, never `.agents/skills/`.
1057
+ if (manifestPath.startsWith(".agents/skills/")) {
1058
+ const sourceManifestPath = manifestPath.slice(".agents/".length);
1059
+ const mirrorSourcePath = path.join(input.assetRoot, sourceManifestPath);
1060
+ if (await exists(mirrorSourcePath)) {
1061
+ return { content: await readFile(mirrorSourcePath, "utf-8"), sourcePath: mirrorSourcePath };
1062
+ }
1063
+ return {};
1064
+ }
956
1065
  if (manifestPath.startsWith("docs/")) {
957
1066
  const doc = manifestPath.slice("docs/".length);
958
1067
  const generated = buildGeneratedCoreDoc({
@@ -1031,7 +1140,10 @@ async function buildCurrentSurfaceState(input) {
1031
1140
  const current = await readFile(targetPath);
1032
1141
  const currentSha256 = sha256Buffer(current);
1033
1142
  if (entry.mode === "managed-block") {
1034
- const currentBlock = extractManagedBlock(current.toString("utf-8"));
1143
+ const text = current.toString("utf-8");
1144
+ const currentBlock = entry.path === ".gitignore" || targetRelativePath === ".gitignore"
1145
+ ? extractGitignoreManagedBlock(text)
1146
+ : extractManagedBlock(text);
1035
1147
  files[targetRelativePath] = {
1036
1148
  ...base,
1037
1149
  currentSha256,
@@ -1194,6 +1306,8 @@ function buildTargetDocsReadme(input) {
1194
1306
  "- `decisions/README.md` - 架构决策 / architecture decisions",
1195
1307
  "- `templates/` - 可复用的计划、报告与 DAG 模板 / reusable planning, reporting, and DAG templates",
1196
1308
  "- `templates/production-readiness-checklist.md` - 低/中风险单仓库 DAG 任务的 production readiness 检查清单 / production readiness checklist for low/medium-risk single-repo DAG work",
1309
+ "- `templates/worker-dogfood-setup.md` - 发布控制器下的真实 Worker sample setup / real Worker sample setup with a published controller",
1310
+ "- `templates/worker-dogfood-evidence.md` - Worker sample、Observe、morning report 与 QA coverage evidence / Worker evidence template",
1197
1311
  "",
1198
1312
  "## 验证 / Verification",
1199
1313
  "",
@@ -1228,6 +1342,9 @@ function buildTargetRuntimeBoundaries(input) {
1228
1342
  "Executors / Integrations layer",
1229
1343
  " └─ 外部工具、SDK、数据库、消息队列、浏览器、模型或 shell 适配",
1230
1344
  "",
1345
+ "Worker adapter layer (optional)",
1346
+ " └─ 产品线 TaskSpec / Task Pool / local Observe 适配;通过已发布 loop-agent CLI 执行,不在进程内耦合 target command 或 application 层",
1347
+ "",
1231
1348
  "Infrastructure / Store layer",
1232
1349
  " └─ 文件系统、数据库、缓存、运行事实、原子写入和生命周期副作用",
1233
1350
  "",
@@ -1241,6 +1358,7 @@ function buildTargetRuntimeBoundaries(input) {
1241
1358
  "- Application / Use-case layer 可以依赖 Domain / Workflow、Infrastructure 和 Integrations。",
1242
1359
  "- Domain / Workflow layer 不应依赖 Entry layer 的格式化、argv、HTTP/UI 细节。",
1243
1360
  "- Executors / Integrations 不应依赖 Entry layer 的输出格式。",
1361
+ "- Worker adapter 应把 `.task-pool/` 作为独立运行事实区;它通过 CLI/subprocess contract 调用 loop-agent,不能复制或直接耦合 target command/application 实现。",
1244
1362
  "- Infrastructure / Store 应集中副作用,不把 raw path mutation 或持久化细节扩散给上层。",
1245
1363
  "",
1246
1364
  "## 目标项目适配",
@@ -1382,6 +1500,7 @@ function buildTargetLoopAgentHarness(input) {
1382
1500
  "- `.harness/tasks/` stores task state and source materials.",
1383
1501
  "- `.harness/dag-runs/` stores DAG run facts.",
1384
1502
  "- `.harness/runs/` stores one-shot executor facts.",
1503
+ "- `.task-pool/` stores optional agent-worker Task Pool state, batch artifacts, failure handoffs, and local Observe events; it is runtime state and normally ignored by Git.",
1385
1504
  "- `docs/` stores durable governance, plans, reports, progress, and decisions.",
1386
1505
  "- `skills/` stores repo-local skill instructions; the CLI can fall back to bundled skills when needed.",
1387
1506
  "",
@@ -1395,6 +1514,8 @@ function buildTargetLoopAgentHarness(input) {
1395
1514
  "",
1396
1515
  "Use `docs/templates/production-readiness-checklist.md` when claiming Production Readiness v0.1 for low/medium-risk single-repo DAG work.",
1397
1516
  "",
1517
+ "For real product-line Worker samples, use `docs/templates/worker-dogfood-setup.md` and `docs/templates/worker-dogfood-evidence.md`. Explicit failed-task retries must preserve prior evidence and use a new worker run id.",
1518
+ "",
1398
1519
  "## Script Matrix",
1399
1520
  "",
1400
1521
  "`scripts/check-repo.sh` verifies loop-agent governance. `scripts/ci-tests.sh` handles target project verification through conservative language/toolchain detection and should be adapted after reading the project.",
@@ -1462,7 +1583,8 @@ export function buildInitInstructions(input) {
1462
1583
  "- After deterministic initialization, inspect README/config/build files (for example package.json, pyproject.toml, go.mod, Cargo.toml, pom.xml, Gradle files, Makefile, .sln/.csproj, or project-specific scripts) and adapt `scripts/ci-tests.sh` plus the verification matrix to the real project.",
1463
1584
  "- Enrich the root `README.md`: keep the deterministic project title and the loop-agent managed block intact, and fill the human-authored sections (项目概览, 技术栈与目录结构, 开发与验证) from the target project's actual files. The root README must serve both as a human-first project entry and as an agent work entry; replace the initialization-model supplement comments when the project files provide the information.",
1464
1585
  "- Populate `docs/verification-matrix.md` with the target project's actual quick, standard, and full verification commands derived from its real language and toolchain, keeping the governance rows intact.",
1465
- "- Copy repo-local skills by default so the target repo has auditable skill instructions.",
1586
+ "- Copy repo-local skills by default so the target repo has auditable skill instructions; full profile also mirrors skills into `.agents/skills/` for external agents.",
1587
+ "- Merge a loop-agent managed block into `.gitignore` that ignores harness runtime facts (tasks, dag-runs, runs, live, cache, init-surface.json, .task-pool) while keeping prompts and directory placeholders shareable.",
1466
1588
  "- Do not copy examples by default; examples stay bundled in the tool and are available through `loop-agent examples`.",
1467
1589
  "- Add or update a loop-agent managed block in AGENTS.md.",
1468
1590
  "- The generated AGENTS.md must include documentation convergence and structured DAG write-boundary rules so target projects keep the same working discipline as this repository.",
@@ -1556,6 +1678,18 @@ export async function initializeLoopAgentProject(options) {
1556
1678
  written,
1557
1679
  skipped,
1558
1680
  });
1681
+ const gitignorePath = path.join(repoRoot, ".gitignore");
1682
+ const existingGitignore = (await exists(gitignorePath))
1683
+ ? await readFile(gitignorePath, "utf-8")
1684
+ : "";
1685
+ await writeText({
1686
+ repoRoot,
1687
+ relativePath: ".gitignore",
1688
+ content: mergeGitignoreManagedBlock(existingGitignore, buildManagedGitignoreBlock()),
1689
+ merge: true,
1690
+ written,
1691
+ skipped,
1692
+ });
1559
1693
  for (const doc of CORE_DOC_FILES) {
1560
1694
  const generated = buildGeneratedCoreDoc({ doc, projectName, governanceRoot });
1561
1695
  if (generated) {
@@ -1604,9 +1738,21 @@ export async function initializeLoopAgentProject(options) {
1604
1738
  targetRelativePath: "skills",
1605
1739
  written,
1606
1740
  });
1741
+ // Mirror bundled skills into the agent-compatible `.agents/skills/` path so
1742
+ // external agents (e.g. OpenCode) that auto-discover `.agents/skills/<name>/SKILL.md`
1743
+ // share the same skill content as loop-agent's primary `skills/` path.
1744
+ // The package still ships only `skills/`; `.agents/skills` is a target projection.
1745
+ await copyDirMerge({
1746
+ assetRoot,
1747
+ repoRoot,
1748
+ sourceRelativePath: "skills",
1749
+ targetRelativePath: ".agents/skills",
1750
+ written,
1751
+ });
1607
1752
  }
1608
1753
  else {
1609
1754
  skipped.push("skills/");
1755
+ skipped.push(".agents/skills/");
1610
1756
  }
1611
1757
  skipped.push("examples/");
1612
1758
  for (const [relativePath, content] of Object.entries(buildInitScriptFiles(governanceRoot))) {
@@ -1736,6 +1882,27 @@ export async function checkInitUpdate(input) {
1736
1882
  }
1737
1883
  }
1738
1884
  }
1885
+ // Safe harness hygiene: strip obsolete pi.requiresApiKey without full harness rewrite.
1886
+ // Cursor requiresApiKey is intentionally kept when present.
1887
+ const harnessPath = path.join(repoRoot, "harness.json");
1888
+ if (await exists(harnessPath)) {
1889
+ try {
1890
+ const harness = JSON.parse(await readFile(harnessPath, "utf-8"));
1891
+ if (isRecord(harness) &&
1892
+ isRecord(harness.executors) &&
1893
+ isRecord(harness.executors.pi) &&
1894
+ Object.prototype.hasOwnProperty.call(harness.executors.pi, "requiresApiKey")) {
1895
+ deterministicActions.push({
1896
+ type: "strip-pi-requires-api-key",
1897
+ path: "harness.json",
1898
+ reason: "executors.pi.requiresApiKey is unused; strip it while keeping user model config and cursor.requiresApiKey",
1899
+ });
1900
+ }
1901
+ }
1902
+ catch {
1903
+ // leave malformed harness for model merge / doctor
1904
+ }
1905
+ }
1739
1906
  const partial = {
1740
1907
  repoRoot,
1741
1908
  controllerVersion: currentState.controllerVersion,
@@ -1759,6 +1926,29 @@ export async function checkInitUpdate(input) {
1759
1926
  };
1760
1927
  }
1761
1928
  async function applySafeAction(input) {
1929
+ if (input.action.type === "strip-pi-requires-api-key") {
1930
+ const target = path.join(input.repoRoot, "harness.json");
1931
+ if (!(await exists(target)))
1932
+ return false;
1933
+ const harness = JSON.parse(await readFile(target, "utf-8"));
1934
+ if (!isRecord(harness) || !isRecord(harness.executors) || !isRecord(harness.executors.pi)) {
1935
+ return false;
1936
+ }
1937
+ if (!Object.prototype.hasOwnProperty.call(harness.executors.pi, "requiresApiKey")) {
1938
+ return false;
1939
+ }
1940
+ const nextPi = { ...harness.executors.pi };
1941
+ delete nextPi.requiresApiKey;
1942
+ const next = {
1943
+ ...harness,
1944
+ executors: {
1945
+ ...harness.executors,
1946
+ pi: nextPi,
1947
+ },
1948
+ };
1949
+ await writeFile(target, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
1950
+ return true;
1951
+ }
1762
1952
  const assetRoot = await findPackageRoot();
1763
1953
  const entry = {
1764
1954
  path: input.action.path,
@@ -1789,11 +1979,13 @@ async function applySafeAction(input) {
1789
1979
  await mkdir(path.dirname(target), { recursive: true });
1790
1980
  if (input.action.type === "refresh-managed-block") {
1791
1981
  const existing = (await exists(target)) ? await readFile(target, "utf-8") : "";
1792
- const next = input.action.path === "README.md" && existing.trim().length === 0
1793
- ? buildTargetReadme({ projectName: input.projectName, governanceRoot: input.governanceRoot })
1794
- : input.action.path === "AGENTS.md" && existing.trim().length === 0
1795
- ? `# AGENTS.md\n\n${desired.content}\n`
1796
- : mergeManagedBlock(existing, desired.content);
1982
+ const next = input.action.path === ".gitignore"
1983
+ ? mergeGitignoreManagedBlock(existing, desired.content)
1984
+ : input.action.path === "README.md" && existing.trim().length === 0
1985
+ ? buildTargetReadme({ projectName: input.projectName, governanceRoot: input.governanceRoot })
1986
+ : input.action.path === "AGENTS.md" && existing.trim().length === 0
1987
+ ? `# AGENTS.md\n\n${desired.content}\n`
1988
+ : mergeManagedBlock(existing, desired.content);
1797
1989
  await writeFile(target, next, "utf-8");
1798
1990
  return true;
1799
1991
  }
@@ -1826,16 +2018,18 @@ export async function applyInitUpdate(input) {
1826
2018
  skipped.push(action);
1827
2019
  continue;
1828
2020
  }
1829
- const currentState = await buildCurrentSurfaceState({
1830
- repoRoot,
1831
- projectName,
1832
- governanceRoot,
1833
- stateKind: "inferred-baseline",
1834
- });
1835
- const relationship = currentState.files[action.path]?.relationship;
1836
- if (relationship === "local-existing-unknown") {
1837
- skipped.push(action);
1838
- continue;
2021
+ if (action.type !== "strip-pi-requires-api-key") {
2022
+ const currentState = await buildCurrentSurfaceState({
2023
+ repoRoot,
2024
+ projectName,
2025
+ governanceRoot,
2026
+ stateKind: "inferred-baseline",
2027
+ });
2028
+ const relationship = currentState.files[action.path]?.relationship;
2029
+ if (relationship === "local-existing-unknown") {
2030
+ skipped.push(action);
2031
+ continue;
2032
+ }
1839
2033
  }
1840
2034
  if (await applySafeAction({ repoRoot, projectName, governanceRoot, action }))
1841
2035
  applied.push(action);
@@ -1909,6 +2103,10 @@ export async function runInitDoctor(input) {
1909
2103
  const readme = path.join(repoRoot, "README.md");
1910
2104
  add("README loop-agent block", (await exists(readme)) && (await readFile(readme, "utf-8")).includes(MANAGED_BLOCK_START), "managed block present");
1911
2105
  add("repo-local skills", await exists(path.join(repoRoot, "skills", "loop-agent", "SKILL.md")), "skills/loop-agent/SKILL.md");
2106
+ add("agent-compatible skills mirror", await exists(path.join(repoRoot, ".agents", "skills", "loop-agent", "SKILL.md")), ".agents/skills/loop-agent/SKILL.md");
2107
+ const gitignorePath = path.join(repoRoot, ".gitignore");
2108
+ const gitignoreContent = (await exists(gitignorePath)) ? await readFile(gitignorePath, "utf-8") : "";
2109
+ add("gitignore loop-agent block", gitignoreContent.includes(GITIGNORE_BLOCK_START) && gitignoreContent.includes(".harness/tasks/*"), ".gitignore managed runtime ignores");
1912
2110
  const requiredScripts = Object.keys(INIT_SCRIPT_FILES);
1913
2111
  const missingScripts = [];
1914
2112
  for (const script of requiredScripts) {
@@ -1,42 +1,42 @@
1
- import { access, readdir } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { getArtifactPath, getArtifactRelativePath, } from '../shared/artifacts-core.js';
4
- import { defaultHybridDagOutputPath } from '../workflows/dag/init-hybrid.js';
5
- import { getTaskDir, getTaskPaths, loadTaskConfig } from '../task/runtime.js';
1
+ import { access, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getArtifactPath, getArtifactRelativePath, } from "../shared/artifacts-core.js";
4
+ import { defaultHybridDagOutputPath } from "../workflows/dag/init-hybrid.js";
5
+ import { getTaskDir, getTaskPaths, loadTaskConfig } from "../task/runtime.js";
6
6
  const STAGES = new Set([
7
- 'source',
8
- 'dag-draft',
9
- 'task-artifacts',
10
- 'promotion',
11
- 'closeout',
7
+ "source",
8
+ "dag-draft",
9
+ "task-artifacts",
10
+ "promotion",
11
+ "closeout",
12
12
  ]);
13
13
  export function parseInstructionsArgs(args) {
14
14
  const [stageRaw, ...rest] = args;
15
15
  if (!stageRaw || !STAGES.has(stageRaw)) {
16
- throw new Error('usage: instructions <source|dag-draft|task-artifacts|promotion|closeout> --task <task-id> [--json]');
16
+ throw new Error("usage: instructions <source|dag-draft|task-artifacts|promotion|closeout> --task <task-id> [--json]");
17
17
  }
18
18
  let taskId;
19
19
  let json = false;
20
20
  for (let i = 0; i < rest.length; i += 1) {
21
21
  const arg = rest[i];
22
- if (arg === '--task') {
22
+ if (arg === "--task") {
23
23
  taskId = rest[++i];
24
24
  if (!taskId)
25
- throw new Error('instructions --task requires a task id');
25
+ throw new Error("instructions --task requires a task id");
26
26
  continue;
27
27
  }
28
- if (arg.startsWith('--task=')) {
29
- taskId = arg.slice('--task='.length);
28
+ if (arg.startsWith("--task=")) {
29
+ taskId = arg.slice("--task=".length);
30
30
  continue;
31
31
  }
32
- if (arg === '--json') {
32
+ if (arg === "--json") {
33
33
  json = true;
34
34
  continue;
35
35
  }
36
36
  throw new Error(`unknown instructions flag: ${arg}`);
37
37
  }
38
38
  if (!taskId)
39
- throw new Error('instructions requires --task <task-id>');
39
+ throw new Error("instructions requires --task <task-id>");
40
40
  return { stage: stageRaw, taskId, json };
41
41
  }
42
42
  export async function runInstructions(repoRoot, args) {
@@ -54,13 +54,13 @@ export async function buildInstructions(repoRoot, stage, taskId) {
54
54
  const taskConfig = await loadTaskConfig(repoRoot, taskId);
55
55
  const allowedEditRoots = taskConfig.allowedPaths.length > 0
56
56
  ? taskConfig.allowedPaths
57
- : [path.relative(repoRoot, taskDir).replace(/\\/g, '/')];
57
+ : [path.relative(repoRoot, taskDir).replace(/\\/g, "/")];
58
58
  const writePolicy = {
59
- mode: stage === 'promotion'
60
- ? 'run-evidence-read-only'
61
- : stage === 'closeout'
62
- ? 'docs-progress'
63
- : 'task-owned',
59
+ mode: stage === "promotion"
60
+ ? "run-evidence-read-only"
61
+ : stage === "closeout"
62
+ ? "docs-progress"
63
+ : "task-owned",
64
64
  allowedEditRoots,
65
65
  forbiddenPaths: taskConfig.forbiddenPaths,
66
66
  };
@@ -70,74 +70,106 @@ export async function buildInstructions(repoRoot, stage, taskId) {
70
70
  existingFiles: await listExistingFiles(taskDir),
71
71
  writePolicy,
72
72
  };
73
- if (stage === 'source') {
73
+ if (stage === "source") {
74
74
  return refreshInstructionBlockers(withMissingDependencies({
75
75
  ...base,
76
76
  outputPath: paths.sourceDir,
77
77
  dependencies: [paths.taskConfigPath],
78
- template: { requiredHeadings: ['# 需求', '# 执行约束'] },
78
+ template: {
79
+ requiredHeadings: [
80
+ "# 需求",
81
+ "# 执行约束",
82
+ "## 目标",
83
+ "## 验收标准",
84
+ "## 非目标",
85
+ ],
86
+ },
79
87
  completionCriteria: [
80
- 'source/需求.md exists with concrete requirements',
81
- 'source/执行约束.md exists when path or behavioral constraints matter',
88
+ "source/references/ holds the immutable original PRD (via import-prd or materialize); do not let AI rewrite it",
89
+ "source/需求.md is a derived execution contract with objective, scope, non-goals, and acceptance criteria mapped back to the original PRD",
90
+ "source/需求.md uses stable REQ/AC ids or explicit source anchors when the original PRD is long",
91
+ "source/执行约束.md exists when path or behavioral constraints matter",
92
+ "AI assumptions that are not in the original PRD stay under open questions, never as fake requirements",
82
93
  ],
83
94
  }));
84
95
  }
85
- if (stage === 'dag-draft') {
96
+ if (stage === "dag-draft") {
86
97
  return refreshInstructionBlockers(withMissingDependencies({
87
98
  ...base,
88
99
  outputPath: defaultHybridDagOutputPath(taskId),
89
100
  dependencies: [
90
101
  paths.taskConfigPath,
91
- path.join(paths.sourceDir, '需求.md'),
102
+ path.join(paths.sourceDir, "需求.md"),
92
103
  ],
93
- template: { requiredHeadings: ['version', 'title', 'objective', 'tasks', 'executorModels'] },
104
+ template: {
105
+ requiredHeadings: [
106
+ "version",
107
+ "title",
108
+ "objective",
109
+ "tasks",
110
+ "executorModels",
111
+ ],
112
+ },
94
113
  completionCriteria: [
95
- 'dag draft validates with dag validate --strict-models --strict-governance',
96
- 'writeSet is narrow and matches task constraints',
114
+ "dag draft validates with dag validate --strict-models --strict-governance",
115
+ "writeSet is narrow and matches task constraints",
97
116
  ],
98
117
  }));
99
118
  }
100
- if (stage === 'promotion') {
119
+ if (stage === "promotion") {
101
120
  return refreshInstructionBlockers(withMissingDependencies({
102
121
  ...base,
103
- outputPath: path.join(taskDir, 'artifacts'),
122
+ outputPath: path.join(taskDir, "artifacts"),
104
123
  dependencies: [
105
- path.join(repoRoot, '.harness', 'dag-runs', 'completed'),
106
- path.join(repoRoot, '.harness', 'runs', 'completed'),
124
+ path.join(repoRoot, ".harness", "dag-runs", "completed"),
125
+ path.join(repoRoot, ".harness", "runs", "completed"),
107
126
  ],
108
- template: { requiredHeadings: ['# 修改记录', '# 验证结果'] },
127
+ template: { requiredHeadings: ["# 修改记录", "# 验证结果"] },
109
128
  completionCriteria: [
110
- 'completed run facts remain read-only',
111
- 'task artifacts summarize real run evidence without inventing verification',
129
+ "completed run facts remain read-only",
130
+ "task artifacts summarize real run evidence without inventing verification",
112
131
  ],
113
132
  }));
114
133
  }
115
- if (stage === 'closeout') {
134
+ if (stage === "closeout") {
116
135
  return refreshInstructionBlockers(withMissingDependencies({
117
136
  ...base,
118
- outputPath: path.join(repoRoot, 'docs', 'progress', `${taskId}.md`),
137
+ outputPath: path.join(repoRoot, "docs", "progress", `${taskId}.md`),
119
138
  dependencies: [
120
- getArtifactPath(taskDir, 'implement'),
121
- getArtifactPath(taskDir, 'verify'),
139
+ getArtifactPath(taskDir, "implement"),
140
+ getArtifactPath(taskDir, "verify"),
122
141
  ],
123
- template: { requiredHeadings: ['# Progress', '## Summary', '## Verification', '## Remaining Risk'] },
142
+ template: {
143
+ requiredHeadings: [
144
+ "# Progress",
145
+ "## Summary",
146
+ "## Verification",
147
+ "## Remaining Risk",
148
+ ],
149
+ },
124
150
  completionCriteria: [
125
- 'records commands actually run and their outcome',
126
- 'links remaining risks or follow-up tasks',
151
+ "records commands actually run and their outcome",
152
+ "links remaining risks or follow-up tasks",
127
153
  ],
128
154
  }));
129
155
  }
130
- const artifactSteps = ['analyze', 'plan', 'implement', 'verify', 'retrospective'];
156
+ const artifactSteps = [
157
+ "analyze",
158
+ "plan",
159
+ "implement",
160
+ "verify",
161
+ "retrospective",
162
+ ];
131
163
  return refreshInstructionBlockers(withMissingDependencies({
132
164
  ...base,
133
- outputPath: path.join(taskDir, 'artifacts'),
165
+ outputPath: path.join(taskDir, "artifacts"),
134
166
  dependencies: [paths.taskConfigPath, paths.statePath],
135
167
  template: {
136
168
  requiredHeadings: artifactSteps.map((step) => getArtifactRelativePath(step)),
137
169
  },
138
170
  completionCriteria: [
139
- 'artifact content is substantive and not the new-task template',
140
- 'verification artifact names exact commands and pass/fail results',
171
+ "artifact content is substantive and not the new-task template",
172
+ "verification artifact names exact commands and pass/fail results",
141
173
  ],
142
174
  }));
143
175
  }
@@ -153,7 +185,7 @@ async function listExistingFiles(root) {
153
185
  const names = await readdir(root, { recursive: true });
154
186
  return names
155
187
  .map((name) => String(name))
156
- .filter((name) => !name.includes('/logs/'))
188
+ .filter((name) => !name.includes("/logs/"))
157
189
  .sort();
158
190
  }
159
191
  catch {
@@ -179,17 +211,17 @@ export async function refreshInstructionBlockers(doc) {
179
211
  function formatInstructionsMarkdown(doc) {
180
212
  const lines = [
181
213
  `# Instructions: ${doc.artifactId}`,
182
- '',
214
+ "",
183
215
  `- task: \`${doc.taskId}\``,
184
216
  `- output: \`${doc.outputPath}\``,
185
217
  `- write policy: \`${doc.writePolicy.mode}\``,
186
- `- blocked: ${doc.blocked ? 'yes' : 'no'}`,
187
- '',
188
- '## Dependencies',
218
+ `- blocked: ${doc.blocked ? "yes" : "no"}`,
219
+ "",
220
+ "## Dependencies",
189
221
  ...doc.dependencies.map((dep) => `- \`${dep}\``),
190
- '',
191
- '## Completion Criteria',
222
+ "",
223
+ "## Completion Criteria",
192
224
  ...doc.completionCriteria.map((criterion) => `- ${criterion}`),
193
225
  ];
194
- return `${lines.join('\n')}\n`;
226
+ return `${lines.join("\n")}\n`;
195
227
  }