@tea-agent/loop-agent 0.26.5-beta.2 → 0.27.1-beta.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 (79) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +13 -23
  4. package/dist/application/dag/args.js +7 -7
  5. package/dist/application/dag/generate-task-dag.js +2 -2
  6. package/dist/application/dag/run-dag.js +3 -4
  7. package/dist/application/dag/validate-dag.js +1 -1
  8. package/dist/application/task-lifecycle/advance.js +845 -0
  9. package/dist/application/task-lifecycle/gates.js +80 -0
  10. package/dist/application/task-lifecycle/index.js +7 -0
  11. package/dist/application/task-lifecycle/observe.js +395 -0
  12. package/dist/application/task-lifecycle/plan-transitions.js +224 -0
  13. package/dist/application/task-lifecycle/recommendations.js +180 -0
  14. package/dist/application/task-lifecycle/record.js +73 -0
  15. package/dist/application/task-lifecycle/types.js +1 -0
  16. package/dist/cli/command-definitions.js +14 -111
  17. package/dist/cli/help.js +6 -7
  18. package/dist/cli/program.js +26 -90
  19. package/dist/cli/update/policy.js +0 -1
  20. package/dist/commands/dag-final-verification.js +1 -1
  21. package/dist/commands/dag-init-hybrid.js +1 -1
  22. package/dist/commands/delegate.js +2 -2
  23. package/dist/commands/init.js +21 -19
  24. package/dist/commands/run-dag-progress.js +1 -1
  25. package/dist/commands/status.js +18 -17
  26. package/dist/commands/study-init.js +2 -2
  27. package/dist/commands/task-advance.js +335 -0
  28. package/dist/commands/task-contract.js +3 -5
  29. package/dist/commands/task-source-prepare.js +9 -4
  30. package/dist/commands/task-status.js +133 -0
  31. package/dist/executors/dag-pi-executor.js +18 -22
  32. package/dist/executors/shell-executor.js +41 -25
  33. package/dist/governance/manifest-types.js +2 -2
  34. package/dist/shared/operator/capabilities.js +1358 -243
  35. package/dist/task/contract/adopt.js +1 -1
  36. package/dist/task/contract/apply.js +1 -1
  37. package/dist/task/contract/import-revision.js +1 -1
  38. package/dist/task/contract/recover.js +2 -2
  39. package/dist/task/read-model.js +18 -23
  40. package/dist/task/runtime.js +1 -1
  41. package/dist/task/source-prepare/completeness.js +1 -1
  42. package/dist/task/source-prepare/parse-intent.js +6 -1
  43. package/dist/task/source-prepare/prepare.js +23 -20
  44. package/dist/worker/console/operator-actions.js +288 -95
  45. package/dist/worker/console/recovery-cta.js +4 -4
  46. package/dist/worker/console/static/assets/{index-CSRIhuzh.js → index-CNO7n6qB.js} +1 -1
  47. package/dist/worker/console/static/index.html +1 -1
  48. package/dist/worker/materialize/harness-task-materializer.js +6 -5
  49. package/dist/worker/run-task/run-task.js +204 -112
  50. package/dist/worker/runner/run-ready.js +1 -1
  51. package/dist/workflows/dag/backend-test-markdown-workflow.js +49 -23
  52. package/dist/workflows/dag/backend-test-pytest-collection.js +57 -9
  53. package/dist/workflows/dag/frontend-implementation-contract.js +3 -3
  54. package/dist/workflows/dag/frontend-prewrite-gate.js +18 -1
  55. package/dist/workflows/dag/frontend-test-l5-report.js +46 -7
  56. package/dist/workflows/dag/init-hybrid.js +6 -35
  57. package/dist/workflows/dag/output-protocol.js +23 -0
  58. package/docs/templates/backend-test-dag.json +2 -2
  59. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  60. package/docs/templates/evaluation/agents-map-verbose-v0.md +3 -3
  61. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  62. package/docs/templates/harness.schema.json +2 -2
  63. package/docs/templates/init-managed-agents.md +13 -16
  64. package/docs/templates/production-readiness-checklist.md +3 -3
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/scripts/kb-bootstrap-init-skeleton.sh +2 -1
  68. package/scripts/kb-graph-incremental-prepare.mjs +2 -2
  69. package/skills/loop-agent/SKILL.md +18 -13
  70. package/skills/loop-agent/references/README.md +1 -1
  71. package/skills/loop-agent/references/command-reference.md +55 -84
  72. package/skills/loop-agent/references/harness-policy.md +19 -23
  73. package/skills/loop-agent/references/hybrid-dag.md +32 -34
  74. package/skills/loop-agent/references/long-running-loop.md +2 -2
  75. package/skills/loop-agent/references/one-shot-runs.md +4 -5
  76. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  77. package/skills/loop-agent/references/post-implementation-and-patterns.md +4 -4
  78. package/skills/loop-agent/references/source-and-plan-practice.md +45 -51
  79. package/skills/loop-agent/references/task-workflow.md +14 -15
