@tea-agent/loop-agent 0.17.1 → 0.18.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.
- package/CHANGELOG.md +96 -22
- package/dist/application/dag/args.js +9 -2
- package/dist/executors/dag-pi-executor.js +11 -0
- package/dist/executors/shell-executor.js +62 -2
- package/dist/governance/spine-audit.js +5 -2
- package/dist/worker/console/draft-store.js +81 -3
- package/dist/worker/console/interview/grill-me.js +253 -0
- package/dist/worker/console/operation-runner.js +2 -1
- package/dist/worker/console/operator-actions.js +609 -20
- package/dist/worker/console/pi-readiness.js +2 -0
- package/dist/worker/console/prd-identity.js +102 -0
- package/dist/worker/console/resolve-dag-run-for-task.js +76 -0
- package/dist/worker/console/server.js +11 -2
- package/dist/worker/console/static/assets/index-KUSib7aM.js +16 -0
- package/dist/worker/console/static/assets/index-ucIzpaGJ.css +1 -0
- package/dist/worker/console/static/index.html +3 -3
- package/dist/worker/console/workflow-kinds.js +46 -0
- package/dist/worker/observe/routes.js +8 -1
- package/dist/worker/observe/static/index.html +8 -8
- package/dist/worker/observe/static/styles.css +296 -181
- package/dist/workflows/dag/backend-test-intake-context.js +148 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +528 -0
- package/dist/workflows/dag/init-hybrid.js +93 -109
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/loop/benchmark.js +1 -1
- package/docs/README.md +84 -39
- package/docs/architecture/README.md +8 -4
- package/docs/architecture/dag-execution.md +8 -1
- package/docs/architecture/evolution.md +38 -25
- package/docs/architecture/facts-and-state.md +2 -0
- package/docs/architecture/system-overview.md +6 -5
- package/docs/architecture/worker-and-feature.md +15 -8
- package/docs/init-surface.manifest.json +10 -1
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +1 -1
- package/docs/templates/backend-test-dag.json +74 -219
- package/docs/templates/branch-merge-report.md +1 -1
- package/docs/templates/exec-plan.md +2 -0
- package/docs/templates/progress-log.md +3 -0
- package/package.json +1 -1
- package/skills/init-capability-evolution/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/worker/console/static/assets/index-BEIdBogJ.js +0 -9
- package/dist/worker/console/static/assets/index-Rt0TqimP.css +0 -1
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
2
|
import { buildOperatorCapabilitiesDocument } from "../../shared/operator/capabilities.js";
|
|
3
3
|
import { operatorFailed, operatorSucceeded, } from "../../shared/operator/envelope.js";
|
|
4
|
+
import { getTaskPaths } from "../../task/runtime.js";
|
|
4
5
|
import { stageUtf8Text } from "./app-data.js";
|
|
5
6
|
import { ALL_CONFIRMATION_CHALLENGES, hashDagBytes, } from "./dag-confirmation.js";
|
|
6
7
|
import { capabilityForObserveTarget, DEFAULT_OBSERVE_BASE_URL, OBSERVE_START_COMMAND, probeAndClassifyObserve, } from "./observe-health-match.js";
|
|
7
8
|
import { buildObserveDeepLink, } from "./observe-link.js";
|
|
9
|
+
import { resolveLatestDagRunIdForTask } from "./resolve-dag-run-for-task.js";
|
|
8
10
|
import { repoFingerprintV1 } from "./repo-fingerprint.js";
|
|
9
11
|
import { scheduleOperation, } from "./operation-runner.js";
|
|
10
12
|
import { readinessGateFailure, } from "./pi-readiness.js";
|
|
11
13
|
import { isAssessmentFreshForDraft } from "./interview/assessment.js";
|
|
14
|
+
import { applyGrillMeAnswer, draftSummary, emptyDraft, isDraftStructurallyComplete, nextGrillMeQuestion, } from "./interview/grill-me.js";
|
|
15
|
+
import { toCanonicalDraftForCli, } from "./draft-store.js";
|
|
16
|
+
import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
|
|
17
|
+
import { deriveTaskIdentityFromPrd, nextTaskIdRevision, } from "./prd-identity.js";
|
|
12
18
|
const MUTATION_OR_LONG = new Set([
|
|
13
19
|
"newTask",
|
|
14
20
|
"importPrd",
|
|
@@ -122,7 +128,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
122
128
|
command: "operator",
|
|
123
129
|
outcome: "invalid",
|
|
124
130
|
code: "INVALID_INPUT",
|
|
125
|
-
message: "action
|
|
131
|
+
message: "缺少 action",
|
|
126
132
|
}),
|
|
127
133
|
};
|
|
128
134
|
}
|
|
@@ -225,6 +231,59 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
225
231
|
}),
|
|
226
232
|
};
|
|
227
233
|
}
|
|
234
|
+
case "interviewStart": {
|
|
235
|
+
return dispatchInterviewStart(ctx, p);
|
|
236
|
+
}
|
|
237
|
+
case "interviewTurn": {
|
|
238
|
+
return dispatchInterviewTurn(ctx, p);
|
|
239
|
+
}
|
|
240
|
+
case "interviewSkip": {
|
|
241
|
+
return dispatchInterviewSkip(ctx, p);
|
|
242
|
+
}
|
|
243
|
+
case "interviewGet": {
|
|
244
|
+
const sessionId = str(p.sessionId);
|
|
245
|
+
if (!sessionId)
|
|
246
|
+
return invalid(action, "sessionId is required");
|
|
247
|
+
const session = await ctx.interviews.get(sessionId);
|
|
248
|
+
if (!session) {
|
|
249
|
+
return {
|
|
250
|
+
kind: "error",
|
|
251
|
+
status: 404,
|
|
252
|
+
body: operatorFailed({
|
|
253
|
+
command: action,
|
|
254
|
+
outcome: "not-found",
|
|
255
|
+
code: "NOT_FOUND",
|
|
256
|
+
message: `interview session not found: ${sessionId}`,
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const draftRec = await ctx.drafts.get(session.taskId);
|
|
261
|
+
return {
|
|
262
|
+
kind: "sync",
|
|
263
|
+
status: 200,
|
|
264
|
+
body: operatorSucceeded("interviewGet", {
|
|
265
|
+
session,
|
|
266
|
+
draft: draftRec?.draft ?? null,
|
|
267
|
+
draftSha256: draftRec?.draftSha256 ?? null,
|
|
268
|
+
summary: draftRec ? draftSummary(draftRec.draft) : null,
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
case "refreshReadiness": {
|
|
273
|
+
if (ctx.refreshReadiness) {
|
|
274
|
+
const report = await ctx.refreshReadiness();
|
|
275
|
+
return {
|
|
276
|
+
kind: "sync",
|
|
277
|
+
status: 200,
|
|
278
|
+
body: operatorSucceeded("refreshReadiness", report),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
kind: "sync",
|
|
283
|
+
status: 200,
|
|
284
|
+
body: operatorSucceeded("refreshReadiness", ctx.getReadiness?.() ?? ctx.readiness),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
228
287
|
case "observeLink": {
|
|
229
288
|
const baseUrl = ctx.observeBaseUrl ?? DEFAULT_OBSERVE_BASE_URL;
|
|
230
289
|
const dagRunId = str(p.dagRunId);
|
|
@@ -232,11 +291,22 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
232
291
|
const featureId = str(p.featureId);
|
|
233
292
|
const workerRunId = str(p.workerRunId);
|
|
234
293
|
let target;
|
|
294
|
+
let resolvedFromTask;
|
|
295
|
+
let fallbackToTask = false;
|
|
296
|
+
let openHome = false;
|
|
235
297
|
if (dagRunId) {
|
|
236
298
|
target = { kind: "dag", dagRunId };
|
|
237
299
|
}
|
|
238
300
|
else if (taskId) {
|
|
239
|
-
|
|
301
|
+
// Prefer `#/dag/<runId>` when a run exists; otherwise open `#/task/<taskId>`.
|
|
302
|
+
resolvedFromTask = await resolveLatestDagRunIdForTask(ctx.repoRoot, taskId);
|
|
303
|
+
if (resolvedFromTask) {
|
|
304
|
+
target = { kind: "dag", dagRunId: resolvedFromTask.dagRunId };
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
target = { kind: "task", taskId };
|
|
308
|
+
fallbackToTask = true;
|
|
309
|
+
}
|
|
240
310
|
}
|
|
241
311
|
else if (featureId) {
|
|
242
312
|
target = { kind: "feature", featureId };
|
|
@@ -245,9 +315,11 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
245
315
|
target = { kind: "worker", workerRunId };
|
|
246
316
|
}
|
|
247
317
|
else {
|
|
248
|
-
|
|
318
|
+
openHome = true;
|
|
249
319
|
}
|
|
250
|
-
const requiredCapability =
|
|
320
|
+
const requiredCapability = target
|
|
321
|
+
? capabilityForObserveTarget(target)
|
|
322
|
+
: undefined;
|
|
251
323
|
let localFingerprint;
|
|
252
324
|
try {
|
|
253
325
|
localFingerprint = repoFingerprintV1(ctx.repoRoot);
|
|
@@ -294,14 +366,31 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
294
366
|
}),
|
|
295
367
|
};
|
|
296
368
|
}
|
|
297
|
-
const link =
|
|
369
|
+
const link = openHome
|
|
370
|
+
? `${baseUrl.replace(/\/+$/, "")}/`
|
|
371
|
+
: buildObserveDeepLink(baseUrl, target);
|
|
298
372
|
return {
|
|
299
373
|
kind: "sync",
|
|
300
374
|
status: 200,
|
|
301
375
|
body: operatorSucceeded("observeLink", {
|
|
302
376
|
url: link,
|
|
303
377
|
status: "match",
|
|
304
|
-
requiredCapability,
|
|
378
|
+
requiredCapability: requiredCapability ?? null,
|
|
379
|
+
dagRunId: target?.kind === "dag"
|
|
380
|
+
? target.dagRunId
|
|
381
|
+
: resolvedFromTask?.dagRunId,
|
|
382
|
+
taskId: taskId ?? undefined,
|
|
383
|
+
home: openHome || undefined,
|
|
384
|
+
fallbackToTask: fallbackToTask || undefined,
|
|
385
|
+
note: fallbackToTask
|
|
386
|
+
? `任务 ${taskId} 尚无 DAG run,已打开任务页;运行 DAG 后可直达 run 详情`
|
|
387
|
+
: undefined,
|
|
388
|
+
resolvedFromTask: resolvedFromTask
|
|
389
|
+
? {
|
|
390
|
+
dagRunId: resolvedFromTask.dagRunId,
|
|
391
|
+
lifecycle: resolvedFromTask.lifecycle,
|
|
392
|
+
}
|
|
393
|
+
: undefined,
|
|
305
394
|
}),
|
|
306
395
|
};
|
|
307
396
|
}
|
|
@@ -317,16 +406,70 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
317
406
|
return { kind: "sync", status: body.ok ? 200 : 400, body };
|
|
318
407
|
}
|
|
319
408
|
case "prepareDagConfirmation": {
|
|
320
|
-
const dagText = str(p.dagText) ?? str(p.dagJson);
|
|
321
409
|
const dagHandle = str(p.dagHandle) ?? "staged-dag.json";
|
|
322
410
|
const taskId = str(p.taskId);
|
|
323
|
-
if (!dagText)
|
|
324
|
-
return invalid(action, "dagText is required");
|
|
325
411
|
if (!taskId)
|
|
326
412
|
return invalid(action, "taskId is required");
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
413
|
+
let dagText = str(p.dagText) ?? str(p.dagJson);
|
|
414
|
+
let staged;
|
|
415
|
+
if (!dagText) {
|
|
416
|
+
// Happy Path: generate a real DAG via CLI; never confirm an empty stub.
|
|
417
|
+
staged = await stageUtf8Text(ctx.appData, "", { extension: ".json" });
|
|
418
|
+
const profile = str(p.profile) ?? "auto";
|
|
419
|
+
const run = ctx.runCommand ?? ((a, o) => ctx.client.run(a, o));
|
|
420
|
+
const gen = await run([
|
|
421
|
+
"dag",
|
|
422
|
+
"run-task",
|
|
423
|
+
taskId,
|
|
424
|
+
"--profile",
|
|
425
|
+
profile,
|
|
426
|
+
"-o",
|
|
427
|
+
staged.path,
|
|
428
|
+
], {
|
|
429
|
+
cwd: ctx.repoRoot,
|
|
430
|
+
artifactName: "prepare-dag-run-task",
|
|
431
|
+
expectJson: true,
|
|
432
|
+
timeoutMs: 180_000,
|
|
433
|
+
});
|
|
434
|
+
if (!gen.ok || gen.exitCode !== 0) {
|
|
435
|
+
return {
|
|
436
|
+
kind: "error",
|
|
437
|
+
status: 400,
|
|
438
|
+
body: operatorFailed({
|
|
439
|
+
command: action,
|
|
440
|
+
outcome: "invalid",
|
|
441
|
+
code: "INVALID_INPUT",
|
|
442
|
+
message: gen.stderr?.trim() ||
|
|
443
|
+
`dag run-task failed with exit ${gen.exitCode}`,
|
|
444
|
+
details: {
|
|
445
|
+
exitCode: gen.exitCode,
|
|
446
|
+
stdoutPreview: (gen.stdout ?? "").slice(0, 1200),
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
dagText = await readFile(staged.path, "utf8");
|
|
452
|
+
if (!dagText.trim()) {
|
|
453
|
+
return {
|
|
454
|
+
kind: "error",
|
|
455
|
+
status: 400,
|
|
456
|
+
body: operatorFailed({
|
|
457
|
+
command: action,
|
|
458
|
+
outcome: "invalid",
|
|
459
|
+
code: "INVALID_INPUT",
|
|
460
|
+
message: "dag run-task produced empty DAG output",
|
|
461
|
+
}),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
staged = await stageUtf8Text(ctx.appData, dagText, {
|
|
465
|
+
extension: ".json",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
staged = await stageUtf8Text(ctx.appData, dagText, {
|
|
470
|
+
extension: ".json",
|
|
471
|
+
});
|
|
472
|
+
}
|
|
330
473
|
const controller = ctx.client.getIdentity?.()?.packageFingerprint?.value ??
|
|
331
474
|
"unknown-controller";
|
|
332
475
|
// Server-derived bindings only: never trust browser-supplied hashes as success defaults.
|
|
@@ -343,9 +486,11 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
343
486
|
command: action,
|
|
344
487
|
outcome: "invalid",
|
|
345
488
|
code: "INVALID_INPUT",
|
|
346
|
-
message:
|
|
489
|
+
message: validateBody.error?.message ??
|
|
490
|
+
"dag validate failed; cannot prepare confirmation for an invalid DAG",
|
|
347
491
|
details: {
|
|
348
492
|
validateError: validateBody.error ?? null,
|
|
493
|
+
validateResult: validateBody.result ?? null,
|
|
349
494
|
dagSha256: staged.sha256,
|
|
350
495
|
},
|
|
351
496
|
}),
|
|
@@ -408,6 +553,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
408
553
|
body: operatorSucceeded("prepareDagConfirmation", {
|
|
409
554
|
confirmation,
|
|
410
555
|
requiredChallenges: ALL_CONFIRMATION_CHALLENGES,
|
|
556
|
+
dagSha256: staged.sha256,
|
|
411
557
|
}),
|
|
412
558
|
};
|
|
413
559
|
}
|
|
@@ -494,6 +640,9 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
494
640
|
cliArgs: ["new-task", taskId, title, "--json"],
|
|
495
641
|
});
|
|
496
642
|
}
|
|
643
|
+
case "bootstrapFromPrd": {
|
|
644
|
+
return dispatchBootstrapFromPrd(ctx, p);
|
|
645
|
+
}
|
|
497
646
|
case "importPrd": {
|
|
498
647
|
const taskId = str(p.taskId);
|
|
499
648
|
const content = str(p.content) ?? str(p.fileText);
|
|
@@ -528,7 +677,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
528
677
|
command: action,
|
|
529
678
|
outcome: "invalid",
|
|
530
679
|
code: "INVALID_INPUT",
|
|
531
|
-
message: "
|
|
680
|
+
message: "该任务尚无 Console 草稿,请先保存草稿",
|
|
532
681
|
}),
|
|
533
682
|
};
|
|
534
683
|
}
|
|
@@ -555,7 +704,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
555
704
|
command: action,
|
|
556
705
|
outcome: "blocked",
|
|
557
706
|
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
558
|
-
message: "
|
|
707
|
+
message: "未找到评估结果",
|
|
559
708
|
}),
|
|
560
709
|
};
|
|
561
710
|
}
|
|
@@ -572,9 +721,15 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
572
721
|
}),
|
|
573
722
|
};
|
|
574
723
|
}
|
|
575
|
-
const staged = await stageUtf8Text(ctx.appData, JSON.stringify(draftRec.draft, null, 2), { extension: ".json" });
|
|
576
|
-
|
|
577
|
-
const
|
|
724
|
+
const staged = await stageUtf8Text(ctx.appData, JSON.stringify(toCanonicalDraftForCli(draftRec.draft), null, 2), { extension: ".json" });
|
|
725
|
+
// Optimistic concurrency: observe live revision/hash; never default to "unknown".
|
|
726
|
+
const showBody = await runReadCli(ctx, ["task", "contract", "show", taskId, "--json"], "task contract show");
|
|
727
|
+
const showState = (showBody.result ?? {}).state;
|
|
728
|
+
const emptyObservedHash = "0".repeat(64);
|
|
729
|
+
const expectedRevision = String(p.expectedRevision ?? showState?.ref?.revision ?? 0);
|
|
730
|
+
const expectedHash = str(p.expectedObservedHash) ??
|
|
731
|
+
showState?.observedCanonicalHash ??
|
|
732
|
+
emptyObservedHash;
|
|
578
733
|
// CLI validates --request-payload-sha256 against input file bytes, not draft projection hash.
|
|
579
734
|
const payloadSha = staged.sha256;
|
|
580
735
|
return acceptOperation(ctx, {
|
|
@@ -584,6 +739,9 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
584
739
|
assessmentId,
|
|
585
740
|
draftSha256: draftRec.draftSha256,
|
|
586
741
|
requestPayloadSha256: payloadSha,
|
|
742
|
+
expectedRevision: Number(expectedRevision),
|
|
743
|
+
expectedObservedHash: expectedHash,
|
|
744
|
+
effectiveStatus: showState?.effectiveStatus,
|
|
587
745
|
},
|
|
588
746
|
clientRequestId,
|
|
589
747
|
taskId,
|
|
@@ -721,6 +879,437 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
721
879
|
};
|
|
722
880
|
}
|
|
723
881
|
}
|
|
882
|
+
async function taskExistsOnDisk(repoRoot, taskId) {
|
|
883
|
+
try {
|
|
884
|
+
await access(getTaskPaths(repoRoot, taskId).taskConfigPath);
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async function allocateUniqueTaskId(repoRoot, preferredId) {
|
|
892
|
+
for (let revision = 1; revision <= 50; revision += 1) {
|
|
893
|
+
const candidate = nextTaskIdRevision(preferredId, revision);
|
|
894
|
+
if (!(await taskExistsOnDisk(repoRoot, candidate)))
|
|
895
|
+
return candidate;
|
|
896
|
+
}
|
|
897
|
+
throw new Error(`unable to allocate unique taskId near ${preferredId}`);
|
|
898
|
+
}
|
|
899
|
+
async function dispatchBootstrapFromPrd(ctx, p) {
|
|
900
|
+
const content = str(p.content) ?? str(p.fileText);
|
|
901
|
+
if (!content) {
|
|
902
|
+
return invalid("bootstrapFromPrd", "content is required");
|
|
903
|
+
}
|
|
904
|
+
const taskKind = normalizeConsoleWorkflowKind(str(p.taskKind), "standard");
|
|
905
|
+
const identity = deriveTaskIdentityFromPrd(content);
|
|
906
|
+
let taskId;
|
|
907
|
+
try {
|
|
908
|
+
taskId = await allocateUniqueTaskId(ctx.repoRoot, identity.taskId);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
return {
|
|
912
|
+
kind: "error",
|
|
913
|
+
status: 409,
|
|
914
|
+
body: operatorFailed({
|
|
915
|
+
command: "bootstrapFromPrd",
|
|
916
|
+
outcome: "conflict",
|
|
917
|
+
code: "REQUEST_ID_REUSE_CONFLICT",
|
|
918
|
+
message: error instanceof Error ? error.message : String(error),
|
|
919
|
+
}),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const title = identity.title;
|
|
923
|
+
const run = ctx.runCommand ?? ((a, o) => ctx.client.run(a, o));
|
|
924
|
+
// new-task prints a path, not operator JSON — do not expectJson (that flips ok=false).
|
|
925
|
+
const created = await run(["new-task", taskId, title], {
|
|
926
|
+
cwd: ctx.repoRoot,
|
|
927
|
+
artifactName: "bootstrap-new-task",
|
|
928
|
+
timeoutMs: 60_000,
|
|
929
|
+
});
|
|
930
|
+
if (created.exitCode !== 0 || created.timedOut) {
|
|
931
|
+
return {
|
|
932
|
+
kind: "error",
|
|
933
|
+
status: 400,
|
|
934
|
+
body: operatorFailed({
|
|
935
|
+
command: "bootstrapFromPrd",
|
|
936
|
+
outcome: "rejected",
|
|
937
|
+
code: "INTERNAL_ERROR",
|
|
938
|
+
message: created.stderr?.trim() ||
|
|
939
|
+
`new-task failed with exit ${created.exitCode}`,
|
|
940
|
+
details: {
|
|
941
|
+
exitCode: created.exitCode,
|
|
942
|
+
timedOut: created.timedOut,
|
|
943
|
+
taskId,
|
|
944
|
+
title,
|
|
945
|
+
stdoutPreview: (created.stdout ?? "").slice(0, 400),
|
|
946
|
+
},
|
|
947
|
+
}),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const staged = await stageUtf8Text(ctx.appData, content, {
|
|
951
|
+
extension: ".md",
|
|
952
|
+
});
|
|
953
|
+
const imported = await run(["import-prd", taskId, "--file", staged.path, "--json"], {
|
|
954
|
+
cwd: ctx.repoRoot,
|
|
955
|
+
artifactName: "bootstrap-import-prd",
|
|
956
|
+
expectJson: true,
|
|
957
|
+
timeoutMs: 60_000,
|
|
958
|
+
});
|
|
959
|
+
if (!imported.ok || imported.exitCode !== 0) {
|
|
960
|
+
return {
|
|
961
|
+
kind: "error",
|
|
962
|
+
status: 400,
|
|
963
|
+
body: operatorFailed({
|
|
964
|
+
command: "bootstrapFromPrd",
|
|
965
|
+
outcome: "rejected",
|
|
966
|
+
code: "INTERNAL_ERROR",
|
|
967
|
+
message: imported.stderr?.trim() ||
|
|
968
|
+
`import-prd failed with exit ${imported.exitCode}`,
|
|
969
|
+
details: { exitCode: imported.exitCode, taskId },
|
|
970
|
+
}),
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const draftSaved = await ctx.drafts.save({
|
|
974
|
+
...emptyDraft(taskId, title, { taskKind }),
|
|
975
|
+
requirement: {
|
|
976
|
+
objective: title,
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
return {
|
|
980
|
+
kind: "sync",
|
|
981
|
+
status: 200,
|
|
982
|
+
body: operatorSucceeded("bootstrapFromPrd", {
|
|
983
|
+
taskId,
|
|
984
|
+
title,
|
|
985
|
+
taskKind,
|
|
986
|
+
identitySource: identity.source,
|
|
987
|
+
preferredTaskId: identity.taskId,
|
|
988
|
+
draftSha256: draftSaved.draftSha256,
|
|
989
|
+
import: imported.json ?? null,
|
|
990
|
+
}),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
async function ensureDraft(ctx, taskId, title, options) {
|
|
994
|
+
const existing = await ctx.drafts.get(taskId);
|
|
995
|
+
if (existing) {
|
|
996
|
+
const seededKind = str(options?.taskKind);
|
|
997
|
+
if (seededKind && !existing.draft.taskKind?.trim()) {
|
|
998
|
+
const saved = await ctx.drafts.save({
|
|
999
|
+
...existing.draft,
|
|
1000
|
+
taskKind: normalizeConsoleWorkflowKind(seededKind),
|
|
1001
|
+
});
|
|
1002
|
+
return { draft: saved.draft, draftSha256: saved.draftSha256 };
|
|
1003
|
+
}
|
|
1004
|
+
return { draft: existing.draft, draftSha256: existing.draftSha256 };
|
|
1005
|
+
}
|
|
1006
|
+
const created = await ctx.drafts.save(emptyDraft(taskId, title, { taskKind: options?.taskKind }));
|
|
1007
|
+
return { draft: created.draft, draftSha256: created.draftSha256 };
|
|
1008
|
+
}
|
|
1009
|
+
async function finalizeInterviewIfComplete(ctx, sessionId, draft) {
|
|
1010
|
+
const question = nextGrillMeQuestion(draft);
|
|
1011
|
+
if (question) {
|
|
1012
|
+
const session = await ctx.interviews.setCurrentQuestion(sessionId, {
|
|
1013
|
+
id: question.id,
|
|
1014
|
+
text: `${question.text}\n\n推荐:${question.recommendation}`,
|
|
1015
|
+
affectsFields: question.affectsFields,
|
|
1016
|
+
risk: question.risk,
|
|
1017
|
+
}, "eliciting");
|
|
1018
|
+
const saved = await ctx.drafts.save(draft);
|
|
1019
|
+
return {
|
|
1020
|
+
session,
|
|
1021
|
+
draft: saved.draft,
|
|
1022
|
+
draftSha256: saved.draftSha256,
|
|
1023
|
+
question,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
if (!isDraftStructurallyComplete(draft)) {
|
|
1027
|
+
const saved = await ctx.drafts.save({
|
|
1028
|
+
...draft,
|
|
1029
|
+
openQuestions: listResidualNotes(draft),
|
|
1030
|
+
});
|
|
1031
|
+
const current = await ctx.interviews.get(sessionId);
|
|
1032
|
+
if (!current) {
|
|
1033
|
+
return {
|
|
1034
|
+
session: undefined,
|
|
1035
|
+
draft: saved.draft,
|
|
1036
|
+
draftSha256: saved.draftSha256,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const blocked = await ctx.interviews.save({
|
|
1040
|
+
...current,
|
|
1041
|
+
state: "validation-blocked",
|
|
1042
|
+
currentQuestion: undefined,
|
|
1043
|
+
});
|
|
1044
|
+
return {
|
|
1045
|
+
session: blocked,
|
|
1046
|
+
draft: saved.draft,
|
|
1047
|
+
draftSha256: saved.draftSha256,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const saved = await ctx.drafts.save({
|
|
1051
|
+
...draft,
|
|
1052
|
+
openQuestions: [],
|
|
1053
|
+
});
|
|
1054
|
+
const assessment = await ctx.assessments.create({
|
|
1055
|
+
draft: saved.draft,
|
|
1056
|
+
instructionVersion: "grill-me@1",
|
|
1057
|
+
provider: "console",
|
|
1058
|
+
model: "deterministic-integrity-v1",
|
|
1059
|
+
sessionId,
|
|
1060
|
+
turnId: `turn_${saved.draftSha256.slice(0, 12)}`,
|
|
1061
|
+
outcome: "complete",
|
|
1062
|
+
});
|
|
1063
|
+
const withRef = await ctx.drafts.save({
|
|
1064
|
+
...saved.draft,
|
|
1065
|
+
interviewAssessmentRef: {
|
|
1066
|
+
assessmentId: assessment.assessmentId,
|
|
1067
|
+
draftSha256: saved.draftSha256,
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
const session = await ctx.interviews.markDraftReady(sessionId, withRef.draft, withRef.draftSha256);
|
|
1071
|
+
await ctx.interviews.save({
|
|
1072
|
+
...session,
|
|
1073
|
+
assessmentId: assessment.assessmentId,
|
|
1074
|
+
state: "draft-ready",
|
|
1075
|
+
currentQuestion: undefined,
|
|
1076
|
+
});
|
|
1077
|
+
const ready = await ctx.interviews.get(sessionId);
|
|
1078
|
+
return {
|
|
1079
|
+
session: ready,
|
|
1080
|
+
draft: withRef.draft,
|
|
1081
|
+
draftSha256: withRef.draftSha256,
|
|
1082
|
+
assessmentId: assessment.assessmentId,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
function listResidualNotes(draft) {
|
|
1086
|
+
return [
|
|
1087
|
+
`validation-blocked: Draft still missing required Interview fields: ${JSON.stringify(draftSummary(draft).gaps)}`,
|
|
1088
|
+
];
|
|
1089
|
+
}
|
|
1090
|
+
async function dispatchInterviewStart(ctx, p) {
|
|
1091
|
+
const taskId = str(p.taskId);
|
|
1092
|
+
if (!taskId)
|
|
1093
|
+
return invalid("interviewStart", "taskId is required");
|
|
1094
|
+
const title = str(p.title) ?? taskId;
|
|
1095
|
+
const taskKind = str(p.taskKind);
|
|
1096
|
+
const { draft } = await ensureDraft(ctx, taskId, title, { taskKind });
|
|
1097
|
+
const session = await ctx.interviews.create({
|
|
1098
|
+
taskId,
|
|
1099
|
+
operatorSessionId: ctx.operatorSessionId,
|
|
1100
|
+
instructionVersion: "grill-me@1",
|
|
1101
|
+
});
|
|
1102
|
+
const finalized = await finalizeInterviewIfComplete(ctx, session.sessionId, draft);
|
|
1103
|
+
return {
|
|
1104
|
+
kind: "sync",
|
|
1105
|
+
status: 200,
|
|
1106
|
+
body: operatorSucceeded("interviewStart", {
|
|
1107
|
+
session: finalized.session,
|
|
1108
|
+
draft: finalized.draft,
|
|
1109
|
+
draftSha256: finalized.draftSha256,
|
|
1110
|
+
assessmentId: finalized.assessmentId ?? null,
|
|
1111
|
+
question: finalized.question
|
|
1112
|
+
? {
|
|
1113
|
+
id: finalized.question.id,
|
|
1114
|
+
text: finalized.question.text,
|
|
1115
|
+
recommendation: finalized.question.recommendation,
|
|
1116
|
+
affectsFields: finalized.question.affectsFields,
|
|
1117
|
+
risk: finalized.question.risk,
|
|
1118
|
+
gapId: finalized.question.gapId,
|
|
1119
|
+
}
|
|
1120
|
+
: null,
|
|
1121
|
+
summary: draftSummary(finalized.draft),
|
|
1122
|
+
zeroExtraQuestions: !finalized.question && Boolean(finalized.assessmentId),
|
|
1123
|
+
}),
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Skip interactive grilling: accept recommendations for remaining gaps and
|
|
1128
|
+
* issue a complete assessment so contractApply can proceed.
|
|
1129
|
+
*/
|
|
1130
|
+
async function dispatchInterviewSkip(ctx, p) {
|
|
1131
|
+
const taskId = str(p.taskId);
|
|
1132
|
+
if (!taskId)
|
|
1133
|
+
return invalid("interviewSkip", "taskId is required");
|
|
1134
|
+
const title = str(p.title) ?? taskId;
|
|
1135
|
+
const taskKind = str(p.taskKind);
|
|
1136
|
+
let { draft } = await ensureDraft(ctx, taskId, title, { taskKind });
|
|
1137
|
+
const accepted = [];
|
|
1138
|
+
for (let i = 0; i < 24; i += 1) {
|
|
1139
|
+
const question = nextGrillMeQuestion(draft);
|
|
1140
|
+
if (!question)
|
|
1141
|
+
break;
|
|
1142
|
+
draft = applyGrillMeAnswer(draft, question, {
|
|
1143
|
+
response: "accept-recommendation",
|
|
1144
|
+
});
|
|
1145
|
+
accepted.push(question.gapId);
|
|
1146
|
+
}
|
|
1147
|
+
if (!isDraftStructurallyComplete(draft)) {
|
|
1148
|
+
const saved = await ctx.drafts.save({
|
|
1149
|
+
...draft,
|
|
1150
|
+
openQuestions: listResidualNotes(draft),
|
|
1151
|
+
});
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "error",
|
|
1154
|
+
status: 400,
|
|
1155
|
+
body: operatorFailed({
|
|
1156
|
+
command: "interviewSkip",
|
|
1157
|
+
outcome: "invalid",
|
|
1158
|
+
code: "INVALID_INPUT",
|
|
1159
|
+
message: "无法跳过:采用推荐后草稿仍不完整,请先补齐需求或手动访谈",
|
|
1160
|
+
details: {
|
|
1161
|
+
gaps: draftSummary(saved.draft).gaps,
|
|
1162
|
+
acceptedRecommendations: accepted,
|
|
1163
|
+
},
|
|
1164
|
+
}),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
const session = await ctx.interviews.create({
|
|
1168
|
+
taskId,
|
|
1169
|
+
operatorSessionId: ctx.operatorSessionId,
|
|
1170
|
+
instructionVersion: "grill-me@1+skip",
|
|
1171
|
+
});
|
|
1172
|
+
const priorAssumptions = Array.isArray(draft.assumptions)
|
|
1173
|
+
? draft.assumptions
|
|
1174
|
+
: [];
|
|
1175
|
+
const finalized = await finalizeInterviewIfComplete(ctx, session.sessionId, {
|
|
1176
|
+
...draft,
|
|
1177
|
+
assumptions: [
|
|
1178
|
+
...priorAssumptions,
|
|
1179
|
+
"operator skipped interactive interview; grill-me recommendations applied",
|
|
1180
|
+
],
|
|
1181
|
+
});
|
|
1182
|
+
if (!finalized.assessmentId) {
|
|
1183
|
+
return {
|
|
1184
|
+
kind: "error",
|
|
1185
|
+
status: 400,
|
|
1186
|
+
body: operatorFailed({
|
|
1187
|
+
command: "interviewSkip",
|
|
1188
|
+
outcome: "invalid",
|
|
1189
|
+
code: "INVALID_INPUT",
|
|
1190
|
+
message: "跳过访谈后未能签发完整评估",
|
|
1191
|
+
details: { gaps: draftSummary(finalized.draft).gaps },
|
|
1192
|
+
}),
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
// Re-tag assessment provenance as explicit skip (finalize uses deterministic model).
|
|
1196
|
+
const assessment = await ctx.assessments.get(finalized.assessmentId);
|
|
1197
|
+
if (assessment) {
|
|
1198
|
+
await ctx.assessments.save({
|
|
1199
|
+
...assessment,
|
|
1200
|
+
provider: "console-skip",
|
|
1201
|
+
model: "accept-recommendations-v1",
|
|
1202
|
+
instructionVersion: "grill-me@1+skip",
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
kind: "sync",
|
|
1207
|
+
status: 200,
|
|
1208
|
+
body: operatorSucceeded("interviewSkip", {
|
|
1209
|
+
session: finalized.session,
|
|
1210
|
+
draft: finalized.draft,
|
|
1211
|
+
draftSha256: finalized.draftSha256,
|
|
1212
|
+
assessmentId: finalized.assessmentId,
|
|
1213
|
+
question: null,
|
|
1214
|
+
summary: draftSummary(finalized.draft),
|
|
1215
|
+
skipped: true,
|
|
1216
|
+
acceptedRecommendations: accepted,
|
|
1217
|
+
complete: true,
|
|
1218
|
+
}),
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
async function dispatchInterviewTurn(ctx, p) {
|
|
1222
|
+
const sessionId = str(p.sessionId);
|
|
1223
|
+
if (!sessionId)
|
|
1224
|
+
return invalid("interviewTurn", "sessionId is required");
|
|
1225
|
+
const session = await ctx.interviews.get(sessionId);
|
|
1226
|
+
if (!session) {
|
|
1227
|
+
return {
|
|
1228
|
+
kind: "error",
|
|
1229
|
+
status: 404,
|
|
1230
|
+
body: operatorFailed({
|
|
1231
|
+
command: "interviewTurn",
|
|
1232
|
+
outcome: "not-found",
|
|
1233
|
+
code: "NOT_FOUND",
|
|
1234
|
+
message: `interview session not found: ${sessionId}`,
|
|
1235
|
+
}),
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
const questionId = str(p.questionId) ?? session.currentQuestion?.id;
|
|
1239
|
+
if (!questionId || !session.currentQuestion) {
|
|
1240
|
+
return invalid("interviewTurn", "no open question; call interviewStart or interviewGet");
|
|
1241
|
+
}
|
|
1242
|
+
if (session.currentQuestion.id !== questionId) {
|
|
1243
|
+
return invalid("interviewTurn", "questionId does not match open question");
|
|
1244
|
+
}
|
|
1245
|
+
const response = str(p.response) ??
|
|
1246
|
+
(str(p.acceptRecommendation) === "true"
|
|
1247
|
+
? "accept-recommendation"
|
|
1248
|
+
: str(p.text)
|
|
1249
|
+
? "override"
|
|
1250
|
+
: "accept-recommendation");
|
|
1251
|
+
const text = str(p.text);
|
|
1252
|
+
const draftRec = await ctx.drafts.get(session.taskId);
|
|
1253
|
+
const draft = draftRec?.draft ?? emptyDraft(session.taskId);
|
|
1254
|
+
const gapId = questionId.replace(/^q-/, "");
|
|
1255
|
+
const question = nextGrillMeQuestion(draft)?.id === questionId
|
|
1256
|
+
? nextGrillMeQuestion(draft)
|
|
1257
|
+
: {
|
|
1258
|
+
id: questionId,
|
|
1259
|
+
gapId,
|
|
1260
|
+
text: session.currentQuestion.text,
|
|
1261
|
+
recommendation: extractRecommendation(session.currentQuestion.text),
|
|
1262
|
+
affectsFields: session.currentQuestion.affectsFields,
|
|
1263
|
+
risk: session.currentQuestion.risk ?? "medium",
|
|
1264
|
+
};
|
|
1265
|
+
if (response === "defer" && question.risk === "high") {
|
|
1266
|
+
return {
|
|
1267
|
+
kind: "error",
|
|
1268
|
+
status: 403,
|
|
1269
|
+
body: operatorFailed({
|
|
1270
|
+
command: "interviewTurn",
|
|
1271
|
+
outcome: "blocked",
|
|
1272
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
1273
|
+
message: "高风险访谈问题不可暂缓",
|
|
1274
|
+
details: { questionId, risk: question.risk },
|
|
1275
|
+
}),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
await ctx.interviews.recordAnswer(sessionId, {
|
|
1279
|
+
questionId,
|
|
1280
|
+
response,
|
|
1281
|
+
text,
|
|
1282
|
+
at: new Date().toISOString(),
|
|
1283
|
+
});
|
|
1284
|
+
const updatedDraft = applyGrillMeAnswer(draft, question, { response, text });
|
|
1285
|
+
const finalized = await finalizeInterviewIfComplete(ctx, sessionId, updatedDraft);
|
|
1286
|
+
return {
|
|
1287
|
+
kind: "sync",
|
|
1288
|
+
status: 200,
|
|
1289
|
+
body: operatorSucceeded("interviewTurn", {
|
|
1290
|
+
session: finalized.session,
|
|
1291
|
+
draft: finalized.draft,
|
|
1292
|
+
draftSha256: finalized.draftSha256,
|
|
1293
|
+
assessmentId: finalized.assessmentId ?? null,
|
|
1294
|
+
question: finalized.question
|
|
1295
|
+
? {
|
|
1296
|
+
id: finalized.question.id,
|
|
1297
|
+
text: finalized.question.text,
|
|
1298
|
+
recommendation: finalized.question.recommendation,
|
|
1299
|
+
affectsFields: finalized.question.affectsFields,
|
|
1300
|
+
risk: finalized.question.risk,
|
|
1301
|
+
gapId: finalized.question.gapId,
|
|
1302
|
+
}
|
|
1303
|
+
: null,
|
|
1304
|
+
summary: draftSummary(finalized.draft),
|
|
1305
|
+
complete: Boolean(finalized.assessmentId),
|
|
1306
|
+
}),
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function extractRecommendation(questionText) {
|
|
1310
|
+
const m = questionText.match(/推荐[::]\s*(.+)$/m);
|
|
1311
|
+
return m?.[1]?.trim() || "";
|
|
1312
|
+
}
|
|
724
1313
|
function invalid(action, message) {
|
|
725
1314
|
return {
|
|
726
1315
|
kind: "error",
|
|
@@ -751,7 +1340,7 @@ async function dispatchContractValidateOrDiff(ctx, action, p) {
|
|
|
751
1340
|
if (!stored) {
|
|
752
1341
|
return invalid(action, "draftJson or stored draft required");
|
|
753
1342
|
}
|
|
754
|
-
const staged = await stageUtf8Text(ctx.appData, JSON.stringify(stored.draft, null, 2), { extension: ".json" });
|
|
1343
|
+
const staged = await stageUtf8Text(ctx.appData, JSON.stringify(toCanonicalDraftForCli(stored.draft), null, 2), { extension: ".json" });
|
|
755
1344
|
inputPath = staged.path;
|
|
756
1345
|
}
|
|
757
1346
|
else {
|