@tea-agent/loop-agent 0.16.26 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/README.md +14 -3
- package/dist/cli/command-definitions.js +43 -0
- package/dist/cli/program.js +26 -0
- package/dist/commands/dag-approve.js +4 -0
- package/dist/commands/dag-resume.js +1 -0
- package/dist/commands/dag-validate.js +6 -0
- package/dist/commands/operator.js +44 -0
- package/dist/commands/task-contract.js +271 -0
- package/dist/executors/dag-pi-executor.js +55 -7
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +55 -8
- package/dist/executors/shell-presets.js +16 -3
- package/dist/shared/operator/capabilities.js +255 -0
- package/dist/shared/operator/envelope.js +59 -0
- package/dist/shared/operator/index.js +4 -0
- package/dist/shared/operator/registry.js +38 -0
- package/dist/shared/operator/types.js +5 -0
- package/dist/task/contract/adopt.js +166 -0
- package/dist/task/contract/apply.js +326 -0
- package/dist/task/contract/canonicalize.js +60 -0
- package/dist/task/contract/constants.js +29 -0
- package/dist/task/contract/diff.js +177 -0
- package/dist/task/contract/hash.js +42 -0
- package/dist/task/contract/import-revision.js +96 -0
- package/dist/task/contract/index.js +17 -0
- package/dist/task/contract/journal.js +155 -0
- package/dist/task/contract/lock.js +153 -0
- package/dist/task/contract/observe.js +296 -0
- package/dist/task/contract/paths.js +19 -0
- package/dist/task/contract/project.js +170 -0
- package/dist/task/contract/recover.js +312 -0
- package/dist/task/contract/request-ledger.js +37 -0
- package/dist/task/contract/schema.js +151 -0
- package/dist/task/contract/transaction.js +160 -0
- package/dist/task/contract/types.js +1 -0
- package/dist/task/contract/validate-draft.js +106 -0
- package/dist/task/index.js +3 -0
- package/dist/task/operator/capabilities.js +6 -0
- package/dist/task/operator/envelope.js +2 -0
- package/dist/task/operator/index.js +5 -0
- package/dist/task/operator/registry.js +2 -0
- package/dist/task/operator/types.js +1 -0
- package/dist/task/runtime.js +5 -1
- package/dist/task/source-references.js +7 -0
- package/dist/worker/cli.js +150 -32
- package/dist/worker/console/app-data.js +185 -0
- package/dist/worker/console/dag-confirmation.js +313 -0
- package/dist/worker/console/doctor.js +169 -0
- package/dist/worker/console/draft-store.js +80 -0
- package/dist/worker/console/index.js +15 -0
- package/dist/worker/console/interview/assessment.js +67 -0
- package/dist/worker/console/interview/session.js +100 -0
- package/dist/worker/console/interview/tools.js +109 -0
- package/dist/worker/console/loopback.js +16 -0
- package/dist/worker/console/observe-health-match.js +174 -0
- package/dist/worker/console/observe-link.js +33 -0
- package/dist/worker/console/operation-runner.js +166 -0
- package/dist/worker/console/operation-sse.js +158 -0
- package/dist/worker/console/operation-store.js +147 -0
- package/dist/worker/console/operator-actions.js +769 -0
- package/dist/worker/console/pi-readiness.js +266 -0
- package/dist/worker/console/recovery-cta.js +133 -0
- package/dist/worker/console/repo-fingerprint.js +29 -0
- package/dist/worker/console/resource-loader.js +95 -0
- package/dist/worker/console/routes.js +368 -0
- package/dist/worker/console/security.js +126 -0
- package/dist/worker/console/server.js +149 -0
- package/dist/worker/console/sibling-controller.js +28 -0
- package/dist/worker/console/static/assets/index-BEIdBogJ.js +9 -0
- package/dist/worker/console/static/assets/index-Rt0TqimP.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/materialize/harness-task-materializer.js +34 -0
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/routes.js +16 -9
- package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
- package/dist/workflows/dag/frontend-repair.js +1 -10
- package/dist/workflows/dag/init-hybrid.js +269 -116
- package/dist/workflows/dag/node-execution.js +25 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +74 -10
- package/dist/workflows/dag/validate.js +25 -7
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +2 -0
- package/docs/architecture/system-overview.md +6 -0
- package/docs/architecture/worker-and-feature.md +7 -0
- package/docs/templates/agent-dag.schema.json +49 -2
- package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
- package/docs/templates/backend-test-dag.json +26 -154
- package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
- package/package.json +8 -2
- package/skills/agent-worker/SKILL.md +1 -0
- package/skills/agent-worker/references/agent-worker-operator.md +3 -2
- package/skills/frontend-design-review/SKILL.md +25 -16
- package/skills/frontend-implementation/references/node-contracts.md +5 -5
- package/skills/loop-agent/references/command-reference.md +48 -1
- 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
|
+
}
|
package/dist/task/index.js
CHANGED
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
package/dist/task/runtime.js
CHANGED
|
@@ -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.
|
|
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,
|
package/dist/worker/cli.js
CHANGED
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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" &&
|
|
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({
|
|
238
|
-
|
|
239
|
-
|
|
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),
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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({
|
|
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({
|
|
276
|
-
|
|
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
|
|
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({
|
|
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({
|
|
558
|
-
|
|
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 ||
|
|
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"
|
|
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
|
+
}
|