@@ -0,0 +1,335 @@
1
+ import { advanceTaskLifecycle, } from "../application/task-lifecycle/index.js";
2
+ import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
3
+ const COMMAND = "task advance";
4
+ const USAGE = `usage:
5
+ task advance <task-id> [title]
6
+ [--prd <path>]...
7
+ [--role <role>]...
8
+ [--from-text <string>]
9
+ [--from-draft <TaskContractDraftV1.json>]
10
+ [--task-kind <kind>]
11
+ [--feature-id <id>]
12
+ [--allowed-path <glob>]...
13
+ [--forbidden-path <glob>]...
14
+ [--verify <label:command>]...
15
+ [--verify-timeout <label:timeoutMs>]...
16
+ [--accept-recommendations <digest>]
17
+ [--approve-gate <gate-id:digest>]
18
+ [--reject-gate <gate-id:digest> --reason <text>]
19
+ [--profile auto|minimal|standard|reviewed|supervised]
20
+ [--max-concurrent <n>]
21
+ [--run-id <id>]
22
+ [--events-jsonl <path>]
23
+ [--worker-association <json>]
24
+ [--dag-output <path>]
25
+ [--skip-finalize]
26
+ [--no-strict-models]
27
+ [--dry-run]
28
+ [--json]`;
29
+ function pushList(target, value) {
30
+ const list = target ?? [];
31
+ list.push(value);
32
+ return list;
33
+ }
34
+ function parseVerify(flag) {
35
+ const idx = flag.indexOf(":");
36
+ if (idx <= 0 || idx === flag.length - 1) {
37
+ throw new Error(`invalid --verify ${flag}; expected label:command`);
38
+ }
39
+ return { label: flag.slice(0, idx), command: flag.slice(idx + 1) };
40
+ }
41
+ function parseVerifyTimeout(flag) {
42
+ const idx = flag.indexOf(":");
43
+ if (idx <= 0 || idx === flag.length - 1) {
44
+ throw new Error(`invalid --verify-timeout ${flag}; expected label:timeoutMs`);
45
+ }
46
+ const timeoutMs = Number(flag.slice(idx + 1));
47
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
48
+ throw new Error(`invalid --verify-timeout ${flag}; timeoutMs must be > 0`);
49
+ }
50
+ return { label: flag.slice(0, idx), timeoutMs };
51
+ }
52
+ export function parseTaskAdvanceArgs(args) {
53
+ const options = {};
54
+ const positional = [];
55
+ for (let i = 0; i < args.length; i += 1) {
56
+ const token = args[i];
57
+ const next = () => {
58
+ const value = args[i + 1];
59
+ if (value === undefined || value.startsWith("-")) {
60
+ throw new Error(`missing value for ${token}\n${USAGE}`);
61
+ }
62
+ i += 1;
63
+ return value;
64
+ };
65
+ if (token === "--json") {
66
+ options.json = true;
67
+ continue;
68
+ }
69
+ if (token === "--dry-run") {
70
+ options.dryRun = true;
71
+ continue;
72
+ }
73
+ if (token === "--prd") {
74
+ options.prdPaths = pushList(options.prdPaths, next());
75
+ continue;
76
+ }
77
+ if (token === "--role") {
78
+ options.roles = pushList(options.roles, next());
79
+ continue;
80
+ }
81
+ if (token === "--from-text") {
82
+ options.fromText = next();
83
+ continue;
84
+ }
85
+ if (token === "--from-draft") {
86
+ options.fromDraft = next();
87
+ continue;
88
+ }
89
+ if (token === "--task-kind") {
90
+ options.taskKind = next();
91
+ continue;
92
+ }
93
+ if (token === "--feature-id") {
94
+ options.featureId = next();
95
+ continue;
96
+ }
97
+ if (token === "--allowed-path") {
98
+ options.allowedPaths = pushList(options.allowedPaths, next());
99
+ continue;
100
+ }
101
+ if (token === "--forbidden-path") {
102
+ options.forbiddenPaths = pushList(options.forbiddenPaths, next());
103
+ continue;
104
+ }
105
+ if (token === "--verify") {
106
+ options.verify = pushList(options.verify, next());
107
+ continue;
108
+ }
109
+ if (token === "--verify-timeout") {
110
+ options.verifyTimeout = pushList(options.verifyTimeout, next());
111
+ continue;
112
+ }
113
+ if (token === "--accept-recommendations") {
114
+ options.acceptRecommendations = next();
115
+ continue;
116
+ }
117
+ if (token === "--approve-gate") {
118
+ options.approveGate = next();
119
+ continue;
120
+ }
121
+ if (token === "--reject-gate") {
122
+ options.rejectGate = next();
123
+ continue;
124
+ }
125
+ if (token === "--reason") {
126
+ options.reason = next();
127
+ continue;
128
+ }
129
+ if (token === "--profile") {
130
+ const value = next();
131
+ if (value !== "auto" &&
132
+ value !== "minimal" &&
133
+ value !== "standard" &&
134
+ value !== "reviewed" &&
135
+ value !== "supervised") {
136
+ throw new Error(`--profile must be auto|minimal|standard|reviewed|supervised\n${USAGE}`);
137
+ }
138
+ options.profile = value;
139
+ continue;
140
+ }
141
+ if (token === "--max-concurrent") {
142
+ const value = Number(next());
143
+ if (!Number.isFinite(value) || value <= 0) {
144
+ throw new Error(`--max-concurrent must be a positive number\n${USAGE}`);
145
+ }
146
+ options.maxConcurrent = value;
147
+ continue;
148
+ }
149
+ if (token === "--run-id") {
150
+ options.runId = next();
151
+ continue;
152
+ }
153
+ if (token === "--events-jsonl") {
154
+ options.eventsJsonlPath = next();
155
+ continue;
156
+ }
157
+ if (token === "--worker-association") {
158
+ options.workerAssociation = next();
159
+ continue;
160
+ }
161
+ if (token === "--dag-output") {
162
+ options.dagOutputPath = next();
163
+ continue;
164
+ }
165
+ if (token === "--skip-finalize") {
166
+ options.skipFinalize = true;
167
+ continue;
168
+ }
169
+ if (token === "--no-strict-models") {
170
+ options.strictModels = false;
171
+ continue;
172
+ }
173
+ if (token === "--help" || token === "-h") {
174
+ throw new Error(USAGE);
175
+ }
176
+ if (token.startsWith("-")) {
177
+ throw new Error(`unknown flag ${token}\n${USAGE}`);
178
+ }
179
+ positional.push(token);
180
+ }
181
+ const taskId = positional[0];
182
+ if (!taskId)
183
+ throw new Error(USAGE);
184
+ if (positional.length > 1) {
185
+ options.title = positional.slice(1).join(" ");
186
+ }
187
+ return { taskId, options };
188
+ }
189
+ function mapOutcome(result) {
190
+ if (result.blockers.length > 0) {
191
+ const code = result.blockers[0]?.code ?? "";
192
+ if (code === "STALE_GATE" || code.includes("CONFLICT"))
193
+ return "conflict";
194
+ if (code === "INTAKE_REQUIRED" || code === "INVALID_DRAFT")
195
+ return "invalid";
196
+ if (code === "NOT_FOUND" || code === "TASK_NOT_FOUND")
197
+ return "not-found";
198
+ return "blocked";
199
+ }
200
+ if (result.lifecycleState === "awaiting-write-set-approval") {
201
+ // Shared operator outcomes do not include awaiting-approval; use blocked
202
+ // with result.gate so machine clients can branch on lifecycleState/gate.
203
+ return "blocked";
204
+ }
205
+ if (result.lifecycleState === "needs-attention")
206
+ return "needs-reconcile";
207
+ if (result.lifecycleState === "run-failed")
208
+ return "blocked";
209
+ return "succeeded";
210
+ }
211
+ function toUseCaseInput(repoRoot, taskId, options) {
212
+ const timeouts = new Map((options.verifyTimeout ?? []).map((entry) => {
213
+ const parsed = parseVerifyTimeout(entry);
214
+ return [parsed.label, parsed.timeoutMs];
215
+ }));
216
+ const verifyCommands = (options.verify ?? []).map((entry) => {
217
+ const parsed = parseVerify(entry);
218
+ const timeoutMs = timeouts.get(parsed.label);
219
+ return timeoutMs
220
+ ? { ...parsed, timeoutMs }
221
+ : { label: parsed.label, command: parsed.command };
222
+ });
223
+ const roles = options.roles ?? [];
224
+ const prdPaths = (options.prdPaths ?? []).map((filePath, index) => ({
225
+ filePath,
226
+ role: roles[index],
227
+ }));
228
+ let workerAssociation;
229
+ if (options.workerAssociation) {
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(options.workerAssociation);
233
+ }
234
+ catch {
235
+ throw new Error("--worker-association must be valid JSON {schemaVersion:1,workerRunId,featureId,taskId}");
236
+ }
237
+ if (!parsed ||
238
+ typeof parsed !== "object" ||
239
+ parsed.schemaVersion !== 1 ||
240
+ typeof parsed.workerRunId !== "string" ||
241
+ typeof parsed.featureId !== "string" ||
242
+ typeof parsed.taskId !== "string") {
243
+ throw new Error("--worker-association must be JSON {schemaVersion:1,workerRunId,featureId,taskId}");
244
+ }
245
+ workerAssociation = parsed;
246
+ }
247
+ return {
248
+ repoRoot,
249
+ taskId,
250
+ title: options.title,
251
+ prdPaths: prdPaths.length > 0 ? prdPaths : undefined,
252
+ fromText: options.fromText,
253
+ fromDraftPath: options.fromDraft,
254
+ taskKind: options.taskKind,
255
+ featureId: options.featureId,
256
+ allowedPaths: options.allowedPaths,
257
+ forbiddenPaths: options.forbiddenPaths,
258
+ verifyCommands: verifyCommands.length > 0 ? verifyCommands : undefined,
259
+ approveGateToken: options.approveGate,
260
+ rejectGateToken: options.rejectGate,
261
+ rejectReason: options.reason,
262
+ acceptRecommendationsDigest: options.acceptRecommendations,
263
+ dryRun: options.dryRun,
264
+ profile: options.profile,
265
+ profileExplicit: Boolean(options.profile),
266
+ maxConcurrent: options.maxConcurrent,
267
+ runId: options.runId,
268
+ eventsJsonlPath: options.eventsJsonlPath,
269
+ workerAssociation,
270
+ dagOutputPath: options.dagOutputPath,
271
+ skipFinalize: options.skipFinalize,
272
+ strictModels: options.strictModels,
273
+ onProgress: options.json
274
+ ? undefined
275
+ : (message) => {
276
+ process.stderr.write(`[task advance] ${message}\n`);
277
+ },
278
+ };
279
+ }
280
+ export async function runTaskAdvance(repoRoot, args) {
281
+ let taskId;
282
+ let options;
283
+ try {
284
+ ({ taskId, options } = parseTaskAdvanceArgs(args));
285
+ }
286
+ catch (error) {
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ const envelope = operatorFailed({
289
+ command: COMMAND,
290
+ outcome: "invalid",
291
+ code: "INVALID_INPUT",
292
+ message,
293
+ });
294
+ writeOperatorJson(envelope);
295
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
296
+ return;
297
+ }
298
+ try {
299
+ const result = await advanceTaskLifecycle(toUseCaseInput(repoRoot, taskId, options));
300
+ const outcome = mapOutcome(result);
301
+ const gateStop = result.lifecycleState === "awaiting-write-set-approval" &&
302
+ result.blockers.length === 0;
303
+ const createOnlyStop = result.lifecycleState === "intake-incomplete" &&
304
+ result.blockers.length === 0 &&
305
+ (result.completedTransitions.includes("task-created") ||
306
+ result.completedTransitions.includes("prd-imported"));
307
+ const ok = outcome === "succeeded" || gateStop || createOnlyStop;
308
+ const envelope = buildOperatorResult({
309
+ command: COMMAND,
310
+ ok,
311
+ // awaiting write-set approval is a successful stop condition for
312
+ // the first advance call; keep outcome blocked for machine branch
313
+ // but ok=true when no blockers.
314
+ outcome: gateStop ? "blocked" : createOnlyStop ? "succeeded" : outcome,
315
+ result,
316
+ warnings: result.warnings,
317
+ });
318
+ writeOperatorJson(envelope);
319
+ process.exitCode =
320
+ envelope.ok && envelope.outcome === "blocked"
321
+ ? 0
322
+ : processExitCodeForOutcome(envelope.outcome);
323
+ }
324
+ catch (error) {
325
+ const message = error instanceof Error ? error.message : String(error);
326
+ const envelope = operatorFailed({
327
+ command: COMMAND,
328
+ outcome: "blocked",
329
+ code: "INTERNAL_ERROR",
330
+ message,
331
+ });
332
+ writeOperatorJson(envelope);
333
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
334
+ }
335
+ }
@@ -1,13 +1,11 @@
1
1
  import { ContractMutationError, adoptTaskContract, applyTaskContract, diffTaskContract, doctorTaskContract, loadDraftFromPath, observeTaskContractState, recoverTaskContract, validateDraftForTask, } from "../task/contract/index.js";
