@tea-agent/loop-agent 0.16.1-beta.2 → 0.16.1

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 (94) hide show
  1. package/AGENTS.md +4 -8
  2. package/CHANGELOG.md +55 -18
  3. package/README.md +76 -299
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/dag-reconcile-run.js +3 -116
  24. package/dist/commands/eval.js +1176 -13
  25. package/dist/commands/init.js +7 -1
  26. package/dist/executors/dag-pi-executor.js +4 -44
  27. package/dist/executors/pi-sdk-executor.js +3 -3
  28. package/dist/executors/shell-executor.js +1 -1
  29. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  30. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  31. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  32. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  33. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  34. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  35. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  36. package/dist/records/promotion.js +3 -1
  37. package/dist/worker/cli.js +83 -0
  38. package/dist/worker/delivery/git-transaction.js +75 -0
  39. package/dist/worker/delivery/verification-bundle.js +13 -2
  40. package/dist/worker/feature/review.js +3 -2
  41. package/dist/worker/observe/static/dag-helpers.js +0 -62
  42. package/dist/worker/observe/static/styles.css +18 -55
  43. package/dist/worker/observe/static/views/dag.js +13 -5
  44. package/dist/worker/outcomes/adapters.js +4 -1
  45. package/dist/worker/outcomes/declared-artifacts.js +103 -0
  46. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  47. package/dist/worker/outcomes/gate.js +10 -11
  48. package/dist/worker/outcomes/projector.js +30 -4
  49. package/dist/worker/outcomes/types.js +3 -0
  50. package/dist/worker/pool/reconcile.js +285 -0
  51. package/dist/worker/run-task/run-task.js +81 -4
  52. package/dist/worker/runner/run-ready.js +25 -2
  53. package/dist/worker/task-graph/ready-planner.js +14 -8
  54. package/dist/worker/task-graph/task-graph-schema.js +5 -3
  55. package/dist/workflows/dag/budget-enforcement.js +67 -0
  56. package/dist/workflows/dag/context-policy.js +137 -0
  57. package/dist/workflows/dag/failure-routing.js +7 -0
  58. package/dist/workflows/dag/frontend-implementation-contract.js +0 -77
  59. package/dist/workflows/dag/init-hybrid.js +33 -53
  60. package/dist/workflows/dag/knowledge-curator.js +3 -0
  61. package/dist/workflows/dag/node-execution.js +11 -4
  62. package/dist/workflows/dag/prompt.js +1 -1
  63. package/dist/workflows/dag/reconcile-run.js +121 -0
  64. package/dist/workflows/dag/report.js +12 -0
  65. package/dist/workflows/dag/runner.js +43 -16
  66. package/dist/workflows/dag/skill-snapshot.js +11 -7
  67. package/dist/workflows/dag/types.js +18 -1
  68. package/dist/workflows/dag/validate.js +15 -1
  69. package/docs/README.md +3 -1
  70. package/docs/architecture/runtime-boundaries.md +3 -2
  71. package/docs/init-surface.manifest.json +4 -0
  72. package/docs/local-development-environment.md +52 -0
  73. package/docs/templates/agent-dag.schema.json +0 -5
  74. package/docs/templates/agent-dag.supervised-implementation.json +23 -4
  75. package/docs/templates/branch-merge-report.md +14 -0
  76. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  77. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  78. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  79. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  80. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  81. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  82. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  83. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  84. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  85. package/docs/templates/product-line/AGENTS.md +1 -0
  86. package/docs/templates/product-line/README.md +17 -0
  87. package/docs/templates/product-line/acceptance.yaml +9 -0
  88. package/docs/templates/product-line/feature.yaml +11 -0
  89. package/docs/templates/product-line/task-graph.yaml +8 -0
  90. package/docs/templates/product-line/task.yaml +4 -0
  91. package/package.json +2 -1
  92. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  93. package/skills/loop-agent/references/command-reference.md +5 -0
  94. package/skills/loop-agent/references/hybrid-dag.md +3 -3
