@tea-agent/loop-agent 0.9.0 → 0.10.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 (63) hide show
  1. package/CHANGELOG.md +42 -11
  2. package/README.md +20 -0
  3. package/dist/cli/command-definitions.js +7 -0
  4. package/dist/cli/program.js +6 -1
  5. package/dist/commands/dag-reconcile-run.js +118 -0
  6. package/dist/commands/init.js +12 -3
  7. package/dist/governance/manifest-types.js +4 -0
  8. package/dist/worker/cli.js +216 -0
  9. package/dist/worker/closeout/apply.js +73 -0
  10. package/dist/worker/closeout/preview.js +30 -0
  11. package/dist/worker/delivery/final-verification.js +158 -0
  12. package/dist/worker/delivery/git-transaction.js +354 -0
  13. package/dist/worker/delivery/package.js +449 -0
  14. package/dist/worker/feature/decision-loader.js +68 -0
  15. package/dist/worker/feature/discover.js +14 -0
  16. package/dist/worker/feature/next-action.js +74 -0
  17. package/dist/worker/feature/reducer.js +133 -0
  18. package/dist/worker/feature/review.js +502 -0
  19. package/dist/worker/feature/run.js +313 -0
  20. package/dist/worker/feature/types.js +1 -0
  21. package/dist/worker/follow-up/approve.js +270 -0
  22. package/dist/worker/follow-up/factory.js +234 -0
  23. package/dist/worker/follow-up/paths.js +25 -0
  24. package/dist/worker/follow-up/policy.js +26 -0
  25. package/dist/worker/follow-up/schema.js +93 -0
  26. package/dist/worker/follow-up/store.js +96 -0
  27. package/dist/worker/metrics/projector.js +139 -0
  28. package/dist/worker/observability/read-model.js +256 -15
  29. package/dist/worker/observe/paths.js +17 -5
  30. package/dist/worker/observe/static/app.js +443 -61
  31. package/dist/worker/observe/static/index.html +3 -1
  32. package/dist/worker/observe/static/styles.css +86 -19
  33. package/dist/worker/pool/run-store.js +14 -2
  34. package/dist/worker/pool/validation.js +59 -0
  35. package/dist/worker/report/morning-report.js +41 -6
  36. package/dist/worker/run-task/run-task.js +1 -1
  37. package/dist/worker/runner/run-ready.js +19 -5
  38. package/dist/workflows/dag/init-hybrid.js +3 -1
  39. package/dist/workflows/dag/lifecycle.js +146 -0
  40. package/dist/workflows/dag/node-execution.js +3 -0
  41. package/dist/workflows/dag/prompt.js +16 -0
  42. package/dist/workflows/dag/report.js +2 -0
  43. package/dist/workflows/dag/runner.js +133 -104
  44. package/dist/workflows/dag/types.js +3 -0
  45. package/docs/README.md +17 -0
  46. package/docs/agent-dag-recovery-playbook.md +1 -1
  47. package/docs/architecture/runtime-boundaries.md +3 -2
  48. package/docs/design/README.md +11 -5
  49. package/docs/exec-plans/active/README.md +1 -1
  50. package/docs/exec-plans/completed/README.md +8 -1
  51. package/docs/loop-agent-harness.md +45 -2
  52. package/docs/progress/README.md +2 -0
  53. package/docs/reports/README.md +10 -0
  54. package/docs/templates/agent-dag-report.schema.json +5 -3
  55. package/docs/templates/harness.schema.json +7 -2
  56. package/docs/templates/init-evolution-review.md +4 -2
  57. package/docs/verification-matrix.md +7 -0
  58. package/harness.json +4 -3
  59. package/package.json +4 -2
  60. package/scripts/check-product-line-docs.sh +7 -3
  61. package/skills/init-capability-evolution/SKILL.md +1 -0
  62. package/skills/loop-agent/references/command-reference.md +21 -0
  63. package/skills/loop-agent/references/hybrid-dag.md +4 -3
