@tea-agent/loop-agent 0.2.1 → 0.3.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 (33) hide show
  1. package/AGENTS.md +56 -54
  2. package/CHANGELOG.md +22 -10
  3. package/README.md +24 -12
  4. package/dist/application/dag/args.js +6 -0
  5. package/dist/application/dag/generate-task-dag.js +2 -0
  6. package/dist/application/dag/run-dag.js +3 -0
  7. package/dist/application/dag/validate-dag.js +40 -0
  8. package/dist/cli/command-definitions.js +2 -2
  9. package/dist/cli/program.js +24 -4
  10. package/dist/commands/init.js +554 -2
  11. package/dist/workflows/dag/dynamic-runtime/loop-until.js +2 -1
  12. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  13. package/dist/workflows/dag/init-hybrid.js +3 -3
  14. package/dist/workflows/dag/skills.js +3 -3
  15. package/dist/workflows/dag/types.js +2 -0
  16. package/dist/workflows/dynamic/compile.js +11 -0
  17. package/dist/workflows/dynamic/spec.js +1 -0
  18. package/docs/README.md +7 -4
  19. package/docs/agent-dag-runner.md +2 -0
  20. package/docs/exec-plans/active/README.md +1 -4
  21. package/docs/exec-plans/completed/README.md +2 -0
  22. package/docs/init-surface.manifest.json +175 -0
  23. package/docs/skills/README.md +6 -0
  24. package/docs/skills/vetted-skill-registry.md +26 -0
  25. package/docs/templates/init-evolution-review.md +33 -0
  26. package/harness.json +5 -3
  27. package/package.json +7 -5
  28. package/skills/code-review-core/SKILL.md +20 -0
  29. package/skills/codebase-scout/SKILL.md +19 -0
  30. package/skills/init-capability-evolution/SKILL.md +69 -0
  31. package/skills/loop-agent/references/command-reference.md +37 -19
  32. package/skills/test-driven-development/SKILL.md +20 -0
  33. package/skills/webapp-testing/SKILL.md +19 -0
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { access, copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -5,6 +6,7 @@ import { copyDir } from "../shared/copy-dir.js";
5
6
  import { loadHarnessManifest } from "../governance/harness.js";
6
7
  const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
7
8
  const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
9
+ const INIT_SURFACE_STATE_PATH = ".harness/init-surface.json";
8
10
  const CORE_DOC_FILES = [
9
11
  "README.md",
10
12
  "architecture/runtime-boundaries.md",
@@ -846,6 +848,236 @@ async function writeCompatPrompts(input) {
846
848
  });
847
849
  }
848
850
  }