@@ -0,0 +1,124 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { experimentIdSchema, experimentRecordSchema, } from "../../application/evaluation/types.js";
4
+ import { sha256Hex } from "../../application/evaluation/candidate-hash.js";
5
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
6
+ import { EVALUATION_ROOT } from "./store.js";
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
9
+ export function assertExperimentId(value) {
10
+ experimentIdSchema.parse(value);
11
+ }
12
+ export function experimentDir(repoRoot, experimentId) {
13
+ assertExperimentId(experimentId);
14
+ return path.join(repoRoot, EVALUATION_ROOT, "experiments", experimentId);
15
+ }
16
+ export function experimentManifestPath(repoRoot, experimentId) {
17
+ return path.join(experimentDir(repoRoot, experimentId), "manifest.json");
18
+ }
19
+ /** Repo-relative content path for experimental learned proposal (allowed as contentRef). */
20
+ export function experimentProposalRepoPath(experimentId) {
21
+ assertExperimentId(experimentId);
22
+ return path.posix.join("ai_workspace/loop-agent/evaluation/experiments", experimentId, "learned-proposal.md");
23
+ }
24
+ async function pathExists(filePath) {
25
+ try {
26
+ await access(filePath);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function serializeRecord(record) {
34
+ return `${JSON.stringify(record, null, 2)}\n`;
35
+ }
36
+ function recordsEqual(a, b) {
37
+ return JSON.stringify(a) === JSON.stringify(b);
38
+ }
39
+ export async function readExperimentRecord(repoRoot, experimentId) {
40
+ assertExperimentId(experimentId);
41
+ const manifestPath = experimentManifestPath(repoRoot, experimentId);
42
+ const rawText = await readFile(manifestPath, "utf-8");
43
+ const expected = (await readFile(path.join(experimentDir(repoRoot, experimentId), MANIFEST_INTEGRITY_FILE), "utf-8")).trim();
44
+ const actual = sha256Hex(rawText);
45
+ if (expected !== actual) {
46
+ throw new Error(`experiment manifest integrity mismatch for ${experimentId}`);
47
+ }
48
+ const stored = experimentRecordSchema.parse(JSON.parse(rawText));
49
+ if (stored.experimentId !== experimentId) {
50
+ throw new Error(`experiment identity mismatch: directory=${experimentId}, manifest=${stored.experimentId}`);
51
+ }
52
+ return stored;
53
+ }
54
+ export async function listExperimentIds(repoRoot) {
55
+ const root = path.join(repoRoot, EVALUATION_ROOT, "experiments");
56
+ try {
57
+ const entries = await readdir(root, { withFileTypes: true });
58
+ return entries
59
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
60
+ .map((entry) => entry.name)
61
+ .sort();
62
+ }
63
+ catch (error) {
64
+ const code = error.code;
65
+ if (code === "ENOENT")
66
+ return [];
67
+ throw error;
68
+ }
69
+ }
70
+ export async function writeExperimentRecord(input) {
71
+ const { repoRoot, record } = input;
72
+ assertExperimentId(record.experimentId);
73
+ const parsed = experimentRecordSchema.parse(record);
74
+ const manifestPath = experimentManifestPath(repoRoot, parsed.experimentId);
75
+ if (await pathExists(manifestPath)) {
76
+ const existing = await readExperimentRecord(repoRoot, parsed.experimentId);
77
+ if (!recordsEqual(existing, parsed)) {
78
+ throw new Error(`experiment already exists with different content: ${parsed.experimentId}`);
79
+ }
80
+ return { record: existing, idempotent: true, manifestPath };
81
+ }
82
+ const root = path.dirname(experimentDir(repoRoot, parsed.experimentId));
83
+ await mkdir(root, { recursive: true });
84
+ const stagingDir = await mkdtemp(path.join(root, `.${parsed.experimentId}.register-`));
85
+ try {
86
+ await writeJsonAtomic(path.join(stagingDir, "manifest.json"), parsed, {
87
+ repoRoot,
88
+ });
89
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serializeRecord(parsed))}\n`, { repoRoot });
90
+ try {
91
+ await rename(stagingDir, experimentDir(repoRoot, parsed.experimentId));
92
+ }
93
+ catch (error) {
94
+ const code = error.code;
95
+ const isCreateRace = code === "EEXIST" ||
96
+ code === "ENOTEMPTY" ||
97
+ (code === "EPERM" &&
98
+ (await pathExists(experimentDir(repoRoot, parsed.experimentId))));
99
+ if (!isCreateRace)
100
+ throw error;
101
+ await rm(stagingDir, { recursive: true, force: true });
102
+ const existing = await readExperimentRecord(repoRoot, parsed.experimentId);
103
+ if (!recordsEqual(existing, parsed)) {
104
+ throw new Error(`experiment already exists with different content: ${parsed.experimentId}`);
105
+ }
106
+ return { record: existing, idempotent: true, manifestPath };
107
+ }
108
+ }
109
+ catch (error) {
110
+ await rm(stagingDir, { recursive: true, force: true });
111
+ throw error;
112
+ }
113
+ return { record: parsed, idempotent: false, manifestPath };
114
+ }
115
+ export async function updateExperimentRecord(input) {
116
+ const parsed = experimentRecordSchema.parse(input.record);
117
+ const manifestPath = experimentManifestPath(input.repoRoot, parsed.experimentId);
118
+ if (!(await pathExists(manifestPath))) {
119
+ throw new Error(`experiment not found: ${parsed.experimentId}`);
120
+ }
121
+ await writeJsonAtomic(manifestPath, parsed, { repoRoot: input.repoRoot });
122
+ await writeTextAtomic(path.join(experimentDir(input.repoRoot, parsed.experimentId), MANIFEST_INTEGRITY_FILE), `${sha256Hex(serializeRecord(parsed))}\n`, { repoRoot: input.repoRoot });
123
+ return manifestPath;
124
+ }
@@ -0,0 +1,82 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ignitionIdSchema, ignitionReportSchema, } from "../../application/evaluation/types.js";
4
+ import { sha256Hex } from "../../application/evaluation/candidate-hash.js";
5
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
6
+ import { EVALUATION_ROOT } from "./store.js";
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
9
+ export function assertIgnitionId(value) {
10
+ ignitionIdSchema.parse(value);
11
+ }
12
+ export function ignitionDir(repoRoot, ignitionId) {
13
+ assertIgnitionId(ignitionId);
14
+ return path.join(repoRoot, EVALUATION_ROOT, "ignition", ignitionId);
15
+ }
16
+ export function ignitionReportPath(repoRoot, ignitionId) {
17
+ return path.join(ignitionDir(repoRoot, ignitionId), "report.json");
18
+ }
19
+ async function pathExists(filePath) {
20
+ try {
21
+ await access(filePath);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function serialize(report) {
29
+ return `${JSON.stringify(report, null, 2)}\n`;
30
+ }
31
+ export async function readIgnitionReport(repoRoot, ignitionId) {
32
+ assertIgnitionId(ignitionId);
33
+ const reportPath = ignitionReportPath(repoRoot, ignitionId);
34
+ if (!(await pathExists(reportPath)))
35
+ return undefined;
36
+ const rawText = await readFile(reportPath, "utf-8");
37
+ const expected = (await readFile(path.join(ignitionDir(repoRoot, ignitionId), MANIFEST_INTEGRITY_FILE), "utf-8")).trim();
38
+ if (sha256Hex(rawText) !== expected) {
39
+ throw new Error(`ignition report integrity mismatch for ${ignitionId}`);
40
+ }
41
+ return ignitionReportSchema.parse(JSON.parse(rawText));
42
+ }
43
+ export async function writeIgnitionReport(input) {
44
+ const parsed = ignitionReportSchema.parse(input.report);
45
+ const reportPath = ignitionReportPath(input.repoRoot, parsed.ignitionId);
46
+ const root = path.dirname(ignitionDir(input.repoRoot, parsed.ignitionId));
47
+ await mkdir(root, { recursive: true });
48
+ if (await pathExists(reportPath)) {
49
+ await writeJsonAtomic(reportPath, parsed, { repoRoot: input.repoRoot });
50
+ await writeTextAtomic(path.join(ignitionDir(input.repoRoot, parsed.ignitionId), MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
51
+ return reportPath;
52
+ }
53
+ const stagingDir = await mkdtemp(path.join(root, `.${parsed.ignitionId}.register-`));
54
+ try {
55
+ await writeJsonAtomic(path.join(stagingDir, "report.json"), parsed, {
56
+ repoRoot: input.repoRoot,
57
+ });
58
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
59
+ await rename(stagingDir, ignitionDir(input.repoRoot, parsed.ignitionId));
60
+ }
61
+ catch (error) {
62
+ await rm(stagingDir, { recursive: true, force: true });
63
+ throw error;
64
+ }
65
+ return reportPath;
66
+ }
67
+ export async function listIgnitionIds(repoRoot) {
68
+ const root = path.join(repoRoot, EVALUATION_ROOT, "ignition");
69
+ try {
70
+ const entries = await readdir(root, { withFileTypes: true });
71
+ return entries
72
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
73
+ .map((entry) => entry.name)
74
+ .sort();
75
+ }
76
+ catch (error) {
77
+ const code = error.code;
78
+ if (code === "ENOENT")
79
+ return [];
80
+ throw error;
81
+ }
82
+ }
@@ -0,0 +1,145 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertPrivateVerifierHash, computePrivateVerifierManifestHash, } from "../../application/evaluation/campaign-hash.js";
4
+ import { formatContentSha, normalizeContentSha, sha256Hex, } from "../../application/evaluation/candidate-hash.js";
5
+ import { privateVerifierIdSchema, privateVerifierManifestInputSchema, privateVerifierManifestSchema, } from "../../application/evaluation/types.js";
6
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
7
+ import { EVALUATION_ROOT } from "./store.js";
8
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
10
+ export function assertPrivateVerifierId(value) {
11
+ privateVerifierIdSchema.parse(value);
12
+ }
13
+ export function privateVerifierDir(repoRoot, privateVerifierId) {
14
+ assertPrivateVerifierId(privateVerifierId);
15
+ return path.join(repoRoot, EVALUATION_ROOT, "private-verifiers", privateVerifierId);
16
+ }
17
+ export function privateVerifierManifestPath(repoRoot, privateVerifierId) {
18
+ return path.join(privateVerifierDir(repoRoot, privateVerifierId), "manifest.json");
19
+ }
20
+ async function pathExists(filePath) {
21
+ try {
22
+ await access(filePath);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function serializeManifest(manifest) {
30
+ return `${JSON.stringify(manifest, null, 2)}\n`;
31
+ }
32
+ export async function materializePrivateVerifierManifest(raw) {
33
+ const input = privateVerifierManifestInputSchema.parse(raw);
34
+ const withoutHash = {
35
+ schemaVersion: 1,
36
+ privateVerifierId: input.privateVerifierId,
37
+ createdAt: input.createdAt,
38
+ ...(input.description !== undefined
39
+ ? { description: input.description }
40
+ : {}),
41
+ checks: input.checks,
42
+ };
43
+ const manifestHash = computePrivateVerifierManifestHash(withoutHash);
44
+ if (input.manifestHash) {
45
+ const provided = formatContentSha(normalizeContentSha(input.manifestHash));
46
+ if (provided !== manifestHash) {
47
+ throw new Error(`privateVerifier manifestHash mismatch: expected ${manifestHash}, got ${provided}`);
48
+ }
49
+ }
50
+ return privateVerifierManifestSchema.parse({
51
+ ...withoutHash,
52
+ manifestHash,
53
+ });
54
+ }
55
+ export async function loadPrivateVerifierManifestFromPath(repoRoot, manifestPath) {
56
+ const resolved = path.isAbsolute(manifestPath)
57
+ ? manifestPath
58
+ : path.resolve(repoRoot, manifestPath);
59
+ const raw = JSON.parse(await readFile(resolved, "utf-8"));
60
+ return privateVerifierManifestInputSchema.parse(raw);
61
+ }
62
+ export async function readPrivateVerifierManifest(repoRoot, privateVerifierId) {
63
+ assertPrivateVerifierId(privateVerifierId);
64
+ const manifestPath = privateVerifierManifestPath(repoRoot, privateVerifierId);
65
+ const rawText = await readFile(manifestPath, "utf-8");
66
+ const integrityPath = path.join(privateVerifierDir(repoRoot, privateVerifierId), MANIFEST_INTEGRITY_FILE);
67
+ const expected = (await readFile(integrityPath, "utf-8")).trim();
68
+ const actual = sha256Hex(rawText);
69
+ if (expected !== actual) {
70
+ throw new Error(`privateVerifier manifest integrity mismatch for ${privateVerifierId}`);
71
+ }
72
+ const stored = privateVerifierManifestSchema.parse(JSON.parse(rawText));
73
+ if (stored.privateVerifierId !== privateVerifierId) {
74
+ throw new Error(`privateVerifier identity mismatch: directory=${privateVerifierId}, manifest=${stored.privateVerifierId}`);
75
+ }
76
+ assertPrivateVerifierHash(stored);
77
+ return stored;
78
+ }
79
+ export async function listPrivateVerifierIds(repoRoot) {
80
+ const root = path.join(repoRoot, EVALUATION_ROOT, "private-verifiers");
81
+ try {
82
+ const entries = await readdir(root, { withFileTypes: true });
83
+ return entries
84
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
85
+ .map((entry) => entry.name)
86
+ .sort();
87
+ }
88
+ catch (error) {
89
+ const code = error.code;
90
+ if (code === "ENOENT")
91
+ return [];
92
+ throw error;
93
+ }
94
+ }
95
+ function manifestsEqual(a, b) {
96
+ return (a.privateVerifierId === b.privateVerifierId &&
97
+ a.manifestHash === b.manifestHash &&
98
+ a.createdAt === b.createdAt &&
99
+ (a.description ?? "") === (b.description ?? "") &&
100
+ JSON.stringify(a.checks) === JSON.stringify(b.checks));
101
+ }
102
+ export async function registerPrivateVerifierManifest(input) {
103
+ const { repoRoot, manifest } = input;
104
+ assertPrivateVerifierId(manifest.privateVerifierId);
105
+ const manifestPath = privateVerifierManifestPath(repoRoot, manifest.privateVerifierId);
106
+ if (await pathExists(manifestPath)) {
107
+ const existing = await readPrivateVerifierManifest(repoRoot, manifest.privateVerifierId);
108
+ if (!manifestsEqual(existing, manifest)) {
109
+ throw new Error(`privateVerifier already exists with different content: ${manifest.privateVerifierId}`);
110
+ }
111
+ return { manifest: existing, idempotent: true, manifestPath };
112
+ }
113
+ const root = path.dirname(privateVerifierDir(repoRoot, manifest.privateVerifierId));
114
+ await mkdir(root, { recursive: true });
115
+ const stagingDir = await mkdtemp(path.join(root, `.${manifest.privateVerifierId}.register-`));
116
+ try {
117
+ await writeJsonAtomic(path.join(stagingDir, "manifest.json"), manifest, {
118
+ repoRoot,
119
+ });
120
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serializeManifest(manifest))}\n`, { repoRoot });
121
+ try {
122
+ await rename(stagingDir, privateVerifierDir(repoRoot, manifest.privateVerifierId));
123
+ }
124
+ catch (error) {
125
+ const code = error.code;
126
+ const isCreateRace = code === "EEXIST" ||
127
+ code === "ENOTEMPTY" ||
128
+ (code === "EPERM" &&
129
+ (await pathExists(privateVerifierDir(repoRoot, manifest.privateVerifierId))));
130
+ if (!isCreateRace)
131
+ throw error;
132
+ await rm(stagingDir, { recursive: true, force: true });
133
+ const existing = await readPrivateVerifierManifest(repoRoot, manifest.privateVerifierId);
134
+ if (!manifestsEqual(existing, manifest)) {
135
+ throw new Error(`privateVerifier already exists with different content: ${manifest.privateVerifierId}`);
136
+ }
137
+ return { manifest: existing, idempotent: true, manifestPath };
138
+ }
139
+ }
140
+ catch (error) {
141
+ await rm(stagingDir, { recursive: true, force: true });
142
+ throw error;
143
+ }
144
+ return { manifest, idempotent: false, manifestPath };
145
+ }
@@ -0,0 +1,78 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { proposerRunIdSchema, proposerRunSchema, } from "../../application/evaluation/types.js";
4
+ import { sha256Hex } from "../../application/evaluation/candidate-hash.js";
5
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
6
+ import { EVALUATION_ROOT } from "./store.js";
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
9
+ export function assertProposerRunId(value) {
10
+ proposerRunIdSchema.parse(value);
11
+ }
12
+ export function proposerRunDir(repoRoot, proposerRunId) {
13
+ assertProposerRunId(proposerRunId);
14
+ return path.join(repoRoot, EVALUATION_ROOT, "proposer-runs", proposerRunId);
15
+ }
16
+ export function proposerRunManifestPath(repoRoot, proposerRunId) {
17
+ return path.join(proposerRunDir(repoRoot, proposerRunId), "manifest.json");
18
+ }
19
+ async function pathExists(filePath) {
20
+ try {
21
+ await access(filePath);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function serialize(record) {
29
+ return `${JSON.stringify(record, null, 2)}\n`;
30
+ }
31
+ export async function writeProposerRun(input) {
32
+ const parsed = proposerRunSchema.parse(input.run);
33
+ const root = path.dirname(proposerRunDir(input.repoRoot, parsed.proposerRunId));
34
+ await mkdir(root, { recursive: true });
35
+ const stagingDir = await mkdtemp(path.join(root, `.${parsed.proposerRunId}.register-`));
36
+ const manifestPath = proposerRunManifestPath(input.repoRoot, parsed.proposerRunId);
37
+ try {
38
+ if (await pathExists(manifestPath)) {
39
+ throw new Error(`proposer run already exists: ${parsed.proposerRunId}`);
40
+ }
41
+ await writeJsonAtomic(path.join(stagingDir, "manifest.json"), parsed, {
42
+ repoRoot: input.repoRoot,
43
+ });
44
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
45
+ await rename(stagingDir, proposerRunDir(input.repoRoot, parsed.proposerRunId));
46
+ }
47
+ catch (error) {
48
+ await rm(stagingDir, { recursive: true, force: true });
49
+ throw error;
50
+ }
51
+ return manifestPath;
52
+ }
53
+ export async function readProposerRun(repoRoot, proposerRunId) {
54
+ assertProposerRunId(proposerRunId);
55
+ const manifestPath = proposerRunManifestPath(repoRoot, proposerRunId);
56
+ const rawText = await readFile(manifestPath, "utf-8");
57
+ const expected = (await readFile(path.join(proposerRunDir(repoRoot, proposerRunId), MANIFEST_INTEGRITY_FILE), "utf-8")).trim();
58
+ if (sha256Hex(rawText) !== expected) {
59
+ throw new Error(`proposer run integrity mismatch for ${proposerRunId}`);
60
+ }
61
+ return proposerRunSchema.parse(JSON.parse(rawText));
62
+ }
63
+ export async function listProposerRunIds(repoRoot) {
64
+ const root = path.join(repoRoot, EVALUATION_ROOT, "proposer-runs");
65
+ try {
66
+ const entries = await readdir(root, { withFileTypes: true });
67
+ return entries
68
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
69
+ .map((entry) => entry.name)
70
+ .sort();
71
+ }
72
+ catch (error) {
73
+ const code = error.code;
74
+ if (code === "ENOENT")
75
+ return [];
76
+ throw error;
77
+ }
78
+ }
@@ -23,7 +23,9 @@ export async function closeoutTask(repoRoot, taskId) {
23
23
  const verify = await readFile(verifyPath, 'utf-8').catch(() => '');
24
24
  const slug = taskId.replace(/^\d{4}-\d{2}-\d{2}-/, '');
25
25
  const datePrefix = taskId.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? new Date().toISOString().slice(0, 10);
26
- const progressDir = path.join(repoRoot, 'docs', 'progress');
26
+ // Runtime bridge evidence stays under .harness/ so promote/closeout never
27
+ // invents tracked docs/progress writes outside TaskSpec allowed_paths.
28
+ const progressDir = path.join(repoRoot, '.harness', 'task-pool', 'artifacts', 'closeout');
27
29
  await mkdir(progressDir, { recursive: true });
28
30
  const progressPath = path.join(progressDir, `${datePrefix}-${slug}.md`);
29
31
  const verificationSummary = summarizeVerification(verify);
@@ -16,6 +16,7 @@ import { buildGlobalSnapshot } from "./observability/read-model.js";
16
16
  import { createObserveServer } from "./observe/server.js";
17
17
  import { diagnoseTaskPoolStates, formatDoctorHuman, loadOperatorMapping } from "./pool/doctor.js";
18
18
  import { migrateTaskPoolStates } from "./pool/migrate-state.js";
19
+ import { markTaskPoolFailed, reconcileTaskPoolAbandon } from "./pool/reconcile.js";
19
20
  import { getTaskPoolRoot, prepareTaskPoolRetry, readFeatureTaskPoolStates } from "./pool/run-store.js";
20
21
  import { TaskPoolStateError } from "./pool/types.js";
21
22
  import { taskSpecSchema } from "./task-spec/schema.js";
@@ -32,6 +33,7 @@ import { previewFeatureCloseout, renderCloseoutPreview } from "./closeout/previe
32
33
  import { applyFeatureCloseout } from "./closeout/apply.js";
33
34
  import { projectMonthlyMetrics } from "./metrics/projector.js";
34
35
  import { runFeatureFinalVerification } from "./delivery/final-verification.js";
36
+ import { advanceGitCheckpoint } from "./delivery/git-transaction.js";
35
37
  export function buildAgentWorkerProgram() {
36
38
  const program = new Command();
37
39
  program
@@ -46,6 +48,29 @@ export function buildAgentWorkerProgram() {
46
48
  const observe = program.command("observe").description("Observe UI server and snapshot utilities");
47
49
  const feature = program.command("feature").description("Feature-level review and delivery workflow");
48
50
  const pool = program.command("pool").description("Task Pool state doctor and migration utilities");
51
+ pool
52
+ .command("mark-failed")
53
+ .requiredOption("--repo <repo-root>", "Target repo root")
54
+ .requiredOption("--feature-id <feature-id>", "Canonical Feature id")
55
+ .requiredOption("--task-id <task-id>", "Task id")
56
+ .requiredOption("--worker-run-id <id>", "Worker run id to mark failed")
57
+ .requiredOption("--reason <reason>", "Why the emergency projection is justified")
58
+ .description("Audit-only emergency Task Pool Failed projection (does not mutate DAG lifecycle)")
59
+ .action(async (options) => {
60
+ try {
61
+ const result = await markTaskPoolFailed({
62
+ repoRoot: path.resolve(options.repo),
63
+ featureId: options.featureId,
64
+ taskId: options.taskId,
65
+ workerRunId: options.workerRunId,
66
+ reason: options.reason,
67
+ });
68
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
69
+ }
70
+ catch (error) {
71
+ handlePoolRecoveryCliError(error);
72
+ }
73
+ });
49
74
  pool
50
75
  .command("doctor")
51
76
  .requiredOption("--repo <repo-root>", "Target repo root")
@@ -97,6 +122,28 @@ export function buildAgentWorkerProgram() {
97
122
  process.exitCode = 1;
98
123
  }
99
124
  });
125
+ const featureGit = feature
126
+ .command("git")
127
+ .description("Feature Git transaction utilities");
128
+ featureGit
129
+ .command("advance-checkpoint")
130
+ .requiredOption("--repo <repo-root>", "Target repo root")
131
+ .requiredOption("--feature-id <feature-id>", "Canonical Feature id")
132
+ .requiredOption("--reason <reason>", "Why the manual revision is safe to record")
133
+ .description("Advance Git transaction lastCheckpoint to current HEAD on a clean owned branch")
134
+ .action(async (options) => {
135
+ try {
136
+ const result = await advanceGitCheckpoint({
137
+ repoRoot: path.resolve(options.repo),
138
+ featureId: options.featureId,
139
+ reason: options.reason,
140
+ });
141
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
142
+ }
143
+ catch (error) {
144
+ handlePoolRecoveryCliError(error);
145
+ }
146
+ });
100
147
  feature
101
148
  .command("review")
102
149
  .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
@@ -262,6 +309,37 @@ export function buildAgentWorkerProgram() {
262
309
  if (!result.ok)
263
310
  process.exitCode = 1;
264
311
  });
312
+ task
313
+ .command("reconcile")
314
+ .argument("<task-id>", "Task Pool task to reconcile after DAG abandon")
315
+ .requiredOption("--repo <repo-root>", "Target repo root")
316
+ .requiredOption("--feature-id <feature-id>", "Canonical Feature id")
317
+ .requiredOption("--worker-run-id <id>", "Worker run id bound to the DAG run")
318
+ .requiredOption("--dag-run-id <id>", "DAG run id to abandon")
319
+ .requiredOption("--action <action>", "Recovery action (only abandon is supported)")
320
+ .requiredOption("--reason <reason>", "Why the run is being abandoned")
321
+ .option("--force-abandon-recoverable", "Override refuse when DAG liveness/canResume indicates recoverable work (document operator intent in --reason)")
322
+ .description("Worker-aware abandon: DAG reconcile-run + Task Pool Failed projection")
323
+ .action(async (taskId, options) => {
324
+ if (options.action !== "abandon") {
325
+ throw new Error("task reconcile --action must be abandon");
326
+ }
327
+ try {
328
+ const result = await reconcileTaskPoolAbandon({
329
+ repoRoot: path.resolve(options.repo),
330
+ taskId,
331
+ featureId: options.featureId,
332
+ workerRunId: options.workerRunId,
333
+ dagRunId: options.dagRunId,
334
+ reason: options.reason,
335
+ forceAbandonRecoverable: options.forceAbandonRecoverable === true,
336
+ });
337
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
338
+ }
339
+ catch (error) {
340
+ handlePoolRecoveryCliError(error);
341
+ }
342
+ });
265
343
  task
266
344
  .command("retry")
267
345
  .argument("<task-id>", "Failed Task Pool task to requeue")
@@ -498,6 +576,11 @@ export async function main(argv = process.argv) {
498
576
  async function readTaskYaml(taskSpecPath) {
499
577
  return YAML.parse(await readFile(taskSpecPath, "utf-8"));
500
578
  }
579
+ function handlePoolRecoveryCliError(error) {
580
+ const detail = error instanceof Error ? error.message : String(error);
581
+ process.stderr.write(`${detail}\n`);
582
+ process.exitCode = 1;
583
+ }
501
584
  function handleFollowUpCliError(error, json, action) {
502
585
  const detail = error instanceof Error ? error.message : String(error);
503
586
  if (!json)
@@ -173,6 +173,71 @@ export async function finalizeGitTask(transaction, outcome) {
173
173
  await assertClean(transaction.repoRoot);
174
174
  return { status: "restored", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, artifactDir, changedFiles: changes };
175
175
  }
176
+ /**
177
+ * Operator-only: advance recorded lastCheckpoint to current HEAD after manual
178
+ * revisions on the owned Feature branch. Does not append checkpoints[] entries.
179
+ */
180
+ export async function advanceGitCheckpoint(input) {
181
+ const reason = input.reason.trim();
182
+ if (!reason)
183
+ throw new Error("feature git advance-checkpoint requires --reason <text>");
184
+ const repoRoot = await realpath(path.resolve(input.repoRoot));
185
+ assertSafeRuntimeId(input.featureId, "featureId");
186
+ const recordPath = transactionRecordPath(repoRoot, input.featureId);
187
+ const record = await readOptionalRecord(recordPath);
188
+ if (!record)
189
+ throw new Error(`Git transaction record not found for feature ${input.featureId}`);
190
+ if (record.featureId !== input.featureId) {
191
+ throw new Error(`Git transaction ownership mismatch: expected ${input.featureId}, found ${record.featureId}`);
192
+ }
193
+ await assertClean(repoRoot);
194
+ const branch = await git(repoRoot, ["branch", "--show-current"]);
195
+ if (branch !== record.branch) {
196
+ throw new Error(`current branch is outside the Feature transaction: expected ${record.branch}, got ${branch || "detached HEAD"}`);
197
+ }
198
+ const head = await git(repoRoot, ["rev-parse", "HEAD"]);
199
+ if (head === record.lastCheckpoint) {
200
+ const evidencePath = await writeAdvanceCheckpointEvidence(repoRoot, input.featureId, {
201
+ status: "unchanged",
202
+ reason,
203
+ previousCheckpoint: record.lastCheckpoint,
204
+ lastCheckpoint: head,
205
+ at: (input.now ?? new Date()).toISOString(),
206
+ });
207
+ return {
208
+ schemaVersion: 1,
209
+ featureId: input.featureId,
210
+ status: "unchanged",
211
+ previousCheckpoint: record.lastCheckpoint,
212
+ lastCheckpoint: head,
213
+ reason,
214
+ evidencePath: path.relative(repoRoot, evidencePath).split(path.sep).join("/"),
215
+ };
216
+ }
217
+ if (!await gitOk(repoRoot, ["merge-base", "--is-ancestor", record.lastCheckpoint, head])) {
218
+ throw new Error(`current HEAD is not descended from recorded checkpoint ${record.lastCheckpoint}`);
219
+ }
220
+ const previousCheckpoint = record.lastCheckpoint;
221
+ const updated = { ...record, lastCheckpoint: head };
222
+ await writeJsonAtomic(recordPath, updated);
223
+ const at = (input.now ?? new Date()).toISOString();
224
+ const evidencePath = await writeAdvanceCheckpointEvidence(repoRoot, input.featureId, {
225
+ status: "advanced",
226
+ reason,
227
+ previousCheckpoint,
228
+ lastCheckpoint: head,
229
+ at,
230
+ });
231
+ return {
232
+ schemaVersion: 1,
233
+ featureId: input.featureId,
234
+ status: "advanced",
235
+ previousCheckpoint,
236
+ lastCheckpoint: head,
237
+ reason,
238
+ evidencePath: path.relative(repoRoot, evidencePath).split(path.sep).join("/"),
239
+ };
240
+ }
176
241
  export function transactionRecordPath(repoRoot, featureId) {
177
242
  return path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "git", "transaction.json");
178
243
  }
@@ -311,6 +376,16 @@ async function readIgnoredBaseline(repoRoot) {
311
376
  function isSensitivePath(relative) {
312
377
  return /(^|\/)(\.env(?:\.|$)|[^/]*\.(?:pem|key|p12|pfx))$/i.test(relative);
313
378
  }
379
+ async function writeAdvanceCheckpointEvidence(repoRoot, featureId, payload) {
380
+ const evidenceDir = path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "git");
381
+ await mkdir(evidenceDir, { recursive: true });
382
+ const stamp = typeof payload.at === "string"
383
+ ? payload.at.replace(/[:.]/g, "-")
384
+ : new Date().toISOString().replace(/[:.]/g, "-");
385
+ const evidencePath = path.join(evidenceDir, `advance-checkpoint-${stamp}.json`);
386
+ await writeJsonAtomic(evidencePath, { schemaVersion: 1, featureId, ...payload });
387
+ return evidencePath;
388
+ }
314
389
  async function readOptionalRecord(filePath) {
315
390
  try {
316
391
  return gitTransactionRecordSchema.parse(JSON.parse(await readFile(filePath, "utf-8")));