@@ -0,0 +1,234 @@
1
+ import { access, mkdir, readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { findRunByWorkerRunId, getRunsJsonlPath, readJsonlFile } from "../pool/run-store.js";
5
+ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
6
+ import { taskSpecSchema } from "../task-spec/schema.js";
7
+ import { assertSafeRuntimeId, assertSafeTaskFile, followUpDir } from "./paths.js";
8
+ import { followUpActionCardSchema, followUpDraftSchema, followUpEvidenceSchema } from "./schema.js";
9
+ import { cleanupPath, hashFeaturePacket, readFollowUpIndex, repoRef, resolveRepoFile, sha256File, withFollowUpLock, writeFollowUpIndex, writeJsonAtomic, writeTextAtomic } from "./store.js";
10
+ import { resolveFollowUpPolicy } from "./policy.js";
11
+ export async function draftProductBugFollowUp(input) {
12
+ const result = await draftFollowUpDecision(input, true);
13
+ if (result.kind !== "TaskDraft")
14
+ throw new Error("ProductBug must create a TaskDraft");
15
+ return result;
16
+ }
17
+ export async function draftFollowUpDecision(input, productBugOnly = false) {
18
+ const repoRoot = path.resolve(input.repoRoot);
19
+ const featureDir = path.resolve(input.featureDir);
20
+ assertSafeRuntimeId(input.taskId, "taskId");
21
+ const run = await findRunByWorkerRunId(repoRoot, input.workerRunId);
22
+ if (!run || run.taskId !== input.taskId)
23
+ throw new Error(`failed run not found for ${input.taskId}/${input.workerRunId}`);
24
+ if (run.status === "succeeded")
25
+ throw new Error("cannot draft follow-up from a successful run");
26
+ const category = run.failure?.category;
27
+ if (!category)
28
+ throw new Error("failed run has no failure category");
29
+ if (productBugOnly && category !== "ProductBug")
30
+ throw new Error(`ProductBug draft required, found ${category}`);
31
+ const allRuns = await readJsonlFile(getRunsJsonlPath(repoRoot));
32
+ const policy = resolveFollowUpPolicy({
33
+ category,
34
+ recentTaskRuns: allRuns.filter((candidate) => candidate.featureId === run.featureId && candidate.taskId === input.taskId),
35
+ });
36
+ const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
37
+ const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
38
+ assertSafeRuntimeId(graph.feature_id, "featureId");
39
+ assertSafeRuntimeId(run.featureId, "run.featureId");
40
+ if (run.featureId !== graph.feature_id)
41
+ throw new Error(`failed run belongs to ${run.featureId}, not ${graph.feature_id}`);
42
+ return withFollowUpLock(repoRoot, run.featureId, async () => {
43
+ const index = await readFollowUpIndex(repoRoot, run.featureId);
44
+ const dedupeKey = `${run.featureId}:${input.taskId}:${input.workerRunId}:${category}`;
45
+ const existing = index.entries.find((entry) => entry.dedupeKey === dedupeKey);
46
+ if (existing) {
47
+ const safeDraftPath = await resolveRepoFile(repoRoot, existing.draftPath);
48
+ const raw = YAML.parse(await readFile(safeDraftPath, "utf-8"));
49
+ if (existing.kind === "ActionCard") {
50
+ return actionResult(repoRoot, existing.followUpId, path.resolve(repoRoot, existing.draftPath), dedupeKey, followUpActionCardSchema.parse(raw), false);
51
+ }
52
+ if (!existing.proposedTaskId)
53
+ throw new Error("TaskDraft index is missing proposedTaskId");
54
+ return result(repoRoot, existing.followUpId, existing.proposedTaskId, path.resolve(repoRoot, existing.draftPath), dedupeKey, followUpDraftSchema.parse(raw), false);
55
+ }
56
+ if (graph.feature_id !== run.featureId)
57
+ throw new Error("Feature graph id does not match failed run");
58
+ const parentNode = graph.nodes.find((node) => node.id === input.taskId);
59
+ if (!parentNode)
60
+ throw new Error(`parent task is missing from graph: ${input.taskId}`);
61
+ assertSafeTaskFile(parentNode.task, "parent task file");
62
+ for (const node of graph.nodes.filter((candidate) => candidate.depends_on.includes(input.taskId))) {
63
+ assertSafeTaskFile(node.task, `rewire task file for ${node.id}`);
64
+ }
65
+ const sequence = nextSequence(index.entries.filter((entry) => entry.parentTaskId === input.taskId).map((entry) => entry.followUpId));
66
+ const suffix = String(sequence).padStart(2, "0");
67
+ const followUpId = `FU-${input.taskId}-${suffix}`;
68
+ assertSafeRuntimeId(followUpId, "followUpId");
69
+ const evidenceRefs = await Promise.all([
70
+ run.runRecordPath,
71
+ run.dagPath,
72
+ ...Object.values(run.failureArtifacts ?? {}),
73
+ ].filter((value) => Boolean(value)).map((value) => canonicalEvidenceRef(repoRoot, value)));
74
+ if (evidenceRefs.length === 0)
75
+ throw new Error("failed run has no canonical evidence refs");
76
+ const evidence = followUpEvidenceSchema.parse({
77
+ schemaVersion: 1,
78
+ followUpId,
79
+ evidence: await Promise.all(evidenceRefs.map(async (ref) => ({ ref, sha256: await sha256File(path.resolve(repoRoot, ref)) }))),
80
+ });
81
+ const directory = followUpDir(repoRoot, run.featureId, followUpId);
82
+ if (await exists(directory))
83
+ throw new Error(`follow-up id collision: ${followUpId}`);
84
+ await mkdir(directory, { recursive: false });
85
+ const draftPath = path.join(directory, "draft.yaml");
86
+ const evidencePath = path.join(directory, "evidence.json");
87
+ try {
88
+ await writeJsonAtomic(evidencePath, evidence);
89
+ const evidenceHash = await sha256File(evidencePath);
90
+ for (const entry of index.entries) {
91
+ if (entry.parentTaskId === input.taskId && (entry.status === "Draft" || entry.status === "ActionRequired"))
92
+ entry.status = "Superseded";
93
+ }
94
+ if (policy.kind === "action-card") {
95
+ const actionCard = followUpActionCardSchema.parse({
96
+ schema_version: 1,
97
+ follow_up_id: followUpId,
98
+ status: "ActionRequired",
99
+ generated_from: { feature_id: run.featureId, task_id: input.taskId, worker_run_id: input.workerRunId, failure_category: category },
100
+ action: policy.action,
101
+ label: policy.label,
102
+ ...(policy.recommendedCommand === "task-retry" ? { command: `agent-worker task retry ${JSON.stringify(input.taskId)} --repo ${JSON.stringify(repoRoot)}` } : {}),
103
+ evidence_refs: evidenceRefs,
104
+ dedupe_key: dedupeKey,
105
+ created_at: (input.now ?? new Date()).toISOString(),
106
+ });
107
+ await writeTextAtomic(draftPath, YAML.stringify(actionCard));
108
+ index.entries.push({ kind: "ActionCard", followUpId, parentTaskId: input.taskId, workerRunId: input.workerRunId, dedupeKey, draftHash: await sha256File(draftPath), evidenceHash, status: "ActionRequired", draftPath: repoRef(repoRoot, draftPath), createdAt: actionCard.created_at });
109
+ await writeFollowUpIndex(repoRoot, index);
110
+ return actionResult(repoRoot, followUpId, draftPath, dedupeKey, actionCard, true);
111
+ }
112
+ const proposedTaskId = `${policy.taskIdPrefix}-${input.taskId}-${suffix}`;
113
+ assertSafeRuntimeId(proposedTaskId, "proposedTaskId");
114
+ const parentTaskPath = path.join(featureDir, "tasks", parentNode.task);
115
+ const parentTask = taskSpecSchema.parse(YAML.parse(await readFile(parentTaskPath, "utf-8")));
116
+ const proposedTaskSpec = buildFixTask(parentTask, proposedTaskId, input.workerRunId, category, policy);
117
+ const draft = {
118
+ schema_version: 1,
119
+ follow_up_id: followUpId,
120
+ proposed_task_id: proposedTaskId,
121
+ status: "Draft",
122
+ generated_from: {
123
+ feature_id: run.featureId,
124
+ task_id: input.taskId,
125
+ worker_run_id: input.workerRunId,
126
+ failure_category: category,
127
+ },
128
+ lineage: { parent_task_id: input.taskId, recommended_action: policy.recommendedAction },
129
+ evidence_refs: evidenceRefs,
130
+ evidence_manifest_hash: evidenceHash,
131
+ dedupe_key: dedupeKey,
132
+ feature_packet_hash: await hashFeaturePacket(featureDir),
133
+ proposed_task_spec: proposedTaskSpec,
134
+ proposed_graph_patch: {
135
+ add_node: { id: proposedTaskId, task: `${proposedTaskId}.yaml`, type: policy.taskType, depends_on: [...parentNode.depends_on] },
136
+ rewire_dependents: graph.nodes.filter((node) => node.depends_on.includes(input.taskId)).map((node) => ({ task_id: node.id, from_dependency: input.taskId, to_dependency: proposedTaskId })),
137
+ },
138
+ created_at: (input.now ?? new Date()).toISOString(),
139
+ };
140
+ followUpDraftSchema.parse(draft);
141
+ await writeTextAtomic(draftPath, YAML.stringify(draft));
142
+ const draftHash = await sha256File(draftPath);
143
+ index.entries.push({
144
+ kind: "TaskDraft",
145
+ followUpId,
146
+ proposedTaskId,
147
+ parentTaskId: input.taskId,
148
+ workerRunId: input.workerRunId,
149
+ dedupeKey,
150
+ draftHash,
151
+ evidenceHash,
152
+ status: "Draft",
153
+ draftPath: repoRef(repoRoot, draftPath),
154
+ createdAt: draft.created_at,
155
+ });
156
+ await writeFollowUpIndex(repoRoot, index);
157
+ return result(repoRoot, followUpId, proposedTaskId, draftPath, dedupeKey, draft, true);
158
+ }
159
+ catch (error) {
160
+ await cleanupPath(directory).catch(() => { });
161
+ throw error;
162
+ }
163
+ });
164
+ }
165
+ function buildFixTask(parent, taskId, workerRunId, category, policy) {
166
+ const qaScoped = category === "TestBug" || category === "FlakyTest";
167
+ const infraScoped = category === "DependencyFailure" || category === "EnvFailure";
168
+ const productBugFromQa = category === "ProductBug" && parent.type.startsWith("qa-");
169
+ const qaDiscoveredProductPaths = productBugFromQa
170
+ ? parent.constraints.forbidden_paths.filter((candidate) => /(^|\/)(src|services|app|apps)(\/|\*|$)/i.test(candidate))
171
+ : [];
172
+ const narrowedAllowedPaths = productBugFromQa
173
+ ? qaDiscoveredProductPaths
174
+ : qaScoped
175
+ ? parent.constraints.allowed_paths.filter((candidate) => /(^|\/)(test|tests|qa|spec)(\/|\*|$)/i.test(candidate))
176
+ : infraScoped
177
+ ? parent.constraints.allowed_paths.filter((candidate) => /package|lock|vendor|depend|config|script|(^|\/)ci(\/|$)|docker|\.github/i.test(candidate))
178
+ : [...parent.constraints.allowed_paths];
179
+ const forbidBusinessCode = qaScoped || infraScoped;
180
+ return taskSpecSchema.parse({
181
+ ...parent,
182
+ id: taskId,
183
+ title: `${policy.recommendedAction}: ${parent.id} — ${parent.title}`,
184
+ description: `Follow-up for ${category} from Worker run ${workerRunId}. ${parent.description}`.trim(),
185
+ type: policy.taskType,
186
+ risk_level: infraScoped && narrowedAllowedPaths.length === 0 ? "low" : parent.risk_level,
187
+ depends_on: [...parent.depends_on],
188
+ scope: {
189
+ ...parent.scope,
190
+ goals: [`${policy.goalPrefix} from ${parent.id}/${workerRunId}`, ...parent.scope.goals],
191
+ non_goals: productBugFromQa ? parent.scope.non_goals.filter((item) => !/do not modify product code/i.test(item)) : parent.scope.non_goals,
192
+ },
193
+ constraints: {
194
+ ...parent.constraints,
195
+ allowed_paths: narrowedAllowedPaths,
196
+ forbidden_paths: productBugFromQa
197
+ ? [...new Set([...parent.constraints.allowed_paths, "test/**", "tests/**", "qa/**"])]
198
+ : forbidBusinessCode
199
+ ? [...new Set([...parent.constraints.forbidden_paths, "src/**", "services/**", "app/**", "apps/**"])]
200
+ : [...parent.constraints.forbidden_paths],
201
+ hard_constraints: [...parent.constraints.hard_constraints.filter((item) => !productBugFromQa || !/read-only qa execution/i.test(item)), ...(productBugFromQa ? ["Do not weaken or remove the failing regression test"] : []), `Preserve lineage to failed run ${workerRunId}`],
202
+ },
203
+ });
204
+ }
205
+ function nextSequence(ids) {
206
+ return Math.max(0, ...ids.map((id) => Number.parseInt(id.match(/-(\d+)$/)?.[1] ?? "0", 10))) + 1;
207
+ }
208
+ async function canonicalEvidenceRef(repoRoot, value) {
209
+ try {
210
+ const canonicalRoot = await realpath(repoRoot);
211
+ const candidate = await resolveRepoFile(repoRoot, value);
212
+ return path.relative(canonicalRoot, candidate).split(path.sep).join("/");
213
+ }
214
+ catch {
215
+ throw new Error(`canonical evidence must be inside the target repo: ${value}`);
216
+ }
217
+ }
218
+ function result(repoRoot, followUpId, proposedTaskId, draftPath, dedupeKey, draft, created) {
219
+ const directory = path.dirname(draftPath);
220
+ return { schemaVersion: 1, kind: "TaskDraft", created, followUpId, proposedTaskId, draftPath, evidencePath: path.join(directory, "evidence.json"), indexPath: path.join(path.dirname(directory), "index.json"), dedupeKey, draft };
221
+ }
222
+ function actionResult(repoRoot, followUpId, draftPath, dedupeKey, actionCard, created) {
223
+ const directory = path.dirname(draftPath);
224
+ return { schemaVersion: 1, kind: "ActionCard", created, followUpId, draftPath, evidencePath: path.join(directory, "evidence.json"), indexPath: path.join(path.dirname(directory), "index.json"), dedupeKey, actionCard };
225
+ }
226
+ async function exists(filePath) {
227
+ try {
228
+ await access(filePath);
229
+ return true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import { getTaskPoolRoot } from "../pool/run-store.js";
3
+ const SAFE_RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
4
+ const SAFE_TASK_FILE = /^[A-Za-z0-9][A-Za-z0-9_-]*\.ya?ml$/;
5
+ export function assertSafeRuntimeId(value, label) {
6
+ if (!SAFE_RUNTIME_ID.test(value)) {
7
+ throw new Error(`${label} contains unsafe path characters: ${JSON.stringify(value)}`);
8
+ }
9
+ }
10
+ export function assertSafeTaskFile(value, label) {
11
+ if (!SAFE_TASK_FILE.test(value) || path.basename(value) !== value) {
12
+ throw new Error(`${label} must be a safe single-file YAML reference: ${JSON.stringify(value)}`);
13
+ }
14
+ }
15
+ export function followUpRoot(repoRoot, featureId) {
16
+ assertSafeRuntimeId(featureId, "featureId");
17
+ return path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "follow-ups");
18
+ }
19
+ export function followUpIndexPath(repoRoot, featureId) {
20
+ return path.join(followUpRoot(repoRoot, featureId), "index.json");
21
+ }
22
+ export function followUpDir(repoRoot, featureId, followUpId) {
23
+ assertSafeRuntimeId(followUpId, "followUpId");
24
+ return path.join(followUpRoot(repoRoot, featureId), followUpId);
25
+ }
@@ -0,0 +1,26 @@
1
+ const TASK_POLICIES = {
2
+ ProductBug: { kind: "task-draft", recommendedAction: "dev-fix", taskIdPrefix: "FIX", taskType: "fix-from-failure", goalPrefix: "Resolve ProductBug" },
3
+ TestBug: { kind: "task-draft", recommendedAction: "qa-fix", taskIdPrefix: "FIX", taskType: "bugfix", goalPrefix: "Repair the failing test contract" },
4
+ FlakyTest: { kind: "task-draft", recommendedAction: "qa-failure-analysis", taskIdPrefix: "FIX", taskType: "qa-failure-analysis", goalPrefix: "Analyse and eliminate flaky verification" },
5
+ DependencyFailure: { kind: "task-draft", recommendedAction: "dependency-fix", taskIdPrefix: "FIX", taskType: "fix-from-failure", goalPrefix: "Restore the blocked dependency" },
6
+ };
7
+ const ACTION_POLICIES = {
8
+ SpecUnclear: { kind: "action-card", action: "clarify-spec", label: "Clarify the specification before creating a new TaskSpec." },
9
+ ContractMismatch: { kind: "action-card", action: "review-contract", label: "Review the architecture or API contract and let a human decide the next task." },
10
+ RiskyChange: { kind: "action-card", action: "review-risk", label: "Complete an architecture and human risk gate before changing scope." },
11
+ NeedsHuman: { kind: "action-card", action: "human-triage", label: "A human decision is required before work can continue." },
12
+ Unknown: { kind: "action-card", action: "human-triage", label: "Triage the unknown failure and classify it before creating work." },
13
+ };
14
+ export function resolveFollowUpPolicy(input) {
15
+ const taskPolicy = TASK_POLICIES[input.category];
16
+ if (taskPolicy)
17
+ return taskPolicy;
18
+ if (input.category === "EnvFailure") {
19
+ const recentFailures = input.recentTaskRuns.slice(-2);
20
+ if (recentFailures.length === 2 && recentFailures.every((run) => run.failure?.category === "EnvFailure")) {
21
+ return { kind: "task-draft", recommendedAction: "env-check", taskIdPrefix: "ENV-CHECK", taskType: "qa-analysis", goalPrefix: "Diagnose and correct repeated environment failure" };
22
+ }
23
+ return { kind: "action-card", action: "retry-task", label: "Correct the environment and retry the unchanged task first.", recommendedCommand: "task-retry" };
24
+ }
25
+ return ACTION_POLICIES[input.category];
26
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import { taskSpecSchema } from "../task-spec/schema.js";
3
+ import { taskGraphNodeSchema } from "../task-graph/task-graph-schema.js";
4
+ const failureCategorySchema = z.enum(["SpecUnclear", "ContractMismatch", "ProductBug", "TestBug", "EnvFailure", "FlakyTest", "RiskyChange", "DependencyFailure", "NeedsHuman", "Unknown"]);
5
+ export const followUpDraftSchema = z.object({
6
+ schema_version: z.literal(1),
7
+ follow_up_id: z.string().min(1),
8
+ proposed_task_id: z.string().min(1),
9
+ status: z.enum(["Draft", "Approved"]),
10
+ generated_from: z.object({
11
+ feature_id: z.string().min(1),
12
+ task_id: z.string().min(1),
13
+ worker_run_id: z.string().min(1),
14
+ dag_run_id: z.string().min(1).optional(),
15
+ failure_category: failureCategorySchema,
16
+ }),
17
+ lineage: z.object({
18
+ parent_task_id: z.string().min(1),
19
+ recommended_action: z.enum(["dev-fix", "qa-fix", "qa-failure-analysis", "dependency-fix", "env-check"]),
20
+ }),
21
+ evidence_refs: z.array(z.string().min(1)).min(1),
22
+ evidence_manifest_hash: z.string().regex(/^[a-f0-9]{64}$/),
23
+ dedupe_key: z.string().min(1),
24
+ feature_packet_hash: z.string().regex(/^[a-f0-9]{64}$/),
25
+ proposed_task_spec: taskSpecSchema,
26
+ proposed_graph_patch: z.object({
27
+ add_node: taskGraphNodeSchema,
28
+ rewire_dependents: z.array(z.object({
29
+ task_id: z.string().min(1),
30
+ from_dependency: z.string().min(1),
31
+ to_dependency: z.string().min(1),
32
+ })),
33
+ }),
34
+ created_at: z.string().datetime(),
35
+ }).strict();
36
+ export const followUpActionCardSchema = z.object({
37
+ schema_version: z.literal(1),
38
+ follow_up_id: z.string().min(1),
39
+ status: z.literal("ActionRequired"),
40
+ generated_from: z.object({
41
+ feature_id: z.string().min(1),
42
+ task_id: z.string().min(1),
43
+ worker_run_id: z.string().min(1),
44
+ failure_category: failureCategorySchema,
45
+ }),
46
+ action: z.enum(["retry-task", "clarify-spec", "review-contract", "review-risk", "human-triage"]),
47
+ label: z.string().min(1),
48
+ command: z.string().min(1).optional(),
49
+ evidence_refs: z.array(z.string().min(1)).min(1),
50
+ dedupe_key: z.string().min(1),
51
+ created_at: z.string().datetime(),
52
+ }).strict();
53
+ export const followUpIndexSchema = z.object({
54
+ schemaVersion: z.literal(1),
55
+ featureId: z.string().min(1),
56
+ entries: z.array(z.object({
57
+ kind: z.enum(["TaskDraft", "ActionCard"]),
58
+ followUpId: z.string().min(1),
59
+ proposedTaskId: z.string().min(1).optional(),
60
+ parentTaskId: z.string().min(1),
61
+ workerRunId: z.string().min(1),
62
+ dedupeKey: z.string().min(1),
63
+ draftHash: z.string().regex(/^[a-f0-9]{64}$/),
64
+ evidenceHash: z.string().regex(/^[a-f0-9]{64}$/),
65
+ status: z.enum(["Draft", "ActionRequired", "Approved", "Superseded", "Rejected"]),
66
+ draftPath: z.string().min(1),
67
+ approvalPath: z.string().min(1).optional(),
68
+ createdAt: z.string().datetime(),
69
+ approvedAt: z.string().datetime().optional(),
70
+ })),
71
+ }).strict();
72
+ export const followUpEvidenceSchema = z.object({
73
+ schemaVersion: z.literal(1),
74
+ followUpId: z.string().min(1),
75
+ evidence: z.array(z.object({
76
+ ref: z.string().min(1),
77
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
78
+ })).min(1),
79
+ }).strict();
80
+ export const followUpApprovalSchema = z.object({
81
+ schemaVersion: z.literal(1),
82
+ followUpId: z.string().min(1),
83
+ proposedTaskId: z.string().min(1),
84
+ owner: z.string().min(1),
85
+ approvedAt: z.string().datetime(),
86
+ draftHash: z.string().regex(/^[a-f0-9]{64}$/),
87
+ beforeGraphHash: z.string().regex(/^[a-f0-9]{64}$/),
88
+ afterGraphHash: z.string().regex(/^[a-f0-9]{64}$/),
89
+ taskSpecPath: z.string().min(1),
90
+ graphPath: z.string().min(1),
91
+ statePath: z.string().min(1),
92
+ eventPath: z.string().min(1),
93
+ }).strict();
@@ -0,0 +1,96 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { followUpIndexSchema } from "./schema.js";
5
+ import { followUpIndexPath, followUpRoot } from "./paths.js";
6
+ export async function readFollowUpIndex(repoRoot, featureId) {
7
+ const indexPath = followUpIndexPath(repoRoot, featureId);
8
+ try {
9
+ return followUpIndexSchema.parse(JSON.parse(await readFile(indexPath, "utf-8")));
10
+ }
11
+ catch (error) {
12
+ if (isNotFound(error))
13
+ return { schemaVersion: 1, featureId, entries: [] };
14
+ throw error;
15
+ }
16
+ }
17
+ export async function writeFollowUpIndex(repoRoot, index) {
18
+ await writeJsonAtomic(followUpIndexPath(repoRoot, index.featureId), index);
19
+ }
20
+ export async function writeJsonAtomic(filePath, value) {
21
+ await writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
22
+ }
23
+ export async function writeTextAtomic(filePath, value) {
24
+ await mkdir(path.dirname(filePath), { recursive: true });
25
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
26
+ await writeFile(tempPath, value, "utf-8");
27
+ await rename(tempPath, filePath);
28
+ }
29
+ export async function withFollowUpLock(repoRoot, featureId, run) {
30
+ const root = followUpRoot(repoRoot, featureId);
31
+ await mkdir(root, { recursive: true });
32
+ const lockPath = path.join(root, ".lock");
33
+ let handle;
34
+ try {
35
+ handle = await open(lockPath, "wx");
36
+ }
37
+ catch (error) {
38
+ throw new Error(`follow-up transaction is already active for ${featureId}`, { cause: error });
39
+ }
40
+ try {
41
+ return await run();
42
+ }
43
+ finally {
44
+ await handle.close().catch(() => { });
45
+ await unlink(lockPath).catch(() => { });
46
+ }
47
+ }
48
+ export async function hashFeaturePacket(featureDir) {
49
+ const files = await listFiles(featureDir);
50
+ const hash = createHash("sha256");
51
+ for (const filePath of files) {
52
+ const relative = path.relative(featureDir, filePath).split(path.sep).join("/");
53
+ hash.update(relative);
54
+ hash.update("\0");
55
+ hash.update(await readFile(filePath));
56
+ hash.update("\0");
57
+ }
58
+ return hash.digest("hex");
59
+ }
60
+ export async function sha256File(filePath) {
61
+ return createHash("sha256").update(await readFile(filePath)).digest("hex");
62
+ }
63
+ export function repoRef(repoRoot, filePath) {
64
+ const relative = path.relative(repoRoot, filePath);
65
+ return relative.startsWith("..") || path.isAbsolute(relative)
66
+ ? filePath
67
+ : relative.split(path.sep).join("/");
68
+ }
69
+ export async function resolveRepoFile(repoRoot, ref) {
70
+ const canonicalRoot = await realpath(repoRoot);
71
+ const candidate = await realpath(path.resolve(repoRoot, ref));
72
+ const relative = path.relative(canonicalRoot, candidate);
73
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
74
+ throw new Error(`path must be inside the target repo: ${ref}`);
75
+ }
76
+ return candidate;
77
+ }
78
+ export async function cleanupPath(filePath) {
79
+ await rm(filePath, { recursive: true, force: true });
80
+ }
81
+ async function listFiles(root) {
82
+ const output = [];
83
+ for (const entry of await readdir(root, { withFileTypes: true })) {
84
+ if (entry.name.startsWith(".followup-stage-"))
85
+ continue;
86
+ const absolute = path.join(root, entry.name);
87
+ if (entry.isDirectory())
88
+ output.push(...await listFiles(absolute));
89
+ if (entry.isFile())
90
+ output.push(absolute);
91
+ }
92
+ return output.sort();
93
+ }
94
+ function isNotFound(error) {
95
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
96
+ }
@@ -0,0 +1,139 @@
1
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { loadFeatureDecisionModels } from "../feature/decision-loader.js";
5
+ import { readFollowUpIndex } from "../follow-up/store.js";
6
+ import { getTaskPoolRoot } from "../pool/run-store.js";
7
+ import { readValidTaskPoolRuns } from "../pool/validation.js";
8
+ export async function projectMonthlyMetrics(input) {
9
+ if (!/^\d{4}-\d{2}$/.test(input.month))
10
+ throw new Error("metrics month must be YYYY-MM");
11
+ const repoRoot = path.resolve(input.repoRoot);
12
+ const start = new Date(`${input.month}-01T00:00:00.000Z`);
13
+ const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
14
+ if (!Number.isFinite(start.getTime()) || start.toISOString().slice(0, 7) !== input.month)
15
+ throw new Error("metrics month must be a valid YYYY-MM");
16
+ const { features, projectionWarnings } = await loadFeatureDecisionModels(repoRoot);
17
+ const validRuns = await readValidTaskPoolRuns(repoRoot);
18
+ const allRuns = validRuns.records;
19
+ projectionWarnings.push(...validRuns.warnings);
20
+ const runs = dedupeRuns(allRuns).filter((run) => inWindow(run.recordedAt, start, end));
21
+ const featureById = new Map(features.map((feature) => [feature.featureId, feature]));
22
+ const entered = new Set(runs.map((run) => run.featureId));
23
+ let closed = 0;
24
+ for (const id of entered) {
25
+ const feature = featureById.get(id);
26
+ if (feature?.status === "closed" && feature.evidence.closeout)
27
+ try {
28
+ const raw = (await import("yaml")).default.parse(await readFile(feature.evidence.closeout, "utf-8"));
29
+ if (raw.appliedAt && inWindow(raw.appliedAt, start, end))
30
+ closed += 1;
31
+ }
32
+ catch { }
33
+ }
34
+ const metricWarnings = [];
35
+ const followUps = new Map();
36
+ for (const id of new Set(allRuns.map((run) => run.featureId)))
37
+ try {
38
+ followUps.set(id, await readFollowUpIndex(repoRoot, id));
39
+ }
40
+ catch {
41
+ metricWarnings.push(`follow-up index unavailable for ${id}`);
42
+ }
43
+ const failed = runs.filter((run) => run.status !== "succeeded");
44
+ const handled = failed.filter((run) => followUps.get(run.featureId)?.entries.some((entry) => entry.workerRunId === run.workerRunId)).length;
45
+ const approved = [...followUps.entries()].flatMap(([featureId, index]) => index.entries.map((entry) => ({ ...entry, featureId }))).filter((entry) => entry.status === "Approved" && entry.approvedAt && inWindow(entry.approvedAt, start, end));
46
+ const executedApproved = approved.filter((entry) => allRuns.some((run) => run.featureId === entry.featureId && run.taskId === entry.proposedTaskId && Boolean(entry.approvedAt) && run.recordedAt >= entry.approvedAt));
47
+ const approvedSuccess = executedApproved.filter((entry) => allRuns.some((run) => run.featureId === entry.featureId && run.taskId === entry.proposedTaskId && run.status === "succeeded" && run.recordedAt >= entry.approvedAt)).length;
48
+ const taskTypes = new Map(features.flatMap((feature) => feature.tasks.map((task) => [`${feature.featureId}:${task.taskId}`, task.type])));
49
+ const successfulDev = new Set(runs.filter((run) => run.status === "succeeded" && isDevelopmentType(taskTypes.get(`${run.featureId}:${run.taskId}`))).map((run) => `${run.featureId}:${run.taskId}`));
50
+ const deliveryTasks = new Set();
51
+ for (const feature of features)
52
+ if (feature.evidence.delivery)
53
+ try {
54
+ const manifest = JSON.parse(await readFile(feature.evidence.delivery, "utf-8"));
55
+ if (manifest.createdAt && inWindow(manifest.createdAt, start, end))
56
+ for (const task of manifest.tasks ?? [])
57
+ if (task.taskId)
58
+ deliveryTasks.add(`${feature.featureId}:${task.taskId}`);
59
+ }
60
+ catch { }
61
+ const required = features.filter((feature) => entered.has(feature.featureId)).flatMap((feature) => feature.acceptanceCoverage.filter((item) => item.required));
62
+ const acComplete = required.filter((item) => item.status === "covered" || item.status === "waived").length;
63
+ const decisionLatencies = approved.flatMap((entry) => entry.approvedAt ? [new Date(entry.approvedAt).getTime() - new Date(entry.createdAt).getTime()] : []).filter((value) => value >= 0).sort((a, b) => a - b);
64
+ const recoveryRounds = [];
65
+ const recoveredTasks = new Set();
66
+ for (const taskId of new Set(runs.filter((run) => run.status !== "succeeded").map((run) => `${run.featureId}:${run.taskId}`))) {
67
+ const [featureId, id] = taskId.split(":");
68
+ const history = runs.filter((run) => run.featureId === featureId && run.taskId === id).sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
69
+ const firstFail = history.findIndex((run) => run.status !== "succeeded");
70
+ const success = history.findIndex((run, index) => index > firstFail && run.status === "succeeded");
71
+ if (firstFail >= 0 && success > firstFail) {
72
+ recoveryRounds.push(success - firstFail);
73
+ recoveredTasks.add(taskId);
74
+ }
75
+ }
76
+ for (const entry of executedApproved) {
77
+ const parentKey = `${entry.featureId}:${entry.parentTaskId}`;
78
+ if (recoveredTasks.has(parentKey))
79
+ continue;
80
+ const sourceRun = allRuns.find((run) => run.featureId === entry.featureId && run.workerRunId === entry.workerRunId);
81
+ const replacementSucceeded = runs.some((run) => run.featureId === entry.featureId && run.taskId === entry.proposedTaskId && run.status === "succeeded" && run.recordedAt >= entry.approvedAt);
82
+ if (!sourceRun || !replacementSucceeded)
83
+ continue;
84
+ const failedAttempts = runs.filter((run) => run.featureId === entry.featureId && run.taskId === entry.parentTaskId && run.status !== "succeeded" && run.recordedAt <= sourceRun.recordedAt).length;
85
+ if (failedAttempts > 0) {
86
+ recoveryRounds.push(failedAttempts);
87
+ recoveredTasks.add(parentKey);
88
+ }
89
+ }
90
+ const boundary = await countBoundaryInterceptions(path.join(getTaskPoolRoot(repoRoot), "artifacts", "features"), start, end);
91
+ const latencySum = decisionLatencies.reduce((a, b) => a + b, 0);
92
+ const recoverySum = recoveryRounds.reduce((a, b) => a + b, 0);
93
+ const metrics = { schemaVersion: 1, month: input.month, window: { start: start.toISOString(), end: end.toISOString() }, generatedAt: (input.now ?? new Date()).toISOString(), metrics: {
94
+ featureClosureRate: ratio(closed, entered.size, [...(entered.size ? [] : ["no Feature entered execution in window"]), ...projectionWarnings]),
95
+ orphanFreeFailureRate: ratio(handled, failed.length, [...(failed.length ? [] : ["no failed runs in window"]), ...metricWarnings, ...projectionWarnings]),
96
+ approvedFollowUpSuccessRate: ratio(approvedSuccess, executedApproved.length, [...(executedApproved.length ? [] : approved.length ? ["approved Follow-up exists but has not executed"] : ["no approved Follow-up in window"]), ...metricWarnings, ...projectionWarnings]),
97
+ deliverableOutputRate: ratio([...successfulDev].filter((id) => deliveryTasks.has(id)).length, successfulDev.size, [...(successfulDev.size ? [] : ["no successful development task in window"]), ...projectionWarnings]),
98
+ requiredAcEvidenceRate: ratio(acComplete, required.length, [...(required.length ? ["formal Blocked decisions are not yet a distinct persisted fact"] : ["no required AC discovered"]), ...projectionWarnings]),
99
+ humanDecisionLatency: { numerator: latencySum, denominator: decisionLatencies.length, value: decisionLatencies.length ? latencySum / decisionLatencies.length : null, sampleSize: decisionLatencies.length, valuesMs: decisionLatencies, medianMs: median(decisionLatencies), missingData: [...(decisionLatencies.length ? [] : ["no completed approval decision in window"]), ...projectionWarnings] },
100
+ failureRecoveryRounds: { numerator: recoverySum, denominator: recoveryRounds.length, value: recoveryRounds.length ? recoverySum / recoveryRounds.length : null, sampleSize: recoveryRounds.length, values: recoveryRounds, average: recoveryRounds.length ? recoverySum / recoveryRounds.length : null, missingData: [...(recoveryRounds.length ? [] : ["no failed task recovered inside the requested window"]), ...projectionWarnings] },
101
+ writeBoundaryInterceptions: { numerator: boundary.interceptions, denominator: boundary.audited, value: boundary.audited ? boundary.interceptions / boundary.audited : null, count: boundary.interceptions, sampleSize: boundary.audited, missingData: [...(boundary.audited ? [] : ["no boundary audit artifacts in window"]), ...projectionWarnings] },
102
+ } };
103
+ const outputDir = path.join(getTaskPoolRoot(repoRoot), "artifacts", "metrics");
104
+ const jsonPath = path.join(outputDir, `monthly-${input.month}.json`);
105
+ const markdownPath = path.join(outputDir, `monthly-${input.month}.md`);
106
+ await mkdir(outputDir, { recursive: true });
107
+ await atomicWrite(jsonPath, `${JSON.stringify(metrics, null, 2)}\n`);
108
+ await atomicWrite(markdownPath, renderMetrics(metrics));
109
+ return { metrics, jsonPath, markdownPath };
110
+ }
111
+ function ratio(numerator, denominator, missingData) { return { numerator, denominator, value: denominator ? numerator / denominator : null, sampleSize: denominator, missingData }; }
112
+ function median(values) { if (!values.length)
113
+ return null; const middle = Math.floor(values.length / 2); return values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2; }
114
+ function inWindow(value, start, end) { const time = new Date(value).getTime(); return time >= start.getTime() && time < end.getTime(); }
115
+ function dedupeRuns(runs) { return [...new Map(runs.map((run) => [run.workerRunId, run])).values()]; }
116
+ async function countBoundaryInterceptions(root, start, end) { let interceptions = 0; let audited = 0; try {
117
+ for (const entry of await readdir(root, { withFileTypes: true })) {
118
+ const target = path.join(root, entry.name);
119
+ if (entry.isDirectory()) {
120
+ const child = await countBoundaryInterceptions(target, start, end);
121
+ interceptions += child.interceptions;
122
+ audited += child.audited;
123
+ }
124
+ else if (entry.name === "failure.json")
125
+ try {
126
+ const raw = JSON.parse(await readFile(target, "utf-8"));
127
+ if (typeof raw.writeBoundaryAudit?.ok === "boolean" && raw.capturedAt && inWindow(raw.capturedAt, start, end)) {
128
+ audited += 1;
129
+ if (!raw.writeBoundaryAudit.ok)
130
+ interceptions += 1;
131
+ }
132
+ }
133
+ catch { }
134
+ }
135
+ }
136
+ catch { } return { interceptions, audited }; }
137
+ async function atomicWrite(filePath, content) { const temp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`); await writeFile(temp, content); await rename(temp, filePath); }
138
+ function renderMetrics(metrics) { const rows = Object.entries(metrics.metrics).map(([name, value]) => `| ${name} | ${value.numerator} | ${value.denominator} | ${value.value ?? "n/a"} | ${value.sampleSize} | ${value.missingData.join("; ") || "none"} |`); return `# Monthly Metrics ${metrics.month}\n\nWindow: ${metrics.window.start} — ${metrics.window.end}\n\n| Metric | Numerator | Denominator | Value | Sample | Missing data |\n|---|---:|---:|---:|---:|---|\n${rows.join("\n")}\n`; }
139
+ function isDevelopmentType(type) { return Boolean(type && !type.startsWith("qa-") && !["architecture", "review"].includes(type)); }