@tea-agent/loop-agent 0.16.25 → 0.17.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 (115) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +118 -16
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +85 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/executors/shell-write-guard.js +64 -2
  16. package/dist/shared/operator/capabilities.js +255 -0
  17. package/dist/shared/operator/envelope.js +59 -0
  18. package/dist/shared/operator/index.js +4 -0
  19. package/dist/shared/operator/registry.js +38 -0
  20. package/dist/shared/operator/types.js +5 -0
  21. package/dist/task/contract/adopt.js +166 -0
  22. package/dist/task/contract/apply.js +326 -0
  23. package/dist/task/contract/canonicalize.js +60 -0
  24. package/dist/task/contract/constants.js +29 -0
  25. package/dist/task/contract/diff.js +177 -0
  26. package/dist/task/contract/hash.js +42 -0
  27. package/dist/task/contract/import-revision.js +96 -0
  28. package/dist/task/contract/index.js +17 -0
  29. package/dist/task/contract/journal.js +155 -0
  30. package/dist/task/contract/lock.js +153 -0
  31. package/dist/task/contract/observe.js +296 -0
  32. package/dist/task/contract/paths.js +19 -0
  33. package/dist/task/contract/project.js +170 -0
  34. package/dist/task/contract/recover.js +312 -0
  35. package/dist/task/contract/request-ledger.js +37 -0
  36. package/dist/task/contract/schema.js +151 -0
  37. package/dist/task/contract/transaction.js +160 -0
  38. package/dist/task/contract/types.js +1 -0
  39. package/dist/task/contract/validate-draft.js +106 -0
  40. package/dist/task/index.js +3 -0
  41. package/dist/task/operator/capabilities.js +6 -0
  42. package/dist/task/operator/envelope.js +2 -0
  43. package/dist/task/operator/index.js +5 -0
  44. package/dist/task/operator/registry.js +2 -0
  45. package/dist/task/operator/types.js +1 -0
  46. package/dist/task/runtime.js +5 -1
  47. package/dist/task/source-references.js +7 -0
  48. package/dist/worker/cli.js +150 -32
  49. package/dist/worker/console/app-data.js +185 -0
  50. package/dist/worker/console/dag-confirmation.js +313 -0
  51. package/dist/worker/console/doctor.js +169 -0
  52. package/dist/worker/console/draft-store.js +80 -0
  53. package/dist/worker/console/index.js +15 -0
  54. package/dist/worker/console/interview/assessment.js +67 -0
  55. package/dist/worker/console/interview/session.js +100 -0
  56. package/dist/worker/console/interview/tools.js +109 -0
  57. package/dist/worker/console/loopback.js +16 -0
  58. package/dist/worker/console/observe-health-match.js +174 -0
  59. package/dist/worker/console/observe-link.js +33 -0
  60. package/dist/worker/console/operation-runner.js +166 -0
  61. package/dist/worker/console/operation-sse.js +158 -0
  62. package/dist/worker/console/operation-store.js +147 -0
  63. package/dist/worker/console/operator-actions.js +769 -0
  64. package/dist/worker/console/pi-readiness.js +94 -0
  65. package/dist/worker/console/recovery-cta.js +133 -0
  66. package/dist/worker/console/repo-fingerprint.js +29 -0
  67. package/dist/worker/console/resource-loader.js +95 -0
  68. package/dist/worker/console/routes.js +368 -0
  69. package/dist/worker/console/security.js +126 -0
  70. package/dist/worker/console/server.js +149 -0
  71. package/dist/worker/console/sibling-controller.js +28 -0
  72. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  73. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  74. package/dist/worker/console/static/index.html +13 -0
  75. package/dist/worker/console/vite.config.js +27 -0
  76. package/dist/worker/delivery/git-transaction.js +43 -8
  77. package/dist/worker/observe/health.js +57 -0
  78. package/dist/worker/observe/paths.js +81 -0
  79. package/dist/worker/observe/routes.js +142 -27
  80. package/dist/worker/observe/spec-evidence.js +84 -0
  81. package/dist/worker/observe/static/api.js +23 -0
  82. package/dist/worker/observe/static/state.js +26 -0
  83. package/dist/worker/observe/static/styles.css +10 -0
  84. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  85. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  86. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  87. package/dist/workflows/dag/frontend-repair.js +1 -10
  88. package/dist/workflows/dag/init-hybrid.js +372 -115
  89. package/dist/workflows/dag/node-execution.js +57 -6
  90. package/dist/workflows/dag/project-governance-context.js +508 -0
  91. package/dist/workflows/dag/prompt.js +46 -1
  92. package/dist/workflows/dag/retry-policy.js +16 -1
  93. package/dist/workflows/dag/runner.js +9 -0
  94. package/dist/workflows/dag/skill-snapshot.js +1 -0
  95. package/dist/workflows/dag/task-contract-binding.js +138 -0
  96. package/dist/workflows/dag/types.js +84 -10
  97. package/dist/workflows/dag/validate.js +53 -7
  98. package/docs/README.md +2 -0
  99. package/docs/architecture/evolution.md +2 -0
  100. package/docs/architecture/system-overview.md +6 -0
  101. package/docs/architecture/worker-and-feature.md +7 -0
  102. package/docs/templates/agent-dag.schema.json +64 -2
  103. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  104. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  105. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  106. package/docs/templates/backend-test-dag.json +26 -154
  107. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  108. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  109. package/package.json +8 -2
  110. package/skills/agent-worker/SKILL.md +1 -0
  111. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  112. package/skills/frontend-design-review/SKILL.md +25 -16
  113. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  114. package/skills/loop-agent/references/command-reference.md +48 -1
  115. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -0,0 +1,106 @@