851
+ function sha256Text(value) {
852
+ return createHash("sha256").update(value).digest("hex");
853
+ }
854
+ function sha256Buffer(value) {
855
+ return createHash("sha256").update(value).digest("hex");
856
+ }
857
+ function isRecord(value) {
858
+ return typeof value === "object" && value !== null && !Array.isArray(value);
859
+ }
860
+ function normalizeRelativePath(relativePath) {
861
+ return relativePath.split(path.sep).join("/");
862
+ }
863
+ function manifestPathToTargetPath(relativePath, governanceRoot) {
864
+ if (governanceRoot !== "docs" && relativePath.startsWith("docs/")) {
865
+ return `${governanceRoot}/${relativePath.slice("docs/".length)}`;
866
+ }
867
+ return relativePath;
868
+ }
869
+ function targetPathToManifestPath(relativePath, governanceRoot) {
870
+ if (governanceRoot !== "docs" && relativePath.startsWith(`${governanceRoot}/`)) {
871
+ return `docs/${relativePath.slice(`${governanceRoot}/`.length)}`;
872
+ }
873
+ return relativePath;
874
+ }
875
+ async function readPackageVersion(assetRoot) {
876
+ const packageJson = JSON.parse(await readFile(path.join(assetRoot, "package.json"), "utf-8"));
877
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
878
+ }
879
+ async function readInitSurfaceManifest(assetRoot) {
880
+ const manifestPath = path.join(assetRoot, "docs", "init-surface.manifest.json");
881
+ const raw = await readFile(manifestPath, "utf-8");
882
+ const manifest = JSON.parse(raw);
883
+ const initFullRequired = manifest.initFullRequired ?? [];
884
+ const initSurface = manifest.initSurface ?? {};
885
+ const entries = initFullRequired.map((relativePath) => ({
886
+ path: relativePath,
887
+ mode: initSurface[relativePath] ?? inferInitSurfaceMode(relativePath),
888
+ }));
889
+ if (!entries.some((entry) => entry.path === INIT_SURFACE_STATE_PATH)) {
890
+ entries.push({ path: INIT_SURFACE_STATE_PATH, mode: "state" });
891
+ }
892
+ return { manifest, raw, sha256: sha256Text(raw), entries };
893
+ }
894
+ function inferInitSurfaceMode(relativePath) {
895
+ if (relativePath === INIT_SURFACE_STATE_PATH)
896
+ return "state";
897
+ if (relativePath === ".harness/tasks" ||
898
+ relativePath === ".harness/dag-runs/active" ||
899
+ relativePath.endsWith("/")) {
900
+ return "directory";
901
+ }
902
+ if (relativePath === "README.md" || relativePath === "AGENTS.md")
903
+ return "managed-block";
904
+ if (relativePath === "harness.json" ||
905
+ relativePath.startsWith("scripts/") ||
906
+ relativePath.startsWith(".harness/prompts/") ||
907
+ relativePath.startsWith("docs/README.md") ||
908
+ relativePath.startsWith("docs/development-principles.md") ||
909
+ relativePath.startsWith("docs/feature-workflow.md") ||
910
+ relativePath.startsWith("docs/verification-matrix.md") ||
911
+ relativePath.startsWith("docs/loop-agent-harness.md") ||
912
+ relativePath.startsWith("docs/architecture/runtime-boundaries.md")) {
913
+ return "generated";
914
+ }
915
+ return "copied";
916
+ }
917
+ async function buildDesiredSurfaceContent(input) {
918
+ const manifestPath = targetPathToManifestPath(input.entry.path, input.governanceRoot);
919
+ if (input.entry.mode === "directory" || input.entry.mode === "state")
920
+ return {};
921
+ if (manifestPath === "README.md") {
922
+ return {
923
+ content: buildManagedReadmeBlock({
924
+ projectName: input.projectName,
925
+ governanceRoot: input.governanceRoot,
926
+ }),
927
+ };
928
+ }
929
+ if (manifestPath === "AGENTS.md") {
930
+ return {
931
+ content: buildManagedAgentsBlock({
932
+ projectName: input.projectName,
933
+ governanceRoot: input.governanceRoot,
934
+ }),
935
+ };
936
+ }
937
+ if (manifestPath === "harness.json") {
938
+ const template = await readJsonIfExists(path.join(input.assetRoot, "harness.json"));
939
+ return {
940
+ content: `${JSON.stringify(buildHarness({
941
+ existing: {},
942
+ template,
943
+ projectName: input.projectName,
944
+ governanceRoot: input.governanceRoot,
945
+ }), null, 2)}\n`,
946
+ };
947
+ }
948
+ if (manifestPath.startsWith("scripts/")) {
949
+ const scripts = buildInitScriptFiles(input.governanceRoot);
950
+ return { content: scripts[manifestPath] };
951
+ }
952
+ if (manifestPath.startsWith(".harness/prompts/")) {
953
+ const name = path.basename(manifestPath);
954
+ return { content: COMPAT_PROMPTS[name] };
955
+ }
956
+ if (manifestPath.startsWith("docs/")) {
957
+ const doc = manifestPath.slice("docs/".length);
958
+ const generated = buildGeneratedCoreDoc({
959
+ doc,
960
+ projectName: input.projectName,
961
+ governanceRoot: input.governanceRoot,
962
+ });
963
+ if (generated !== undefined)
964
+ return { content: generated };
965
+ }
966
+ const sourcePath = path.join(input.assetRoot, manifestPath);
967
+ if (await exists(sourcePath))
968
+ return { content: await readFile(sourcePath, "utf-8"), sourcePath };
969
+ return {};
970
+ }
971
+ function extractManagedBlock(content) {
972
+ const start = content.indexOf(MANAGED_BLOCK_START);
973
+ const end = content.indexOf(MANAGED_BLOCK_END);
974
+ if (start < 0 || end <= start)
975
+ return undefined;
976
+ return content.slice(start, end + MANAGED_BLOCK_END.length);
977
+ }
978
+ async function readExistingSurfaceState(repoRoot) {
979
+ const filePath = path.join(repoRoot, INIT_SURFACE_STATE_PATH);
980
+ if (!(await exists(filePath)))
981
+ return undefined;
982
+ const parsed = JSON.parse(await readFile(filePath, "utf-8"));
983
+ if (!isRecord(parsed) || parsed.schemaVersion !== 1)
984
+ return undefined;
985
+ return parsed;
986
+ }
987
+ async function buildCurrentSurfaceState(input) {
988
+ const assetRoot = await findPackageRoot();
989
+ const controllerVersion = await readPackageVersion(assetRoot);
990
+ const manifest = await readInitSurfaceManifest(assetRoot);
991
+ const files = {};
992
+ for (const manifestEntry of manifest.entries) {
993
+ const targetRelativePath = normalizeRelativePath(manifestPathToTargetPath(manifestEntry.path, input.governanceRoot));
994
+ const entry = { ...manifestEntry, path: targetRelativePath };
995
+ const targetPath = path.join(input.repoRoot, targetRelativePath);
996
+ const targetStat = await stat(targetPath).catch(() => null);
997
+ const desired = await buildDesiredSurfaceContent({
998
+ assetRoot,
999
+ repoRoot: input.repoRoot,
1000
+ projectName: input.projectName,
1001
+ governanceRoot: input.governanceRoot,
1002
+ entry,
1003
+ });
1004
+ const sourceSha256 = desired.content === undefined ? undefined : sha256Text(desired.content);
1005
+ const base = {
1006
+ status: targetStat ? "present" : "missing",
1007
+ relationship: "missing-from-target",
1008
+ sourceSha256,
1009
+ sourcePath: desired.sourcePath ? repoRelative(assetRoot, desired.sourcePath) : undefined,
1010
+ mode: entry.mode,
1011
+ };
1012
+ if (entry.mode === "state") {
1013
+ files[targetRelativePath] = {
1014
+ ...base,
1015
+ status: "present",
1016
+ relationship: "state-file",
1017
+ };
1018
+ continue;
1019
+ }
1020
+ if (!targetStat) {
1021
+ files[targetRelativePath] = base;
1022
+ continue;
1023
+ }
1024
+ if (entry.mode === "directory") {
1025
+ files[targetRelativePath] = {
1026
+ ...base,
1027
+ relationship: targetStat.isDirectory() ? "directory-present" : "local-existing-unknown",
1028
+ };
1029
+ continue;
1030
+ }
1031
+ const current = await readFile(targetPath);
1032
+ const currentSha256 = sha256Buffer(current);
1033
+ if (entry.mode === "managed-block") {
1034
+ const currentBlock = extractManagedBlock(current.toString("utf-8"));
1035
+ files[targetRelativePath] = {
1036
+ ...base,
1037
+ currentSha256,
1038
+ relationship: currentBlock && sourceSha256 && sha256Text(currentBlock) === sourceSha256
1039
+ ? "managed-block-current"
1040
+ : "managed-block-present",
1041
+ };
1042
+ continue;
1043
+ }
1044
+ files[targetRelativePath] = {
1045
+ ...base,
1046
+ currentSha256,
1047
+ relationship: sourceSha256 && currentSha256 === sourceSha256
1048
+ ? entry.mode === "generated"
1049
+ ? "matches-current-generated"
1050
+ : "matches-current-package"
1051
+ : "local-existing-unknown",
1052
+ };
1053
+ }
1054
+ return {
1055
+ schemaVersion: 1,
1056
+ controllerVersion,
1057
+ stateKind: input.stateKind,
1058
+ generatedAt: input.generatedAt ?? new Date().toISOString(),
1059
+ manifestSha256: manifest.sha256,
1060
+ files,
1061
+ };
1062
+ }
1063
+ async function writeInitSurfaceState(input) {
1064
+ const state = await buildCurrentSurfaceState(input);
1065
+ const target = path.join(input.repoRoot, INIT_SURFACE_STATE_PATH);
1066
+ await mkdir(path.dirname(target), { recursive: true });
1067
+ await writeFile(target, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
1068
+ return state;
1069
+ }
1070
+ async function resolveInitProjectContext(input) {
1071
+ const harness = await readJsonIfExists(path.join(input.repoRoot, "harness.json"));
1072
+ return {
1073
+ projectName: input.projectName ??
1074
+ (typeof harness.project === "string" ? harness.project : undefined) ??
1075
+ path.basename(input.repoRoot),
1076
+ governanceRoot: input.governanceRoot ??
1077
+ (typeof harness.governanceRoot === "string" ? harness.governanceRoot : undefined) ??
1078
+ "docs",
1079
+ };
1080
+ }
849
1081
  function buildManagedReadmeBlock(input) {
850
1082
  return [
851
1083
  MANAGED_BLOCK_START,
@@ -1389,8 +1621,277 @@ export async function initializeLoopAgentProject(options) {
1389
1621
  }
1390
1622
  await ensureHarnessDirs(repoRoot, written);
1391
1623
  await writeCompatPrompts({ repoRoot, merge, written, skipped });
1624
+ await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "recorded" });
1625
+ written.push(INIT_SURFACE_STATE_PATH);
1392
1626
  return { repoRoot, projectName, governanceRoot, profile, written, skipped };
1393
1627
  }
