@tea-agent/loop-agent 0.17.0 → 0.17.2

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.
@@ -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 is required",
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
- target = { kind: "task", taskId };
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
- return invalid(action, "dagRunId, taskId, featureId, or workerRunId required");
318
+ openHome = true;
249
319
  }
250
- const requiredCapability = capabilityForObserveTarget(target);
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 = buildObserveDeepLink(baseUrl, target);
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
- const staged = await stageUtf8Text(ctx.appData, dagText, {
328
- extension: ".json",
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: "dag validate failed; cannot prepare confirmation for an invalid DAG",
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: "no Console draft for task; save draft first",
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: "assessment not found",
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
- const expectedRevision = String(p.expectedRevision ?? "0");
577
- const expectedHash = str(p.expectedObservedHash) ?? "unknown";
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 {