2
2
  import { operatorFailed, operatorSucceeded, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
3
3
  const USAGE = `usage:
4
- task contract show <taskId> [--json]
5
4
  task contract validate --task <id> --input <path> [--json]
6
5
  task contract diff --task <id> --input <path> [--json]
7
- task contract apply --task <id> --input <path> --expected-revision <n> --expected-observed-hash <sha256> --request-id <id> --request-payload-sha256 <sha256> [--json]
8
- task contract adopt --task <id> --expected-revision <n> --expected-observed-hash <sha256> --request-id <id> --request-payload-sha256 <sha256> [--json]
9
- task contract doctor --task <id> [--json]
10
- task contract recover --task <id> [--tx-id <id>] [--json]`;
6
+
7
+ Advanced-only public leaves. Managed apply/show/adopt/doctor/recover are internal services
8
+ reached via task advance / task status (or application APIs), not public CLI subcommands.`;
11
9
  export function parseTaskContractArgs(args) {
12
10
  const options = {};
13
11
  const positional = [];
@@ -205,13 +205,13 @@ function validateOptionCombinations(options) {
205
205
  throw new Error(`need at least one intent source: --use-imported-prd, --from-text/--from-file/--from-stdin, --from-draft, or sufficient flags (title/objective + --ac + --allowed-path)\n${USAGE}`);
206
206
  }
207
207
  }
208
- async function readStdinText() {
209
- if (stdinStream.isTTY) {
208
+ export async function readTaskSourcePrepareStdin(stream = stdinStream) {
209
+ if (stream.isTTY) {
210
210
  throw new Error("--from-stdin refused on TTY; pipe input or use --from-file/--from-text");
211
211
  }
212
212
  const chunks = [];
213
213
  let total = 0;
214
- for await (const chunk of stdinStream) {
214
+ for await (const chunk of stream) {
215
215
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
216
216
  total += buf.byteLength;
217
217
  if (total > PREPARE_MAX_FILE_BYTES) {
@@ -239,7 +239,7 @@ async function loadTextInput(repoRoot, options) {
239
239
  return { text: raw.toString("utf-8"), label: options.fromFile };
240
240
  }
241
241
  if (options.fromStdin) {
242
- const text = await readStdinText();
242
+ const text = await readTaskSourcePrepareStdin();
243
243
  return { text, label: "stdin" };
244
244
  }
245
245
  return {};
@@ -386,6 +386,11 @@ function printHuman(result) {
386
386
  console.log(lines.join("\n"));
387
387
  }
388
388
  export async function runTaskSourcePrepare(repoRoot, args) {
389
+ if (args.includes("--help") || args.includes("-h")) {
390
+ console.log(USAGE);
391
+ process.exitCode = 0;
392
+ return;
393
+ }
389
394
  let taskId;
390
395
  let options = {};
391
396
  try {
@@ -0,0 +1,133 @@
1
+ import { observeTaskLifecycle } from "../application/task-lifecycle/index.js";
2
+ import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
3
+ const COMMAND = "task status";
4
+ const USAGE = `usage:
5
+ task status <task-id> [--json|--markdown] [--details]`;
6
+ export function parseTaskStatusArgs(args) {
7
+ const options = {};
8
+ const positional = [];
9
+ for (const token of args) {
10
+ if (token === "--json") {
11
+ options.json = true;
12
+ continue;
13
+ }
14
+ if (token === "--markdown") {
15
+ options.markdown = true;
16
+ continue;
17
+ }
18
+ if (token === "--details") {
19
+ options.details = true;
20
+ continue;
21
+ }
22
+ if (token === "--help" || token === "-h") {
23
+ throw new Error(USAGE);
24
+ }
25
+ if (token.startsWith("-")) {
26
+ throw new Error(`unknown flag ${token}\n${USAGE}`);
27
+ }
28
+ positional.push(token);
29
+ }
30
+ const taskId = positional[0];
31
+ if (!taskId || positional.length > 1)
32
+ throw new Error(USAGE);
33
+ return { taskId, options };
34
+ }
35
+ function formatMarkdown(snapshot, details) {
36
+ const lines = [
37
+ `# Task ${snapshot.taskId}`,
38
+ "",
39
+ `- lifecycleState: \`${snapshot.lifecycleState}\``,
40
+ `- exists: ${snapshot.exists}`,
41
+ ];
42
+ if (snapshot.title)
43
+ lines.push(`- title: ${snapshot.title}`);
44
+ if (snapshot.contract) {
45
+ lines.push(`- contract: ${snapshot.contract.effectiveStatus} rev=${snapshot.contract.revision ?? "n/a"}`);
46
+ }
47
+ if (snapshot.dagDraft) {
48
+ lines.push(`- dagDraft: exists=${snapshot.dagDraft.exists} strictValidated=${snapshot.dagDraft.strictValidated}`);
49
+ if (details && snapshot.dagDraft.writeSet.length > 0) {
50
+ lines.push(`- writeSet: ${snapshot.dagDraft.writeSet.join(", ")}`);
51
+ }
52
+ }
53
+ if (snapshot.gate) {
54
+ lines.push(`- gate: ${snapshot.gate.id} digest=${snapshot.gate.digest}`);
55
+ lines.push(`- approve: \`${snapshot.gate.approveCommand}\``);
56
+ }
57
+ if (snapshot.activeRun) {
58
+ lines.push(`- activeRun: ${snapshot.activeRun.runId} (${snapshot.activeRun.lifecycle}/${snapshot.activeRun.status ?? "?"})`);
59
+ }
60
+ if (snapshot.latestRun) {
61
+ lines.push(`- latestRun: ${snapshot.latestRun.runId} (${snapshot.latestRun.lifecycle}/${snapshot.latestRun.status ?? "?"})`);
62
+ }
63
+ if (snapshot.next.kind === "none") {
64
+ lines.push("- next: none");
65
+ }
66
+ else if (snapshot.next.kind === "external-action") {
67
+ lines.push(`- next: ${snapshot.next.description}`);
68
+ }
69
+ else {
70
+ lines.push(`- next: \`${snapshot.next.command}\``);
71
+ }
72
+ if (details && snapshot.completedTransitions.length > 0) {
73
+ lines.push("", "## completedTransitions", "");
74
+ for (const transition of snapshot.completedTransitions) {
75
+ lines.push(`- ${transition}`);
76
+ }
77
+ }
78
+ if (snapshot.blockers.length > 0) {
79
+ lines.push("", "## blockers", "");
80
+ for (const blocker of snapshot.blockers) {
81
+ lines.push(`- ${blocker.code}: ${blocker.message}`);
82
+ }
83
+ }
84
+ return `${lines.join("\n")}\n`;
85
+ }
86
+ export async function runTaskStatus(repoRoot, args) {
87
+ let taskId;
88
+ let options;
89
+ try {
90
+ ({ taskId, options } = parseTaskStatusArgs(args));
91
+ }
92
+ catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ const envelope = operatorFailed({
95
+ command: COMMAND,
96
+ outcome: "invalid",
97
+ code: "INVALID_INPUT",
98
+ message,
99
+ });
100
+ writeOperatorJson(envelope);
101
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
102
+ return;
103
+ }
104
+ try {
105
+ const snapshot = await observeTaskLifecycle(repoRoot, taskId);
106
+ if (options.markdown && !options.json) {
107
+ process.stdout.write(formatMarkdown(snapshot, Boolean(options.details)));
108
+ process.exitCode = snapshot.exists ? 0 : 2;
109
+ return;
110
+ }
111
+ const envelope = buildOperatorResult({
112
+ command: COMMAND,
113
+ ok: true,
114
+ outcome: snapshot.exists ? "succeeded" : "not-found",
115
+ result: snapshot,
116
+ });
117
+ // status is read-only; missing task is still a valid observation.
118
+ envelope.ok = true;
119
+ writeOperatorJson(envelope);
120
+ process.exitCode = snapshot.exists ? 0 : 2;
121
+ }
122
+ catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ const envelope = operatorFailed({
125
+ command: COMMAND,
126
+ outcome: "blocked",
127
+ code: "INTERNAL_ERROR",
128
+ message,
129
+ });
130
+ writeOperatorJson(envelope);
131
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
132
+ }
133
+ }
@@ -576,32 +576,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
576
576
  export function validateWriterImplementationOutcome(text, changedFiles) {
577
577
  const parsed = parseWriterImplementationOutcome(text);
578
578
  const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
579
- if (parsed.kind !== "valid") {
579
+ // The workspace diff is authoritative. The model outcome is advisory and
580
+ // may be missing, malformed, or inconsistent without blocking a completed
581
+ // writer. An explicit blocked signal remains a hard failure.
582
+ if (parsed.candidates.some((candidate) => candidate.outcome === "blocked")) {
580
583
  return {
581
584
  ok: false,
582
- reason: `writer outcome validation failed: ${parsed.kind} outcome; ${diagnostics}; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked`,
585
+ reason: `writer outcome validation failed: explicit blocked outcome; ${diagnostics}`,
583
586
  };
584
587
  }
585
- const outcome = parsed.outcome;
586
- if (outcome === "blocked") {
587
- return {
588
- ok: false,
589
- reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
590
- };
591
- }
592
- if (outcome === "changed" && changedFiles.length === 0) {
593
- return {
594
- ok: false,
595
- reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
596
- };
597
- }
598
- if (outcome === "already-satisfied" && changedFiles.length > 0) {
599
- return {
600
- ok: false,
601
- reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
602
- };
603
- }
604
- return { ok: true, outcome };
588
+ return {
589
+ ok: true,
590
+ outcome: changedFiles.length > 0 ? "changed" : "already-satisfied",
591
+ };
605
592
  }
606
593
  function parseWriterImplementationOutcome(text) {
607
594
  const lines = text.split(/\r?\n/);
@@ -732,6 +719,15 @@ function normalizeProtocolLine(line, firstProtocolLine, nextLine) {
732
719
  const value = normalizedProtocolValue(direct[1] ?? "");
733
720
  return value ? `${firstProtocolLine} ${value}` : undefined;
734
721
  }
722
+ // Writers sometimes place a short delivery sentence before the protocol
723
+ // line. Accept the protocol token when it appears inline, while preserving
724
+ // the candidate value so template repetitions still fail as unknown or
725
+ // conflicting outcomes.
726
+ const inline = normalizedLine.match(new RegExp(`${labelPattern}\\s*[::]\\s*(.+)$`, "i"));
727
+ if (inline) {
728
+ const value = normalizedProtocolValue(inline[1] ?? "");
729
+ return value ? `${firstProtocolLine} ${value}` : undefined;
730
+ }
735
731
  const splitValue = normalizedLine.match(new RegExp(`^${labelPattern}\\s*[::]?\\s*$`, "i"));
736
732
  if (splitValue && nextLine !== undefined) {
737
733
  const value = normalizedProtocolValue(nextLine);
@@ -24,8 +24,8 @@ import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBa
24
24
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
25
25
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
26
26
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
27
- import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
28
- import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
27
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
28
+ import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
29
29
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
30
30
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
31
31
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -515,31 +515,45 @@ async function executeBackendTestPipeline(input, meta) {
515
515
  }
516
516
  }
517
517
  else if (pipeline === "markdown-collection-assess") {
518
- const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
519
- const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
520
- const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
521
- const targets = mappedScripts.map(shellQuote).join(" ");
522
- const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
523
- const [result] = await executePipelineCommands(input, meta, [command]);
524
- if (!result)
525
- throw new Error("backend pytest collection command did not produce a result");
526
- if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
527
- return {
528
- ok: false,
518
+ const resolution = await resolveBackendTestMappedPytestScripts(input.cwd);
519
+ let facts;
520
+ if (resolution.missingScripts.length > 0) {
521
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
522
+ facts = assessMissingBackendPytestScripts({
523
+ mappedScripts: resolution.expectedScripts,
524
+ existingMappedScripts: resolution.existingScripts,
525
+ missingMappedScripts: resolution.missingScripts,
526
+ assetFiles: inventory.assetFiles,
527
+ inputHashes: inventory.inputHashes,
528
+ });
529
+ }
530
+ else {
531
+ const mappedScripts = resolution.existingScripts;
532
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
533
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
534
+ const targets = mappedScripts.map(shellQuote).join(" ");
535
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
536
+ const [result] = await executePipelineCommands(input, meta, [command]);
537
+ if (!result)
538
+ throw new Error("backend pytest collection command did not produce a result");
539
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
540
+ return {
541
+ ok: false,
542
+ stdout: result.stdout,
543
+ stderr: result.stderr || "backend pytest collection could not start",
544
+ failureCategory: result.failureCategory,
545
+ durationMs: Date.now() - started,
546
+ };
547
+ }
548
+ facts = assessBackendPytestCollection({
549
+ phase: "initial",
550
+ mappedScripts,
551
+ inventory,
552
+ exitCode: result.exitCode ?? 2,
529
553
  stdout: result.stdout,
530
- stderr: result.stderr || "backend pytest collection could not start",
531
- failureCategory: result.failureCategory,
532
- durationMs: Date.now() - started,
533
- };
554
+ stderr: result.stderr,
555
+ });
534
556
  }
535
- const facts = assessBackendPytestCollection({
536
- phase: "initial",
537
- mappedScripts,
538
- inventory,
539
- exitCode: result.exitCode ?? 2,
540
- stdout: result.stdout,
541
- stderr: result.stderr,
542
- });
543
557
  const artifacts = await writeBackendPytestCollectionArtifacts({
544
558
  runDir: meta.runDir,
545
559
  stem: "initial",
@@ -551,8 +565,10 @@ async function executeBackendTestPipeline(input, meta) {
551
565
  stdout: JSON.stringify({
552
566
  status: facts.status,
553
567
  repairEligible: facts.repairEligible,
568
+ collectionAttempted: facts.collectionAttempted,
554
569
  pytestExitCode: facts.pytestExitCode,
555
570
  collectedItemCount: facts.collectedItemCount,
571
+ missingMappedScripts: facts.missingMappedScripts,
556
572
  factsPath: "contracts/backend-test-pytest-collection-initial.json",
557
573
  }),
558
574
  stderr: "",