1628
+ function actionForMissing(pathName, state) {
1629
+ if (state.mode === "state")
1630
+ return undefined;
1631
+ if (state.mode === "directory") {
1632
+ return { type: "create-directory", path: pathName, reason: "required init directory is missing" };
1633
+ }
1634
+ if (state.mode === "copied") {
1635
+ return { type: "copy-missing", path: pathName, reason: "required bundled init file is missing" };
1636
+ }
1637
+ if (state.mode === "generated") {
1638
+ return { type: "write-generated-missing", path: pathName, reason: "required generated init file is missing" };
1639
+ }
1640
+ return { type: "refresh-managed-block", path: pathName, reason: "managed block file is missing or stale" };
1641
+ }
1642
+ function modelMergeTaskFor(pathName, state, allPaths) {
1643
+ return {
1644
+ path: pathName,
1645
+ reason: "target file exists but does not match the current loop-agent initialization surface",
1646
+ sourcePath: state.sourcePath,
1647
+ allowedPaths: [pathName],
1648
+ forbiddenPaths: [
1649
+ ...allPaths.filter((candidate) => candidate !== pathName),
1650
+ ".harness/dag-runs/**",
1651
+ ".harness/runs/**",
1652
+ ".git/**",
1653
+ ],
1654
+ mergeRules: [
1655
+ "Preserve target-project user content and project-specific commands.",
1656
+ "Adopt the current loop-agent initialization structure where it does not conflict with local intent.",
1657
+ "Do not delete user-authored sections merely because they differ from the package template.",
1658
+ "Keep writes inside allowedPaths only.",
1659
+ ],
1660
+ verification: [
1661
+ "loop-agent init check-update --repo-root . --json",
1662
+ "loop-agent init doctor --repo-root .",
1663
+ "loop-agent inspect --repo-root .",
1664
+ "bash scripts/check-repo.sh",
1665
+ ],
1666
+ };
1667
+ }
1668
+ function recommendedNextFor(report) {
1669
+ const next = [];
1670
+ if (report.surfaceState === "missing") {
1671
+ next.push("loop-agent init update --repo-root <target> --bootstrap-surface");
1672
+ }
1673
+ if (report.deterministicActions.some((action) => action.type !== "bootstrap-surface")) {
1674
+ next.push("loop-agent init update --repo-root <target> --apply-safe");
1675
+ }
1676
+ if (report.modelMergeTasks.length > 0) {
1677
+ next.push("loop-agent init check-update --repo-root <target> --markdown");
1678
+ }
1679
+ if (report.humanDecisions.length > 0) {
1680
+ next.push("Review humanDecisions before applying semantic or policy-sensitive changes.");
1681
+ }
1682
+ if (next.length === 0)
1683
+ next.push("No init update actions are needed.");
1684
+ return next;
1685
+ }
1686
+ export async function checkInitUpdate(input) {
1687
+ const repoRoot = path.resolve(input.repoRoot);
1688
+ const { projectName, governanceRoot } = await resolveInitProjectContext({
1689
+ repoRoot,
1690
+ projectName: input.projectName,
1691
+ governanceRoot: input.governanceRoot,
1692
+ });
1693
+ const recordedState = await readExistingSurfaceState(repoRoot);
1694
+ const surfaceState = recordedState?.stateKind ?? "missing";
1695
+ const currentState = await buildCurrentSurfaceState({
1696
+ repoRoot,
1697
+ projectName,
1698
+ governanceRoot,
1699
+ stateKind: recordedState?.stateKind ?? "inferred-baseline",
1700
+ });
1701
+ const allPaths = Object.keys(currentState.files).sort();
1702
+ const deterministicActions = [];
1703
+ const modelMergeTasks = [];
1704
+ const humanDecisions = [];
1705
+ if (!recordedState) {
1706
+ deterministicActions.push({
1707
+ type: "bootstrap-surface",
1708
+ path: INIT_SURFACE_STATE_PATH,
1709
+ reason: "target project has no recorded init surface baseline",
1710
+ });
1711
+ }
1712
+ for (const [pathName, state] of Object.entries(currentState.files)) {
1713
+ if (state.relationship === "missing-from-target") {
1714
+ const action = actionForMissing(pathName, state);
1715
+ if (action)
1716
+ deterministicActions.push(action);
1717
+ continue;
1718
+ }
1719
+ if (state.relationship === "managed-block-present") {
1720
+ deterministicActions.push({
1721
+ type: "refresh-managed-block",
1722
+ path: pathName,
1723
+ reason: "managed block is missing or differs from the current package block",
1724
+ });
1725
+ continue;
1726
+ }
1727
+ if (state.relationship === "local-existing-unknown") {
1728
+ if (state.mode === "directory") {
1729
+ humanDecisions.push({
1730
+ path: pathName,
1731
+ reason: "required init directory path exists but is not a directory",
1732
+ });
1733
+ }
1734
+ else {
1735
+ modelMergeTasks.push(modelMergeTaskFor(pathName, state, allPaths));
1736
+ }
1737
+ }
1738
+ }
1739
+ const partial = {
1740
+ repoRoot,
1741
+ controllerVersion: currentState.controllerVersion,
1742
+ surfaceState,
1743
+ deterministicActions,
1744
+ modelMergeTasks,
1745
+ humanDecisions,
1746
+ summary: {
1747
+ missing: deterministicActions.filter((action) => action.type !== "bootstrap-surface").length,
1748
+ modelMerge: modelMergeTasks.length,
1749
+ humanDecision: humanDecisions.length,
1750
+ },
1751
+ };
1752
+ const recommendedNext = recommendedNextFor(partial);
1753
+ return {
1754
+ ...partial,
1755
+ ok: deterministicActions.length === 0 &&
1756
+ modelMergeTasks.length === 0 &&
1757
+ humanDecisions.length === 0,
1758
+ recommendedNext,
1759
+ };
1760
+ }
1761
+ async function applySafeAction(input) {
1762
+ const assetRoot = await findPackageRoot();
1763
+ const entry = {
1764
+ path: input.action.path,
1765
+ mode: input.action.type === "create-directory"
1766
+ ? "directory"
1767
+ : input.action.type === "copy-missing"
1768
+ ? "copied"
1769
+ : input.action.type === "refresh-managed-block"
1770
+ ? "managed-block"
1771
+ : "generated",
1772
+ };
1773
+ const target = path.join(input.repoRoot, input.action.path);
1774
+ if (input.action.type === "create-directory") {
1775
+ if (await exists(target))
1776
+ return false;
1777
+ await mkdir(target, { recursive: true });
1778
+ return true;
1779
+ }
1780
+ const desired = await buildDesiredSurfaceContent({
1781
+ assetRoot,
1782
+ repoRoot: input.repoRoot,
1783
+ projectName: input.projectName,
1784
+ governanceRoot: input.governanceRoot,
1785
+ entry,
1786
+ });
1787
+ if (desired.content === undefined)
1788
+ return false;
1789
+ await mkdir(path.dirname(target), { recursive: true });
1790
+ if (input.action.type === "refresh-managed-block") {
1791
+ 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);
1797
+ await writeFile(target, next, "utf-8");
1798
+ return true;
1799
+ }
1800
+ if (await exists(target))
1801
+ return false;
1802
+ await writeFile(target, desired.content, "utf-8");
1803
+ return true;
1804
+ }
1805
+ export async function applyInitUpdate(input) {
1806
+ const repoRoot = path.resolve(input.repoRoot);
1807
+ const { projectName, governanceRoot } = await resolveInitProjectContext({
1808
+ repoRoot,
1809
+ projectName: input.projectName,
1810
+ governanceRoot: input.governanceRoot,
1811
+ });
1812
+ const applied = [];
1813
+ const skipped = [];
1814
+ if (input.bootstrapSurface) {
1815
+ await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "inferred-baseline" });
1816
+ applied.push({
1817
+ type: "bootstrap-surface",
1818
+ path: INIT_SURFACE_STATE_PATH,
1819
+ reason: "wrote inferred init surface baseline",
1820
+ });
1821
+ }
1822
+ if (input.applySafe) {
1823
+ const report = await checkInitUpdate({ repoRoot, projectName, governanceRoot });
1824
+ for (const action of report.deterministicActions) {
1825
+ if (action.type === "bootstrap-surface") {
1826
+ skipped.push(action);
1827
+ continue;
1828
+ }
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;
1839
+ }
1840
+ if (await applySafeAction({ repoRoot, projectName, governanceRoot, action }))
1841
+ applied.push(action);
1842
+ else
1843
+ skipped.push(action);
1844
+ }
1845
+ await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "inferred-baseline" });
1846
+ }
1847
+ return {
1848
+ applied,
1849
+ skipped,
1850
+ report: await checkInitUpdate({ repoRoot, projectName, governanceRoot }),
1851
+ };
1852
+ }
1853
+ function formatCheckUpdateText(report) {
1854
+ return [
1855
+ `loop-agent init update check for ${report.repoRoot}`,
1856
+ `controllerVersion: ${report.controllerVersion}`,
1857
+ `surfaceState: ${report.surfaceState}`,
1858
+ `deterministicActions: ${report.deterministicActions.length}`,
1859
+ `modelMergeTasks: ${report.modelMergeTasks.length}`,
1860
+ `humanDecisions: ${report.humanDecisions.length}`,
1861
+ "recommendedNext:",
1862
+ ...report.recommendedNext.map((item) => `- ${item}`),
1863
+ ].join("\n");
1864
+ }
1865
+ function formatCheckUpdateMarkdown(report) {
1866
+ const lines = [
1867
+ "# loop-agent init check-update",
1868
+ "",
1869
+ `- repoRoot: \`${report.repoRoot}\``,
1870
+ `- controllerVersion: \`${report.controllerVersion}\``,
1871
+ `- surfaceState: \`${report.surfaceState}\``,
1872
+ "",
1873
+ "## Deterministic Actions",
1874
+ "",
1875
+ ...(report.deterministicActions.length
1876
+ ? report.deterministicActions.map((action) => `- \`${action.type}\` \`${action.path}\`: ${action.reason}`)
1877
+ : ["- None"]),
1878
+ "",
1879
+ "## Model Merge Tasks",
1880
+ "",
1881
+ ];
1882
+ if (report.modelMergeTasks.length === 0) {
1883
+ lines.push("- None", "");
1884
+ }
1885
+ else {
1886
+ for (const task of report.modelMergeTasks) {
1887
+ lines.push(`### ${task.path}`, "", task.reason, "", "allowedPaths:", ...task.allowedPaths.map((item) => `- ${item}`), "", "forbiddenPaths:", ...task.forbiddenPaths.map((item) => `- ${item}`), "", "mergeRules:", ...task.mergeRules.map((item) => `- ${item}`), "", "verification:", ...task.verification.map((item) => `- ${item}`), "");
1888
+ }
1889
+ }
1890
+ lines.push("## Human Decisions", "", ...(report.humanDecisions.length
1891
+ ? report.humanDecisions.map((decision) => `- \`${decision.path}\`: ${decision.reason}`)
1892
+ : ["- None"]), "", "## Recommended Next", "", ...report.recommendedNext.map((item) => `- ${item}`), "");
1893
+ return lines.join("\n");
1894
+ }
1394
1895
  export async function runInitDoctor(input) {
1395
1896
  const repoRoot = path.resolve(input.repoRoot);
1396
1897
  const checks = [];
@@ -1432,9 +1933,12 @@ function parseInitArgs(repoRoot, args) {
1432
1933
  let provider;
1433
1934
  let model;
1434
1935
  let json = false;
1936
+ let markdown = false;
1937
+ let bootstrapSurface = false;
1938
+ let applySafe = false;
1435
1939
  for (let i = 0; i < args.length; i += 1) {
1436
1940
  const arg = args[i];
1437
- if ((arg === "instructions" || arg === "doctor") && !subcommand) {
1941
+ if ((arg === "instructions" || arg === "doctor" || arg === "check-update" || arg === "update") && !subcommand) {
1438
1942
  subcommand = arg;
1439
1943
  continue;
1440
1944
  }
@@ -1464,6 +1968,12 @@ function parseInitArgs(repoRoot, args) {
1464
1968
  model = arg.slice("--model=".length);
1465
1969
  else if (arg === "--json")
1466
1970
  json = true;
1971
+ else if (arg === "--markdown")
1972
+ markdown = true;
1973
+ else if (arg === "--bootstrap-surface")
1974
+ bootstrapSurface = true;
1975
+ else if (arg === "--apply-safe")
1976
+ applySafe = true;
1467
1977
  else if (arg.startsWith("-"))
1468
1978
  throw new Error(`unknown init flag: ${arg}`);
1469
1979
  else
@@ -1471,7 +1981,20 @@ function parseInitArgs(repoRoot, args) {
1471
1981
  }
1472
1982
  if (profile !== "full" && profile !== "minimal")
1473
1983
  throw new Error("init --profile must be full or minimal");
1474
- return { repoRoot, projectName, governanceRoot, profile, merge, provider, model, subcommand, json };
1984
+ return {
1985
+ repoRoot,
1986
+ projectName,
1987
+ governanceRoot,
1988
+ profile,
1989
+ merge,
1990
+ provider,
1991
+ model,
1992
+ subcommand,
1993
+ json,
1994
+ markdown,
1995
+ bootstrapSurface,
1996
+ applySafe,
1997
+ };
1475
1998
  }
1476
1999
  export async function runInit(repoRoot, rawArgs) {
1477
2000
  const parsed = parseInitArgs(repoRoot, rawArgs);
@@ -1486,9 +2009,38 @@ export async function runInit(repoRoot, rawArgs) {
1486
2009
  process.exitCode = 1;
1487
2010
  return;
1488
2011
  }
2012
+ if (parsed.subcommand === "check-update") {
2013
+ const report = await checkInitUpdate(parsed);
2014
+ if (parsed.json)
2015
+ console.log(JSON.stringify(report, null, 2));
2016
+ else if (parsed.markdown)
2017
+ console.log(formatCheckUpdateMarkdown(report));
2018
+ else
2019
+ console.log(formatCheckUpdateText(report));
2020
+ return;
2021
+ }
2022
+ if (parsed.subcommand === "update") {
2023
+ if (!parsed.bootstrapSurface && !parsed.applySafe) {
2024
+ throw new Error("usage: init update [--bootstrap-surface] [--apply-safe]");
2025
+ }
2026
+ const result = await applyInitUpdate(parsed);
2027
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : formatInitUpdateResult(result));
2028
+ return;
2029
+ }
1489
2030
  const result = await initializeLoopAgentProject(parsed);
1490
2031
  console.log(parsed.json ? JSON.stringify(result, null, 2) : formatInitResult(result));
1491
2032
  }
2033
+ function formatInitUpdateResult(result) {
2034
+ return [
2035
+ "loop-agent init update complete",
2036
+ `applied: ${result.applied.length}`,
2037
+ `skipped: ${result.skipped.length}`,
2038
+ `remaining deterministicActions: ${result.report.deterministicActions.length}`,
2039
+ `remaining modelMergeTasks: ${result.report.modelMergeTasks.length}`,
2040
+ "recommendedNext:",
2041
+ ...result.report.recommendedNext.map((item) => `- ${item}`),
2042
+ ].join("\n");
2043
+ }
1492
2044
  function formatInitResult(result) {
1493
2045
  return [
1494
2046
  `Initialized loop-agent harness at ${result.repoRoot}`,
@@ -27,7 +27,7 @@ function renderLoopTemplate(template, iteration) {
27
27
  .replace(/\{\{\s*iteration\s*\}\}/g, String(iteration))
28
28
  .replace(/\{\{\s*iterationStatus\s*\}\}/g, iterationStatus);
29
29
  }
30
- function buildLoopBodyChildTask(input) {
30
+ export function buildLoopBodyChildTask(input) {
31
31
  const { parent, bodyTask, iteration, nodeId, bodyIdMap } = input;
32
32
  const mappedDepends = bodyTask.dependsOn.map((depId) => bodyIdMap.get(depId) ?? depId);
33
33
  return {
@@ -37,6 +37,7 @@ function buildLoopBodyChildTask(input) {
37
37
  subtask_prompt: renderLoopTemplate(bodyTask.subtaskPromptTemplate, iteration),
38
38
  executor: bodyTask.executor,
39
39
  role: bodyTask.role,
40
+ skills: bodyTask.skills,
40
41
  writePolicy: bodyTask.writePolicy,
41
42
  allowedPaths: bodyTask.allowedPaths,
42
43
  forbiddenPaths: bodyTask.forbiddenPaths,
@@ -18,6 +18,7 @@ export function buildExpandedChildTask(input) {
18
18
  subtask_prompt: subtaskPrompt,
19
19
  executor: child.executor,
20
20
  role: child.role,
21
+ skills: child.skills,
21
22
  writePolicy: child.writePolicy,
22
23
  allowedPaths: renderDynamicPatternList(child.allowedPaths, item, index, expansion.itemName) ?? [],
23
24
  forbiddenPaths: renderDynamicPatternList(child.forbiddenPaths, item, index, expansion.itemName) ?? [],
@@ -22,9 +22,9 @@ const HYBRID_DEFAULTS = {
22
22
  };
23
23
  const HYBRID_SKILLS_BY_ROLE = {
24
24
  planner: ["loop-agent"],
25
- scout: ["ai-engineering-context"],
26
- implementer: ["verification-before-completion"],
27
- reviewer: ["requesting-code-review"],
25
+ scout: ["ai-engineering-context", "codebase-scout"],
26
+ implementer: ["test-driven-development", "verification-before-completion"],
27
+ reviewer: ["requesting-code-review", "code-review-core"],
28
28
  verifier: ["verification-before-completion", "systematic-debugging"],
29
29
  closeout: ["loop-agent", "verification-before-completion"],
30
30
  };
@@ -4,10 +4,10 @@
4
4
  */
5
5
  export const DEFAULT_SKILLS_BY_ROLE = {
6
6
  planner: ["loop-agent"],
7
- scout: ["ai-engineering-context"],
8
- implementer: ["verification-before-completion"],
7
+ scout: ["ai-engineering-context", "codebase-scout"],
8
+ implementer: ["test-driven-development", "verification-before-completion"],
9
9
  verifier: ["verification-before-completion", "systematic-debugging"],
10
- reviewer: ["requesting-code-review"],
10
+ reviewer: ["requesting-code-review", "code-review-core"],
11
11
  supervisor: ["verification-before-completion", "loop-agent"],
12
12
  closeout: ["loop-agent", "verification-before-completion"],
13
13
  };
@@ -94,6 +94,7 @@ export const dagStaticConfigSchema = z.object({
94
94
  export const dagDynamicExpansionChildTaskSchema = z.object({
95
95
  executor: dagNodeExecutorSchema.default("pi"),
96
96
  role: dagRoleSchema.optional(),
97
+ skills: z.array(z.string()).optional(),
97
98
  complexity: dagComplexitySchema.default("LOW"),
98
99
  subtaskPromptTemplate: z.string().min(1),
99
100
  outputContract: z.string().optional(),
@@ -153,6 +154,7 @@ export const dagDynamicLoopBodyTaskSchema = z.object({
153
154
  dependsOn: z.array(z.string()).default([]),
154
155
  executor: dagNodeExecutorSchema.default("pi"),
155
156
  role: dagRoleSchema.optional(),
157
+ skills: z.array(z.string()).optional(),
156
158
  complexity: dagComplexitySchema.default("LOW"),
157
159
  subtaskPromptTemplate: z.string().min(1),
158
160
  outputContract: z.string().optional(),