1
+ import path from "node:path";
2
+ import { safeParseTaskContractDraft } from "./schema.js";
3
+ import { getTaskContractPaths } from "./paths.js";
4
+ import { readSourceManifest } from "../source-references.js";
5
+ function isPathOutsideRepoHint(p) {
6
+ if (path.isAbsolute(p))
7
+ return true;
8
+ if (p.includes("\0"))
9
+ return true;
10
+ const normalized = p.replace(/\\/g, "/");
11
+ if (normalized.startsWith("../") || normalized.includes("/../"))
12
+ return true;
13
+ return false;
14
+ }
15
+ export async function validateTaskContractDraft(input) {
16
+ const errors = [];
17
+ const warnings = [];
18
+ const parsed = safeParseTaskContractDraft(input.draft);
19
+ if (!parsed.success) {
20
+ for (const issue of parsed.error.issues) {
21
+ errors.push({
22
+ code: issue.message.includes("schemaVersion") ||
23
+ issue.path[0] === "schemaVersion"
24
+ ? "UNSUPPORTED_SCHEMA_VERSION"
25
+ : "INVALID_INPUT",
26
+ path: issue.path.join("."),
27
+ message: issue.message,
28
+ });
29
+ }
30
+ return {
31
+ taskId: input.taskId,
32
+ valid: false,
33
+ errors,
34
+ warnings,
35
+ };
36
+ }
37
+ const draft = parsed.data;
38
+ if (draft.taskId !== input.taskId) {
39
+ errors.push({
40
+ code: "INVALID_INPUT",
41
+ path: "taskId",
42
+ message: `draft.taskId (${draft.taskId}) does not match task (${input.taskId})`,
43
+ });
44
+ }
45
+ for (const p of draft.constraints.allowedPaths) {
46
+ if (isPathOutsideRepoHint(p) && !p.includes("*")) {
47
+ // globs may be relative; absolute is out of repo
48
+ if (path.isAbsolute(p)) {
49
+ errors.push({
50
+ code: "PATH_OUTSIDE_REPO",
51
+ path: "constraints.allowedPaths",
52
+ message: `absolute path not allowed: ${p}`,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ for (const p of draft.constraints.forbiddenPaths) {
58
+ if (path.isAbsolute(p)) {
59
+ errors.push({
60
+ code: "PATH_OUTSIDE_REPO",
61
+ path: "constraints.forbiddenPaths",
62
+ message: `absolute path not allowed: ${p}`,
63
+ });
64
+ }
65
+ }
66
+ if (draft.references && draft.references.length > 0) {
67
+ const paths = getTaskContractPaths(input.repoRoot, input.taskId);
68
+ const manifest = await readSourceManifest(paths.sourceDir);
69
+ if (!manifest) {
70
+ warnings.push({
71
+ code: "MISSING_SOURCE_MANIFEST",
72
+ message: "draft references present but source-manifest.json is missing; import-prd first for immutable evidence",
73
+ });
74
+ }
75
+ else {
76
+ for (const ref of draft.references) {
77
+ const normalized = ref.ref.replace(/\\/g, "/");
78
+ const hit = manifest.documents.some((doc) => {
79
+ const mat = doc.materializedPath.replace(/\\/g, "/");
80
+ return (mat === normalized ||
81
+ `source/${mat}` === normalized ||
82
+ normalized.endsWith(mat));
83
+ });
84
+ if (!hit) {
85
+ warnings.push({
86
+ code: "REFERENCE_NOT_IN_MANIFEST",
87
+ message: `reference not found in source-manifest: ${ref.ref}`,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ }
93
+ if (draft.sourceResolutions &&
94
+ draft.sourceResolutions.some((r) => r.decidedBy !== "human")) {
95
+ warnings.push({
96
+ code: "NON_HUMAN_SOURCE_RESOLUTION",
97
+ message: "one or more sourceResolutions are not decidedBy=human; blocking conflicts require human resolution before apply",
98
+ });
99
+ }
100
+ return {
101
+ taskId: input.taskId,
102
+ valid: errors.length === 0,
103
+ errors,
104
+ warnings,
105
+ };
106
+ }
@@ -7,3 +7,6 @@ export * from "./subagent-guidance.js";
7
7
  export * from "./runtime.js";
8
8
  export * from "./config.js";
9
9
  export * from "./worktree.js";
10
+ export * as taskContract from "./contract/index.js";
11
+ export * as taskOperator from "./operator/index.js";
12
+ export * from "./contract/index.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Compatibility re-export — canonical capabilities document lives in
3
+ * src/shared/operator (includes Console negotiation `dag` block).
4
+ * No independent projection / dual implementation.
5
+ */
6
+ export { buildOperatorCapabilitiesDocument, } from "../../shared/operator/capabilities.js";
@@ -0,0 +1,2 @@
1
+ /** Compatibility re-export — canonical envelope lives in src/shared/operator. */
2
+ export { buildOperatorResult, operatorSucceeded, operatorFailed, writeOperatorJson, processExitCodeForOutcome, } from "../../shared/operator/envelope.js";
@@ -0,0 +1,5 @@
1
+ /** Compatibility barrel — re-exports src/shared/operator for task-adjacent imports. */
2
+ export * from "./types.js";
3
+ export * from "./envelope.js";
4
+ export * from "./registry.js";
5
+ export * from "./capabilities.js";
@@ -0,0 +1,2 @@
1
+ /** Compatibility re-export — canonical registry lives in src/shared/operator. */
2
+ export { OPERATOR_OUTCOMES_V1, OPERATOR_ERROR_CODES_V1, OPERATOR_ERROR_CODE_SET, isOperatorErrorCode, } from "../../shared/operator/registry.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -64,6 +64,10 @@ export function getTaskPaths(repoRoot, taskId) {
64
64
  logsDir: path.join(taskDir, 'logs'),
65
65
  sourceDir: path.join(taskDir, 'source'),
66
66
  statePath: path.join(taskDir, '.workflow_state.json'),
67
+ /** Managed TaskContractRefV1 metadata (Phase 0.5). */
68
+ contractPath: path.join(taskDir, 'contract.json'),
69
+ /** Journaled contract transactions directory. */
70
+ transactionsDir: path.join(taskDir, '.transactions'),
67
71
  };
68
72
  }
69
73
  export async function createTask(repoRoot, taskId, title) {
@@ -180,7 +184,7 @@ export function parseTaskConfigForRuntime(rawConfig) {
180
184
  const parsed = taskConfigSchema.parse(rawConfig);
181
185
  if (typeof rawConfig === 'object' &&
182
186
  rawConfig !== null &&
183
- !Object.prototype.hasOwnProperty.call(rawConfig, 'maxFixLoops')) {
187
+ !Object.hasOwn(rawConfig, 'maxFixLoops')) {
184
188
  return {
185
189
  ...parsed,
186
190
  maxFixLoops: deriveMaxFixLoops(parsed.complexity),
@@ -174,6 +174,13 @@ export async function importPrdDocument(input) {
174
174
  referenceDocs,
175
175
  });
176
176
  }
177
+ // Managed contracts must bump revision after import-prd mutates manifest/task config.
178
+ // Lazy import avoids circular init with contract/observe.
179
+ const { refreshManagedContractAfterImport } = await import("./contract/import-revision.js");
180
+ await refreshManagedContractAfterImport({
181
+ repoRoot: input.repoRoot,
182
+ taskId: input.taskId,
183
+ });
177
184
  return {
178
185
  taskId: input.taskId,
179
186
  sourcePath: sourcePathForRecord,
@@ -14,10 +14,10 @@ import { createCompositeProgressReporter } from "./observability/progress-compos
14
14
  import { createRoutedWorkerEventStore } from "./observability/event-store.js";
15
15
  import { buildGlobalSnapshot } from "./observability/read-model.js";
16
16
  import { createObserveServer } from "./observe/server.js";
17
- import { diagnoseTaskPoolStates, formatDoctorHuman, loadOperatorMapping } from "./pool/doctor.js";
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";
20
- import { getTaskPoolRoot, prepareTaskPoolRetry, readFeatureTaskPoolStates } from "./pool/run-store.js";
19
+ import { markTaskPoolFailed, reconcileTaskPoolAbandon, } from "./pool/reconcile.js";
20
+ import { getTaskPoolRoot, prepareTaskPoolRetry, readFeatureTaskPoolStates, } from "./pool/run-store.js";
21
21
  import { TaskPoolStateError } from "./pool/types.js";
22
22
  import { taskSpecSchema } from "./task-spec/schema.js";
23
23
  import { validateTaskSpec } from "./task-spec/validate.js";
@@ -29,7 +29,7 @@ import { renderFeatureRun, runFeature } from "./feature/run.js";
29
29
  import { draftFollowUpDecision } from "./follow-up/factory.js";
30
30
  import { approveFollowUp } from "./follow-up/approve.js";
31
31
  import { prepareFeatureDelivery } from "./delivery/package.js";
32
- import { previewFeatureCloseout, renderCloseoutPreview } from "./closeout/preview.js";
32
+ import { previewFeatureCloseout, renderCloseoutPreview, } from "./closeout/preview.js";
33
33
  import { applyFeatureCloseout } from "./closeout/apply.js";
34
34
  import { projectMonthlyMetrics } from "./metrics/projector.js";
35
35
  import { runFeatureFinalVerification } from "./delivery/final-verification.js";
@@ -43,11 +43,24 @@ export function buildAgentWorkerProgram() {
43
43
  .showHelpAfterError()
44
44
  .showSuggestionAfterError();
45
45
  const task = program.command("task").description("TaskSpec utilities");
46
- const batch = program.command("batch").description("Task Pool batch utilities");
47
- const report = program.command("report").description("Task Pool reporting utilities");
48
- const observe = program.command("observe").description("Observe UI server and snapshot utilities");
49
- const feature = program.command("feature").description("Feature-level review and delivery workflow");
50
- const pool = program.command("pool").description("Task Pool state doctor and migration utilities");
46
+ const batch = program
47
+ .command("batch")
48
+ .description("Task Pool batch utilities");
49
+ const report = program
50
+ .command("report")
51
+ .description("Task Pool reporting utilities");
52
+ const observe = program
53
+ .command("observe")
54
+ .description("Observe UI server and snapshot utilities");
55
+ const consoleCmd = program
56
+ .command("console")
57
+ .description("Loop Operator Console (local)");
58
+ const feature = program
59
+ .command("feature")
60
+ .description("Feature-level review and delivery workflow");
61
+ const pool = program
62
+ .command("pool")
63
+ .description("Task Pool state doctor and migration utilities");
51
64
  pool
52
65
  .command("mark-failed")
53
66
  .requiredOption("--repo <repo-root>", "Target repo root")
@@ -81,7 +94,9 @@ export function buildAgentWorkerProgram() {
81
94
  const repoRoot = path.resolve(options.repo);
82
95
  const mapping = await loadOperatorMapping(options.mapping);
83
96
  const report = await diagnoseTaskPoolStates(repoRoot, { mapping });
84
- process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : formatDoctorHuman(report));
97
+ process.stdout.write(options.json
98
+ ? `${JSON.stringify(report, null, 2)}\n`
99
+ : formatDoctorHuman(report));
85
100
  });
86
101
  pool
87
102
  .command("migrate-state")
@@ -108,7 +123,9 @@ export function buildAgentWorkerProgram() {
108
123
  }
109
124
  catch (error) {
110
125
  const detail = error instanceof Error ? error.message : String(error);
111
- const code = error instanceof TaskPoolStateError ? error.code : "task-pool-migrate-failed";
126
+ const code = error instanceof TaskPoolStateError
127
+ ? error.code
128
+ : "task-pool-migrate-failed";
112
129
  if (options.json) {
113
130
  process.stdout.write(`${JSON.stringify({
114
131
  schemaVersion: 1,
@@ -155,7 +172,9 @@ export function buildAgentWorkerProgram() {
155
172
  featureDir: path.resolve(options.featureDir),
156
173
  repoRoot: path.resolve(options.repo),
157
174
  });
158
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderFeatureReview(result));
175
+ process.stdout.write(options.json
176
+ ? `${JSON.stringify(result, null, 2)}\n`
177
+ : renderFeatureReview(result));
159
178
  });
160
179
  feature
161
180
  .command("run")
@@ -197,7 +216,9 @@ export function buildAgentWorkerProgram() {
197
216
  });
198
217
  }
199
218
  catch {
200
- progress = createProgressReporter({ quiet: options.quiet ?? options.dryRun ?? false });
219
+ progress = createProgressReporter({
220
+ quiet: options.quiet ?? options.dryRun ?? false,
221
+ });
201
222
  }
202
223
  const result = await runFeature({
203
224
  featureDir: path.resolve(options.featureDir),
@@ -207,7 +228,9 @@ export function buildAgentWorkerProgram() {
207
228
  limit: Number.parseInt(options.limit, 10),
208
229
  batchRunId,
209
230
  runCheckRepo: options.checkRepo ?? false,
210
- ...(options.checkRepoCommand ? { checkRepoCommand: options.checkRepoCommand } : {}),
231
+ ...(options.checkRepoCommand
232
+ ? { checkRepoCommand: options.checkRepoCommand }
233
+ : {}),
211
234
  progress,
212
235
  ...(options.piModel ? { piModel: options.piModel } : {}),
213
236
  gitMode: options.gitMode === "checkpoint" ? "checkpoint" : "none",
@@ -215,7 +238,9 @@ export function buildAgentWorkerProgram() {
215
238
  controllerIdentity: client.getIdentity(),
216
239
  controllerExpectation: expectedIdentity,
217
240
  });
218
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderFeatureRun(result));
241
+ process.stdout.write(options.json
242
+ ? `${JSON.stringify(result, null, 2)}\n`
243
+ : renderFeatureRun(result));
219
244
  if (result.status === "failed" || result.status === "needs-action")
220
245
  process.exitCode = 1;
221
246
  });
@@ -231,12 +256,28 @@ export function buildAgentWorkerProgram() {
231
256
  .option("--expected-controller-fingerprint <value>", "Exact expected controller fingerprint sha256:<hex> (fail before writes if mismatched)")
232
257
  .description("Run an independent HEAD-bound final QA verification and project Delivery evidence")
233
258
  .action(async (options) => {
234
- if (options.evidenceMode !== "auto" && options.evidenceMode !== "qa-execute" && options.evidenceMode !== "typed")
259
+ if (options.evidenceMode !== "auto" &&
260
+ options.evidenceMode !== "qa-execute" &&
261
+ options.evidenceMode !== "typed")
235
262
  throw new Error("feature verify-final --evidence-mode must be auto, qa-execute, or typed");
236
263
  const repoRoot = path.resolve(options.repo);
237
- const client = new LoopAgentClient({ loopAgentBin: options.loopAgentBin, artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", `final-verification-${Date.now()}`), resolveIdentity: true });
238
- const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client, controllerIdentity: client.getIdentity(), controllerExpectation: buildIdentityExpectation(options), evidenceMode: options.evidenceMode });
239
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nEvidence mode: ${result.evidenceMode ?? "qa-execute"}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}${result.bundleEvidencePath ? `\nVerification bundle: ${result.bundleEvidencePath}` : ""}\n`);
264
+ const client = new LoopAgentClient({
265
+ loopAgentBin: options.loopAgentBin,
266
+ artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", `final-verification-${Date.now()}`),
267
+ resolveIdentity: true,
268
+ });
269
+ const result = await runFeatureFinalVerification({
270
+ featureDir: path.resolve(options.featureDir),
271
+ repoRoot,
272
+ taskId: options.taskId,
273
+ client,
274
+ controllerIdentity: client.getIdentity(),
275
+ controllerExpectation: buildIdentityExpectation(options),
276
+ evidenceMode: options.evidenceMode,
277
+ });
278
+ process.stdout.write(options.json
279
+ ? `${JSON.stringify(result, null, 2)}\n`
280
+ : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nEvidence mode: ${result.evidenceMode ?? "qa-execute"}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}${result.bundleEvidencePath ? `\nVerification bundle: ${result.bundleEvidencePath}` : ""}\n`);
240
281
  });
241
282
  feature
242
283
  .command("delivery")
@@ -250,11 +291,16 @@ export function buildAgentWorkerProgram() {
250
291
  .description("Validate checkpointed Feature evidence and prepare a local Delivery Package")
251
292
  .action(async (options) => {
252
293
  const result = await prepareFeatureDelivery({
253
- featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo),
254
- qaEvidencePath: options.qaEvidence, finalVerificationPath: options.finalVerification,
255
- ...(options.waivers ? { waiverPath: options.waivers } : {}), dryRun: options.dryRun ?? false,
294
+ featureDir: path.resolve(options.featureDir),
295
+ repoRoot: path.resolve(options.repo),
296
+ qaEvidencePath: options.qaEvidence,
297
+ finalVerificationPath: options.finalVerification,
298
+ ...(options.waivers ? { waiverPath: options.waivers } : {}),
299
+ dryRun: options.dryRun ?? false,
256
300
  });
257
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nDelivery: ${result.status}\nArtifacts: ${Object.values(result.artifacts).join(", ")}\nBlockers: ${result.blockers.join("; ") || "none"}\n`);
301
+ process.stdout.write(options.json
302
+ ? `${JSON.stringify(result, null, 2)}\n`
303
+ : `Feature: ${result.featureId}\nDelivery: ${result.status}\nArtifacts: ${Object.values(result.artifacts).join(", ")}\nBlockers: ${result.blockers.join("; ") || "none"}\n`);
258
304
  if (result.status === "blocked")
259
305
  process.exitCode = 1;
260
306
  });
@@ -268,12 +314,21 @@ export function buildAgentWorkerProgram() {
268
314
  .description("Preview Feature Closeout gates without modifying the Feature Packet")
269
315
  .action(async (options) => {
270
316
  if (options.apply) {
271
- const result = await applyFeatureCloseout({ featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo), owner: options.owner ?? "" });
317
+ const result = await applyFeatureCloseout({
318
+ featureDir: path.resolve(options.featureDir),
319
+ repoRoot: path.resolve(options.repo),
320
+ owner: options.owner ?? "",
321
+ });
272
322
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
273
323
  return;
274
324
  }
275
- const result = await previewFeatureCloseout({ featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo) });
276
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderCloseoutPreview(result));
325
+ const result = await previewFeatureCloseout({
326
+ featureDir: path.resolve(options.featureDir),
327
+ repoRoot: path.resolve(options.repo),
328
+ });
329
+ process.stdout.write(options.json
330
+ ? `${JSON.stringify(result, null, 2)}\n`
331
+ : renderCloseoutPreview(result));
277
332
  if (result.status === "blocked")
278
333
  process.exitCode = 1;
279
334
  });
@@ -482,7 +537,9 @@ export function buildAgentWorkerProgram() {
482
537
  const result = await runReadyTasks({
483
538
  repoRoot,
484
539
  featureDir: path.resolve(options.featureDir),
485
- ...(options.limit ? { limit: Number.parseInt(options.limit, 10) } : {}),
540
+ ...(options.limit
541
+ ? { limit: Number.parseInt(options.limit, 10) }
542
+ : {}),
486
543
  batchRunId,
487
544
  client,
488
545
  runCheckRepo: options.checkRepo ?? false,
@@ -527,9 +584,62 @@ export function buildAgentWorkerProgram() {
527
584
  .option("--json", "Emit JSON (default)")
528
585
  .description("Print GlobalSnapshot JSON to stdout")
529
586
  .action(async (options) => {
530
- const snapshot = await buildGlobalSnapshot({ repoRoot: path.resolve(options.repo) });
587
+ const snapshot = await buildGlobalSnapshot({
588
+ repoRoot: path.resolve(options.repo),
589
+ });
531
590
  process.stdout.write(`${JSON.stringify(snapshot)}\n`);
532
591
  });
592
+ consoleCmd
593
+ .command("serve")
594
+ .requiredOption("--repo <repo-root>", "Target repo root")
595
+ .option("--port <port>", "HTTP port", "8790")
596
+ .option("--host <host>", "Bind host (default 127.0.0.1; non-loopback is rejected)", "127.0.0.1")
597
+ .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
598
+ .description("Start Loop Operator Console HTTP server (static SPA + Operator API)")
599
+ .action(async (options) => {
600
+ const { createConsoleServer } = await import("./console/server.js");
601
+ const { isLoopbackHost } = await import("./console/loopback.js");
602
+ if (!isLoopbackHost(options.host)) {
603
+ process.stderr.write(`console serve: non-loopback host "${options.host}" is not allowed (fail closed)\n`);
604
+ process.exitCode = 1;
605
+ return;
606
+ }
607
+ const repoRoot = path.resolve(options.repo);
608
+ const server = await createConsoleServer({
609
+ repoRoot,
610
+ host: options.host,
611
+ port: Number.parseInt(options.port, 10),
612
+ appDataRoot: options.appData
613
+ ? path.resolve(options.appData)
614
+ : undefined,
615
+ });
616
+ process.stdout.write(`${server.url}\n`);
617
+ await new Promise((resolve) => {
618
+ const shutdown = () => resolve();
619
+ process.once("SIGINT", shutdown);
620
+ process.once("SIGTERM", shutdown);
621
+ });
622
+ await server.close();
623
+ });
624
+ consoleCmd
625
+ .command("doctor")
626
+ .requiredOption("--repo <repo-root>", "Target repo root")
627
+ .option("--json", "Emit JSON report on stdout")
628
+ .option("--observe-url <url>", "Optional Observe base URL to probe (non-blocking)", "http://127.0.0.1:8787")
629
+ .description("Report sibling loop-agent identity, repo preflight, and optional Observe health")
630
+ .action(async (options) => {
631
+ const { formatConsoleDoctorHuman, runConsoleDoctor } = await import("./console/doctor.js");
632
+ const report = await runConsoleDoctor({
633
+ repoRoot: path.resolve(options.repo),
634
+ observeBaseUrl: options.observeUrl,
635
+ });
636
+ if (options.json) {
637
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
638
+ }
639
+ else {
640
+ process.stdout.write(formatConsoleDoctorHuman(report));
641
+ }
642
+ });
533
643
  report
534
644
  .command("morning")
535
645
  .requiredOption("--repo <repo-root>", "Target repo root")
@@ -554,13 +664,19 @@ export function buildAgentWorkerProgram() {
554
664
  .option("--json", "Emit the metrics JSON on stdout")
555
665
  .description("Project monthly Feature delivery metrics to JSON and Markdown")
556
666
  .action(async (options) => {
557
- const result = await projectMonthlyMetrics({ repoRoot: path.resolve(options.repo), month: options.month });
558
- process.stdout.write(options.json ? `${JSON.stringify(result.metrics, null, 2)}\n` : `${JSON.stringify({ ok: true, jsonPath: result.jsonPath, markdownPath: result.markdownPath }, null, 2)}\n`);
667
+ const result = await projectMonthlyMetrics({
668
+ repoRoot: path.resolve(options.repo),
669
+ month: options.month,
670
+ });
671
+ process.stdout.write(options.json
672
+ ? `${JSON.stringify(result.metrics, null, 2)}\n`
673
+ : `${JSON.stringify({ ok: true, jsonPath: result.jsonPath, markdownPath: result.markdownPath }, null, 2)}\n`);
559
674
  });
560
675
  return program;
561
676
  }
562
677
  export function buildIdentityExpectation(options) {
563
- if (options.expectedControllerVersion || options.expectedControllerFingerprint) {
678
+ if (options.expectedControllerVersion ||
679
+ options.expectedControllerFingerprint) {
564
680
  const expectation = {};
565
681
  if (options.expectedControllerVersion)
566
682
  expectation.expectedVersion = options.expectedControllerVersion;
@@ -599,7 +715,9 @@ function handleFollowUpCliError(error, json, action) {
599
715
  status: "failed",
600
716
  action,
601
717
  error: { code, message: detail },
602
- nextAction: action === "draft-followup" ? "Inspect the failed Worker run and failure category." : "Regenerate and review the Follow-up draft before approving.",
718
+ nextAction: action === "draft-followup"
719
+ ? "Inspect the failed Worker run and failure category."
720
+ : "Regenerate and review the Follow-up draft before approving.",
603
721
  }, null, 2)}\n`);
604
722
  process.exitCode = 1;
605
723
  }
@@ -0,0 +1,185 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, writeFileSync, } from "node:fs";
3
+ import { readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { repoFingerprintV1 } from "./repo-fingerprint.js";
7
+ const APP_DATA_PRODUCT = "loop-console";
8
+ export const MAX_STAGED_BYTES = 10 * 1024 * 1024;
9
+ /**
10
+ * Resolve OS application-data root for Console (overrideable for tests).
11
+ * Never under a target repo `.harness` tree.
12
+ */
13
+ export function defaultConsoleAppDataRoot(env = process.env) {
14
+ if (env.LOOP_CONSOLE_APP_DATA && env.LOOP_CONSOLE_APP_DATA.trim()) {
15
+ return path.resolve(env.LOOP_CONSOLE_APP_DATA.trim());
16
+ }
17
+ if (process.platform === "win32") {
18
+ const base = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
19
+ return path.join(base, APP_DATA_PRODUCT);
20
+ }
21
+ if (process.platform === "darwin") {
22
+ return path.join(os.homedir(), "Library", "Application Support", APP_DATA_PRODUCT);
23
+ }
24
+ const xdg = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
25
+ return path.join(xdg, APP_DATA_PRODUCT);
26
+ }
27
+ export function openConsoleAppData(options) {
28
+ const fingerprint = repoFingerprintV1(options.repoRoot);
29
+ const root = path.resolve(options.appDataRoot ?? defaultConsoleAppDataRoot());
30
+ const repoNamespace = path.join(root, "repos", fingerprint);
31
+ assertNotUnderRepoHarness(options.repoRoot, repoNamespace);
32
+ const paths = {
33
+ root,
34
+ repoRoot: path.resolve(options.repoRoot),
35
+ fingerprint,
36
+ drafts: path.join(repoNamespace, "drafts"),
37
+ operations: path.join(repoNamespace, "operations"),
38
+ confirmations: path.join(repoNamespace, "confirmations"),
39
+ staged: path.join(repoNamespace, "staged"),
40
+ assessments: path.join(repoNamespace, "assessments"),
41
+ interviews: path.join(repoNamespace, "interviews"),
42
+ };
43
+ for (const dir of [
44
+ paths.drafts,
45
+ paths.operations,
46
+ paths.confirmations,
47
+ paths.staged,
48
+ paths.assessments,
49
+ paths.interviews,
50
+ ]) {
51
+ ensureSecureDir(dir);
52
+ }
53
+ return paths;
54
+ }
55
+ function assertNotUnderRepoHarness(repoRoot, candidate) {
56
+ const repo = path.resolve(repoRoot);
57
+ const harness = path.join(repo, ".harness");
58
+ const resolvedCandidate = path.resolve(candidate);
59
+ const harnessPrefix = harness.endsWith(path.sep)
60
+ ? harness
61
+ : harness + path.sep;
62
+ if (resolvedCandidate === harness ||
63
+ resolvedCandidate.startsWith(harnessPrefix) ||
64
+ resolvedCandidate.startsWith(path.join(repo, ".harness"))) {
65
+ throw new Error(`console app-data must not live under repo .harness: ${resolvedCandidate}`);
66
+ }
67
+ }
68
+ export function ensureSecureDir(dir) {
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ try {
71
+ chmodSync(dir, 0o700);
72
+ }
73
+ catch {
74
+ // Windows may ignore mode; best-effort.
75
+ }
76
+ }
77
+ export function assertSafeAppDataPath(baseDir, candidate) {
78
+ const resolvedBase = path.resolve(baseDir);
79
+ const resolved = path.resolve(candidate);
80
+ const prefix = resolvedBase.endsWith(path.sep)
81
+ ? resolvedBase
82
+ : resolvedBase + path.sep;
83
+ if (resolved !== resolvedBase && !resolved.startsWith(prefix)) {
84
+ throw new Error(`path escapes app-data namespace: ${candidate}`);
85
+ }
86
+ if (existsSync(resolved)) {
87
+ const st = lstatSync(resolved);
88
+ if (st.isSymbolicLink()) {
89
+ throw new Error(`symlink rejected in app-data: ${candidate}`);
90
+ }
91
+ if (st.nlink > 1 && st.isFile()) {
92
+ throw new Error(`hardlink rejected in app-data: ${candidate}`);
93
+ }
94
+ }
95
+ return resolved;
96
+ }
97
+ export async function writeSecureJson(filePath, value) {
98
+ const dir = path.dirname(filePath);
99
+ ensureSecureDir(dir);
100
+ const resolved = assertSafeAppDataPath(dir, filePath);
101
+ const tmp = `${resolved}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
102
+ const body = `${JSON.stringify(value, null, 2)}\n`;
103
+ await writeFile(tmp, body, { encoding: "utf8", mode: 0o600 });
104
+ try {
105
+ chmodSync(tmp, 0o600);
106
+ }
107
+ catch {
108
+ // best-effort
109
+ }
110
+ await rename(tmp, resolved);
111
+ try {
112
+ chmodSync(resolved, 0o600);
113
+ }
114
+ catch {
115
+ // best-effort
116
+ }
117
+ }
118
+ export async function readJsonIfExists(filePath) {
119
+ if (!existsSync(filePath))
120
+ return undefined;
121
+ const st = lstatSync(filePath);
122
+ if (st.isSymbolicLink() || !st.isFile()) {
123
+ throw new Error(`refusing to read non-regular file: ${filePath}`);
124
+ }
125
+ const raw = await readFile(filePath, "utf8");
126
+ try {
127
+ return JSON.parse(raw);
128
+ }
129
+ catch (error) {
130
+ throw new Error(`invalid JSON in app-data file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ export async function listJsonFiles(dir) {
134
+ if (!existsSync(dir))
135
+ return [];
136
+ const names = await readdir(dir);
137
+ return names
138
+ .filter((n) => n.endsWith(".json") && !n.endsWith(".tmp"))
139
+ .map((n) => path.join(dir, n));
140
+ }
141
+ export function sha256Utf8(text) {
142
+ return createHash("sha256").update(text, "utf8").digest("hex");
143
+ }
144
+ export function sha256Json(value) {
145
+ return sha256Utf8(JSON.stringify(value));
146
+ }
147
+ /**
148
+ * Stage UTF-8 text under app-data staged/ with random name (≤10MiB).
149
+ * Returns absolute path; rejects path traversal / symlink semantics.
150
+ */
151
+ export async function stageUtf8Text(appData, text, options) {
152
+ const bytes = Buffer.byteLength(text, "utf8");
153
+ if (bytes > MAX_STAGED_BYTES) {
154
+ throw new Error(`staged input exceeds ${MAX_STAGED_BYTES} bytes (got ${bytes})`);
155
+ }
156
+ const ext = options?.extension ?? ".txt";
157
+ const name = `${randomBytes(16).toString("hex")}${ext.startsWith(".") ? ext : `.${ext}`}`;
158
+ const target = assertSafeAppDataPath(appData.staged, path.join(appData.staged, name));
159
+ ensureSecureDir(appData.staged);
160
+ await writeFile(target, text, { encoding: "utf8", mode: 0o600 });
161
+ try {
162
+ chmodSync(target, 0o600);
163
+ }
164
+ catch {
165
+ // best-effort
166
+ }
167
+ return { path: target, sha256: sha256Utf8(text), bytes };
168
+ }
169
+ export async function removeStaged(filePath) {
170
+ if (!existsSync(filePath))
171
+ return;
172
+ await rm(filePath, { force: true });
173
+ }
174
+ /** Synchronous helpers used by boot-time path checks. */
175
+ export function writeSecureJsonSync(filePath, value) {
176
+ const dir = path.dirname(filePath);
177
+ ensureSecureDir(dir);
178
+ const resolved = assertSafeAppDataPath(dir, filePath);
179
+ const tmp = `${resolved}.${process.pid}.tmp`;
180
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
181
+ encoding: "utf8",
182
+ mode: 0o600,
183
+ });
184
+ renameSync(tmp, resolved);
185
+ }