@dev-loops/core 0.7.1 → 0.8.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/package.json +6 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/config/config.mjs +80 -1
- package/src/config/extension-defaults.yaml +6 -0
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +3 -0
- package/src/loop/issue-refinement-artifact.mjs +70 -10
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +129 -2
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/queue-board-ordering.mjs +1 -1
- package/src/loop/queue-board-sync.mjs +1 -1
- package/src/loop/refinement-grill-state.mjs +173 -0
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
|
@@ -34,6 +34,16 @@ export const REFINEMENT_ARTIFACT_STATUS = Object.freeze({
|
|
|
34
34
|
|
|
35
35
|
export const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* `refinementArtifact.specSource` values: which of the three sanctioned
|
|
39
|
+
* spec-of-record origins (artifact-authority-contract.md) backed the check.
|
|
40
|
+
*/
|
|
41
|
+
export const REFINEMENT_ARTIFACT_SPEC_SOURCE = Object.freeze({
|
|
42
|
+
LINKED_ISSUE: "linked_issue",
|
|
43
|
+
PR_BODY: "pr_body",
|
|
44
|
+
PLAN_FILE: "plan_file",
|
|
45
|
+
});
|
|
46
|
+
|
|
37
47
|
export const PR_CHECKPOINT_ACTION = Object.freeze({
|
|
38
48
|
RUN_DRAFT_GATE: "run_draft_gate",
|
|
39
49
|
MARK_READY_FOR_REVIEW: "mark_ready_for_review",
|
|
@@ -227,7 +237,15 @@ function normalizeRefinementArtifactStatus(value) {
|
|
|
227
237
|
return REFINEMENT_ARTIFACT_STATUS.UNKNOWN;
|
|
228
238
|
}
|
|
229
239
|
|
|
230
|
-
|
|
240
|
+
// Issue-less refinement artifacts (specSource "pr_body"/"plan_file") carry
|
|
241
|
+
// their own validation-failure reason from the detector; that reason must
|
|
242
|
+
// replace the "linked issue" wording, which does not apply when the PR is the
|
|
243
|
+
// spec-of-record and no linked issue was ever expected.
|
|
244
|
+
function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact) {
|
|
245
|
+
const specSource = refinementArtifact?.specSource;
|
|
246
|
+
if (specSource != null && specSource !== REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE && typeof refinementArtifact?.reason === "string" && refinementArtifact.reason.length > 0) {
|
|
247
|
+
return `The draft gate cannot complete: ${refinementArtifact.reason} finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
248
|
+
}
|
|
231
249
|
if (linkedIssue !== null && Number.isInteger(linkedIssue)) {
|
|
232
250
|
return `Linked issue #${linkedIssue} has no refinement artifact (Acceptance criteria / DoD / linked refinement doc). Run refinement first, add ACs/DoD to the issue, then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
233
251
|
}
|
|
@@ -470,6 +488,110 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
|
|
|
470
488
|
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
471
489
|
]);
|
|
472
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Independent gate-ENTRY re-check (issue #1190): even when the caller's
|
|
493
|
+
* lifecycleState/sameHeadCleanConverged claims a settled Copilot convergence,
|
|
494
|
+
* an outstanding (`requested`/`already-requested`) Copilot review request on
|
|
495
|
+
* the CURRENT head is a second, independent "unsettled" signal — not derived
|
|
496
|
+
* from sameHeadCleanConverged — that must refuse pre_approval_gate /
|
|
497
|
+
* final-approval entry outright.
|
|
498
|
+
*
|
|
499
|
+
* This mirrors the fail-closed predicate that previously only fired at
|
|
500
|
+
* *verdict-post* time (upsert-checkpoint-verdict.mjs, which refuses to post a
|
|
501
|
+
* pre_approval_gate verdict while this same evaluator forbids
|
|
502
|
+
* RUN_PRE_APPROVAL_GATE): asserting it here, at gate *entry*, refuses the
|
|
503
|
+
* pre-approval fan-out up front instead of only after reviewer tokens have
|
|
504
|
+
* already been spent.
|
|
505
|
+
*
|
|
506
|
+
* Skipped when Copilot review is not required at all — `reviewMode:
|
|
507
|
+
* "internal_only"` or `maxCopilotRounds: 0` — preserving the existing #613 /
|
|
508
|
+
* #1210 exemptions (internal-only and light-dispatched-with-disabled-review
|
|
509
|
+
* PRs never need a Copilot round in the first place).
|
|
510
|
+
*/
|
|
511
|
+
const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
|
|
512
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
513
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
514
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
518
|
+
if (!result || typeof result !== "object" || !PRE_APPROVAL_ENTRY_BOUNDARIES.includes(result.gateBoundary)) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
if (input.maxCopilotRounds === 0) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const reviewMode = typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null;
|
|
525
|
+
if (reviewMode === "internal_only") {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
|
|
529
|
+
? input.copilotReviewRequestStatus.trim().toLowerCase()
|
|
530
|
+
: "none";
|
|
531
|
+
if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
// Round-cap exemption (mirrors shouldGuardCopilotReviewRequest, #896/#848):
|
|
535
|
+
// past the cap a lingering requested/already-requested status is for a review
|
|
536
|
+
// that can never come (no further round is permitted), so treating it as
|
|
537
|
+
// "unsettled" here would re-introduce the infinite-wait dead-end the
|
|
538
|
+
// ROUND_CAP_CLEAN_FALLBACK routing exists to prevent. When the cap is reached
|
|
539
|
+
// and the head is clean — either sameHeadCleanConverged or the interpreter's
|
|
540
|
+
// round_cap_clean_fallback state — the pre_approval_gate proceeds unless
|
|
541
|
+
// significant post-convergence changes require a new review cycle.
|
|
542
|
+
const roundCapReached = isCopilotRoundCapReached({
|
|
543
|
+
copilotReviewRoundCount: input.copilotReviewRoundCount,
|
|
544
|
+
maxCopilotRounds: input.maxCopilotRounds,
|
|
545
|
+
});
|
|
546
|
+
const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
|
|
547
|
+
const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
548
|
+
if (
|
|
549
|
+
roundCapReached
|
|
550
|
+
&& (input.sameHeadCleanConverged === true || roundCapCleanFallback)
|
|
551
|
+
&& input.postConvergenceSignificantChange !== true
|
|
552
|
+
) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const allowedNextActions = [];
|
|
557
|
+
const forbiddenActions = [];
|
|
558
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
|
|
559
|
+
// Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
|
|
560
|
+
// result this guard synthesizes) plus the final-approval actions the replaced
|
|
561
|
+
// boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
|
|
562
|
+
// here would let a draft_gate verdict post on a non-draft PR slip through where
|
|
563
|
+
// the replaced result would have refused it.
|
|
564
|
+
pushUnique(forbiddenActions, [
|
|
565
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
566
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
567
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
568
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
569
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
return buildResult({
|
|
573
|
+
repo: input.repo ?? null,
|
|
574
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
575
|
+
currentHeadSha: result.currentHeadSha ?? null,
|
|
576
|
+
lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
577
|
+
loopDisposition: DISPOSITION.PENDING,
|
|
578
|
+
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
579
|
+
draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
|
|
580
|
+
draftGate: result.draftGate,
|
|
581
|
+
preApprovalGate: result.preApprovalGate,
|
|
582
|
+
allowedNextActions,
|
|
583
|
+
forbiddenActions,
|
|
584
|
+
nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
|
|
585
|
+
reason: "A Copilot review request is still outstanding on the current head (independent gate-entry "
|
|
586
|
+
+ "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
|
|
587
|
+
+ "review settles, even though the caller-reported convergence signal claims otherwise.",
|
|
588
|
+
mergeStateStatus: result.mergeStateStatus ?? null,
|
|
589
|
+
conflictFiles: result.conflictFiles ?? [],
|
|
590
|
+
refinementArtifact: result.refinementArtifact ?? null,
|
|
591
|
+
copilotReviewRoundCount: normalizeNonNegativeInteger(input.copilotReviewRoundCount),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
473
595
|
/**
|
|
474
596
|
* Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
|
|
475
597
|
* (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
|
|
@@ -482,6 +604,11 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
|
|
|
482
604
|
export function evaluatePrGateCoordination(input = {}) {
|
|
483
605
|
const result = evaluatePrGateCoordinationCore(input);
|
|
484
606
|
|
|
607
|
+
const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
|
|
608
|
+
if (unsettledReviewResult) {
|
|
609
|
+
return unsettledReviewResult;
|
|
610
|
+
}
|
|
611
|
+
|
|
485
612
|
const prDraft = input.prDraft === true;
|
|
486
613
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
487
614
|
// Draft PRs may legitimately carry a WIP title; the marker only blocks once
|
|
@@ -755,7 +882,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
755
882
|
allowedNextActions,
|
|
756
883
|
forbiddenActions,
|
|
757
884
|
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
758
|
-
reason: formatRefinementBlockedReason(refinementLinkedIssue, refinementArtifactStatus),
|
|
885
|
+
reason: formatRefinementBlockedReason(refinementLinkedIssue, refinementArtifactStatus, refinementArtifact),
|
|
759
886
|
mergeStateStatus,
|
|
760
887
|
conflictFiles,
|
|
761
888
|
refinementArtifact,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR lifecycle: the 13-state vocabulary + required transitions from
|
|
3
|
+
* skills/docs/pr-lifecycle-contract.md (issue #1193), promoted to a real
|
|
4
|
+
* exported contract surface.
|
|
5
|
+
*
|
|
6
|
+
* This is the single source of truth for the family-local PR lifecycle graph:
|
|
7
|
+
* both scripts/pages/build-state-atlas.mjs (site diagram generator) and
|
|
8
|
+
* scripts/docs/validate-state-machine-conformance.mjs (the L2/L3 conformance
|
|
9
|
+
* harness) import this same table, instead of one importing the other's
|
|
10
|
+
* module (which would pull the whole page generator — eager mermaid diagram
|
|
11
|
+
* rendering, duplicate core module instances via relative imports — into the
|
|
12
|
+
* harness's process at load time).
|
|
13
|
+
*
|
|
14
|
+
* Pure data + one derivation, no imports, no side effects.
|
|
15
|
+
*/
|
|
16
|
+
export const PR_LIFECYCLE_STATES = Object.freeze([
|
|
17
|
+
'draft_local_review_gate',
|
|
18
|
+
'draft_local_remediation',
|
|
19
|
+
'ready_state_needs_copilot_request',
|
|
20
|
+
'waiting_for_copilot_review',
|
|
21
|
+
'copilot_feedback_remediation',
|
|
22
|
+
'copilot_reply_resolve_pending',
|
|
23
|
+
'merge_conflict_resolution',
|
|
24
|
+
'final_local_preapproval_gate',
|
|
25
|
+
'final_gate_remediation',
|
|
26
|
+
'waiting_for_human_pr_approval',
|
|
27
|
+
'waiting_for_merge',
|
|
28
|
+
'terminal_slice_complete',
|
|
29
|
+
'stopped_needs_user_decision',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// '[*]' is the synthetic terminal-marker target (see build-state-atlas.mjs's
|
|
33
|
+
// renderStateDiagram and validate-state-machine-conformance.mjs's realEdges):
|
|
34
|
+
// a row `[state, '[*]']` marks `state` as absorbing without being a real edge.
|
|
35
|
+
const TERMINAL_MARKER = '[*]';
|
|
36
|
+
|
|
37
|
+
export const PR_LIFECYCLE_TRANSITIONS = Object.freeze([
|
|
38
|
+
Object.freeze(['draft_local_review_gate', 'draft_local_remediation']),
|
|
39
|
+
Object.freeze(['draft_local_review_gate', 'ready_state_needs_copilot_request']),
|
|
40
|
+
Object.freeze(['draft_local_review_gate', 'stopped_needs_user_decision']),
|
|
41
|
+
Object.freeze(['draft_local_remediation', 'draft_local_review_gate']),
|
|
42
|
+
Object.freeze(['ready_state_needs_copilot_request', 'waiting_for_copilot_review']),
|
|
43
|
+
Object.freeze(['ready_state_needs_copilot_request', 'stopped_needs_user_decision']),
|
|
44
|
+
Object.freeze(['waiting_for_copilot_review', 'copilot_feedback_remediation']),
|
|
45
|
+
Object.freeze(['copilot_feedback_remediation', 'copilot_reply_resolve_pending']),
|
|
46
|
+
Object.freeze(['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request']),
|
|
47
|
+
Object.freeze(['waiting_for_copilot_review', 'merge_conflict_resolution']),
|
|
48
|
+
Object.freeze(['merge_conflict_resolution', 'waiting_for_copilot_review']),
|
|
49
|
+
Object.freeze(['waiting_for_copilot_review', 'final_local_preapproval_gate']),
|
|
50
|
+
Object.freeze(['final_local_preapproval_gate', 'final_gate_remediation']),
|
|
51
|
+
Object.freeze(['final_local_preapproval_gate', 'waiting_for_human_pr_approval']),
|
|
52
|
+
Object.freeze(['final_gate_remediation', 'final_local_preapproval_gate']),
|
|
53
|
+
Object.freeze(['waiting_for_human_pr_approval', 'waiting_for_merge']),
|
|
54
|
+
Object.freeze(['waiting_for_human_pr_approval', 'draft_local_review_gate']),
|
|
55
|
+
Object.freeze(['waiting_for_merge', 'terminal_slice_complete']),
|
|
56
|
+
Object.freeze(['terminal_slice_complete', TERMINAL_MARKER]),
|
|
57
|
+
Object.freeze(['stopped_needs_user_decision', TERMINAL_MARKER]),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Derived, not hand-listed (lesson from #1157: a hand-copied terminal list can
|
|
61
|
+
// silently drift from the transition table it is supposed to describe). A
|
|
62
|
+
// state is terminal when it has zero real (non-marker) outgoing edges.
|
|
63
|
+
function deriveTerminalStates(states, transitions) {
|
|
64
|
+
const hasRealOutgoing = new Set(
|
|
65
|
+
transitions.filter(([, to]) => to !== TERMINAL_MARKER).map(([from]) => from),
|
|
66
|
+
);
|
|
67
|
+
return states.filter((state) => !hasRealOutgoing.has(state));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const PR_LIFECYCLE_TERMINAL_STATES = Object.freeze(deriveTerminalStates(PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS));
|
|
71
|
+
|
|
72
|
+
// Enum-style access (SCREAMING_SNAKE_CASE key -> the same state string), so
|
|
73
|
+
// handoff scripts can reference `PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST`
|
|
74
|
+
// instead of hardcoding the literal, mirroring the STATE/OUTER_STATE/REVIEWER_STATE
|
|
75
|
+
// convention used by the other loop state machines. Derived from PR_LIFECYCLE_STATES
|
|
76
|
+
// so a new state cannot be added to one without the other.
|
|
77
|
+
export const PR_LIFECYCLE_STATE = Object.freeze(
|
|
78
|
+
Object.fromEntries(PR_LIFECYCLE_STATES.map((state) => [state.toUpperCase(), state])),
|
|
79
|
+
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadBoardConfig, resolveProjectNumber, loadStateColumnMap, LOGICAL_COLUMN } from "./queue-board-sync.mjs";
|
|
2
|
-
import { main as listQueueItemsMain } from "
|
|
2
|
+
import { main as listQueueItemsMain } from "../projects/list-queue-items.mjs";
|
|
3
3
|
|
|
4
4
|
// Canonical fail-closed Next Up tokens — the SINGLE source of truth so the reason
|
|
5
5
|
// codes and the empty-queue message stay byte-identical across every layer that
|
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
4
|
import { runChild as coreRunChild } from "../cli/primitives.mjs";
|
|
5
|
-
import { main as moveQueueItemMain } from "
|
|
5
|
+
import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
|
|
8
8
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic state machine for the refinement/grill sub-loop.
|
|
3
|
+
*
|
|
4
|
+
* The refinement loop runs the grill as a CLOSED, DETERMINISTIC sub-loop:
|
|
5
|
+
* detect-gaps -> auto-answer -> synthesize -> re-grill -> terminal. The
|
|
6
|
+
* iteration lives entirely in the transition graph below; the LLM answer and
|
|
7
|
+
* synthesis enter ONLY as a bounded input consumed at the `await_answers`
|
|
8
|
+
* state (and reflected in the `synthesized` snapshot flag), never as hidden
|
|
9
|
+
* orchestration inside a deterministic coordinator script (keeps
|
|
10
|
+
* OPS-NO-INLINE-INTERPRETER, #1224, clean).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the shape of `reviewer-loop-state.mjs` / `copilot-loop-state.mjs`:
|
|
13
|
+
* a frozen STATE vocabulary, a frozen TRANSITIONS adjacency table, a
|
|
14
|
+
* `normalize*Snapshot` canonicalizer, and a pure `interpret*State` that maps a
|
|
15
|
+
* point-in-time snapshot to exactly one current state plus its legal exits.
|
|
16
|
+
*
|
|
17
|
+
* Honest handoff: when a gap is genuinely unanswerable (only-`inferred`, no
|
|
18
|
+
* citation), the machine reaches `needs_human_handoff` naming the question
|
|
19
|
+
* rather than fabricating an answer to force convergence.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const GRILL_STATE = Object.freeze({
|
|
23
|
+
LOAD_TARGET: "load_target",
|
|
24
|
+
DETECT_GAPS: "detect_gaps",
|
|
25
|
+
AWAIT_ANSWERS: "await_answers",
|
|
26
|
+
SYNTHESIZE: "synthesize",
|
|
27
|
+
RE_GRILL: "re_grill",
|
|
28
|
+
GRILL_CLEAN: "grill_clean",
|
|
29
|
+
NEEDS_HUMAN_HANDOFF: "needs_human_handoff",
|
|
30
|
+
BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The iterate-to-clean loop: detect_gaps -> await_answers -> synthesize ->
|
|
34
|
+
// re_grill, with re_grill either re-entering detect_gaps (a new answerable gap
|
|
35
|
+
// surfaced) or terminating at grill_clean (fixed point). Any I/O/parse failure
|
|
36
|
+
// fails closed to blocked_needs_user_decision; any unresolved (uncitable) gap
|
|
37
|
+
// terminates honestly at needs_human_handoff.
|
|
38
|
+
export const GRILL_TRANSITIONS = Object.freeze({
|
|
39
|
+
[GRILL_STATE.LOAD_TARGET]: [
|
|
40
|
+
GRILL_STATE.DETECT_GAPS,
|
|
41
|
+
GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
42
|
+
],
|
|
43
|
+
[GRILL_STATE.DETECT_GAPS]: [
|
|
44
|
+
GRILL_STATE.AWAIT_ANSWERS,
|
|
45
|
+
GRILL_STATE.GRILL_CLEAN,
|
|
46
|
+
GRILL_STATE.NEEDS_HUMAN_HANDOFF,
|
|
47
|
+
GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
48
|
+
],
|
|
49
|
+
[GRILL_STATE.AWAIT_ANSWERS]: [
|
|
50
|
+
GRILL_STATE.SYNTHESIZE,
|
|
51
|
+
GRILL_STATE.NEEDS_HUMAN_HANDOFF,
|
|
52
|
+
GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
53
|
+
],
|
|
54
|
+
[GRILL_STATE.SYNTHESIZE]: [
|
|
55
|
+
GRILL_STATE.RE_GRILL,
|
|
56
|
+
GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
57
|
+
],
|
|
58
|
+
[GRILL_STATE.RE_GRILL]: [
|
|
59
|
+
GRILL_STATE.DETECT_GAPS,
|
|
60
|
+
GRILL_STATE.GRILL_CLEAN,
|
|
61
|
+
GRILL_STATE.NEEDS_HUMAN_HANDOFF,
|
|
62
|
+
GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
63
|
+
],
|
|
64
|
+
[GRILL_STATE.GRILL_CLEAN]: [],
|
|
65
|
+
[GRILL_STATE.NEEDS_HUMAN_HANDOFF]: [],
|
|
66
|
+
[GRILL_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const GRILL_NEXT_ACTIONS = Object.freeze({
|
|
70
|
+
[GRILL_STATE.LOAD_TARGET]: "Load the target issue/PR/plan body for grilling",
|
|
71
|
+
[GRILL_STATE.DETECT_GAPS]: "Run the loop-grill gap detectors on the loaded spec",
|
|
72
|
+
[GRILL_STATE.AWAIT_ANSWERS]: "Consume the bounded answer input: --auto self-answer with a citation, or ask the human interactively",
|
|
73
|
+
[GRILL_STATE.SYNTHESIZE]: "Synthesize Acceptance criteria / Definition of done / Non-goals into the body; write raw Q&A only to the ephemeral tmp artifact",
|
|
74
|
+
[GRILL_STATE.RE_GRILL]: "Re-run gap detection to check for a fixed point",
|
|
75
|
+
[GRILL_STATE.GRILL_CLEAN]: "Grill reached a fixed point; synthesized spec is clean",
|
|
76
|
+
[GRILL_STATE.NEEDS_HUMAN_HANDOFF]: "Stop and hand off the named unanswerable question(s) to the human; headless parks with the recorded reason",
|
|
77
|
+
[GRILL_STATE.BLOCKED_NEEDS_USER_DECISION]: "Stop and request explicit user direction",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
|
|
81
|
+
|
|
82
|
+
function normalizeCount(value) {
|
|
83
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
84
|
+
? Math.floor(value)
|
|
85
|
+
: 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeStringOrNull(value) {
|
|
89
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Canonicalize a raw grill snapshot into a deterministic shape.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} raw
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeGrillSnapshot(raw) {
|
|
99
|
+
if (!raw || typeof raw !== "object") {
|
|
100
|
+
throw new Error("Snapshot must be a non-null object");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
surface: VALID_SURFACES.has(raw.surface) ? raw.surface : "issue",
|
|
105
|
+
targetRef: normalizeStringOrNull(raw.targetRef),
|
|
106
|
+
|
|
107
|
+
loaded: Boolean(raw.loaded),
|
|
108
|
+
loadFailed: Boolean(raw.loadFailed),
|
|
109
|
+
|
|
110
|
+
detectRan: Boolean(raw.detectRan),
|
|
111
|
+
// answerable gaps still awaiting an answer this pass
|
|
112
|
+
openGapCount: normalizeCount(raw.openGapCount),
|
|
113
|
+
// uncitable gaps that must hand off honestly (never fabricated)
|
|
114
|
+
unresolvedGapCount: normalizeCount(raw.unresolvedGapCount),
|
|
115
|
+
|
|
116
|
+
// the bounded LLM answer input, consumed at await_answers
|
|
117
|
+
answersReady: Boolean(raw.answersReady),
|
|
118
|
+
// synthesized AC/DoD/Non-goals applied to the body this iteration
|
|
119
|
+
synthesized: Boolean(raw.synthesized),
|
|
120
|
+
|
|
121
|
+
// post-synthesis re-grill fixed-point signals
|
|
122
|
+
reGrillRan: Boolean(raw.reGrillRan),
|
|
123
|
+
reGrillFixedPoint: Boolean(raw.reGrillFixedPoint),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Deterministically interpret the current refinement-grill state.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} snapshot
|
|
131
|
+
* @returns {{state: string, allowedTransitions: string[], nextAction: string}}
|
|
132
|
+
*/
|
|
133
|
+
export function interpretRefinementGrillState(snapshot) {
|
|
134
|
+
const s = normalizeGrillSnapshot(snapshot);
|
|
135
|
+
|
|
136
|
+
let state;
|
|
137
|
+
|
|
138
|
+
if (s.loadFailed) {
|
|
139
|
+
// Fail closed on any load/parse failure, from any point in the loop.
|
|
140
|
+
state = GRILL_STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
141
|
+
} else if (!s.loaded) {
|
|
142
|
+
state = GRILL_STATE.LOAD_TARGET;
|
|
143
|
+
} else if (s.unresolvedGapCount > 0) {
|
|
144
|
+
// Honest handoff outranks everything else: never fabricate to converge.
|
|
145
|
+
state = GRILL_STATE.NEEDS_HUMAN_HANDOFF;
|
|
146
|
+
} else if (s.synthesized) {
|
|
147
|
+
if (!s.reGrillRan) {
|
|
148
|
+
// Synthesis applied -> re-grill to check the fixed point.
|
|
149
|
+
state = GRILL_STATE.RE_GRILL;
|
|
150
|
+
} else if (s.reGrillFixedPoint) {
|
|
151
|
+
state = GRILL_STATE.GRILL_CLEAN;
|
|
152
|
+
} else {
|
|
153
|
+
// Re-grill surfaced a new answerable gap -> iterate.
|
|
154
|
+
state = GRILL_STATE.DETECT_GAPS;
|
|
155
|
+
}
|
|
156
|
+
} else if (s.answersReady) {
|
|
157
|
+
// Bounded answer input present -> apply synthesis.
|
|
158
|
+
state = GRILL_STATE.SYNTHESIZE;
|
|
159
|
+
} else if (s.detectRan) {
|
|
160
|
+
// Detection ran with no unresolved and no pending answers:
|
|
161
|
+
// open gaps -> await answers; zero gaps -> clean fixed point
|
|
162
|
+
// (also the already-refined, zero-iteration path).
|
|
163
|
+
state = s.openGapCount > 0 ? GRILL_STATE.AWAIT_ANSWERS : GRILL_STATE.GRILL_CLEAN;
|
|
164
|
+
} else {
|
|
165
|
+
state = GRILL_STATE.DETECT_GAPS;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
state,
|
|
170
|
+
allowedTransitions: [...GRILL_TRANSITIONS[state]],
|
|
171
|
+
nextAction: GRILL_NEXT_ACTIONS[state],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -18,40 +18,55 @@ export const REVIEWER_STATE = Object.freeze({
|
|
|
18
18
|
BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
// The reviewSubmissionStatus guard applies only after the {!prExists, prMerged, prClosed,
|
|
22
|
+
// prDraft} pre-gates, then fires ahead of the state-specific branches: failed ->
|
|
23
|
+
// blocked_needs_user_decision, submitted -> submitted_review. The table declares every such pair.
|
|
21
24
|
export const REVIEWER_TRANSITIONS = Object.freeze({
|
|
22
|
-
[REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: [
|
|
25
|
+
[REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: [
|
|
26
|
+
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
27
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
28
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
29
|
+
],
|
|
23
30
|
[REVIEWER_STATE.REVIEW_REQUESTED]: [
|
|
24
31
|
REVIEWER_STATE.DETERMINE_REVIEW_PLAN,
|
|
32
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
25
33
|
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
26
34
|
],
|
|
27
35
|
[REVIEWER_STATE.DETERMINE_REVIEW_PLAN]: [
|
|
28
36
|
REVIEWER_STATE.REVIEWS_RUNNING,
|
|
37
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
29
38
|
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
30
39
|
],
|
|
31
40
|
[REVIEWER_STATE.REVIEWS_RUNNING]: [
|
|
32
41
|
REVIEWER_STATE.MERGE_RESULTS,
|
|
42
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
33
43
|
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
34
44
|
],
|
|
35
45
|
[REVIEWER_STATE.MERGE_RESULTS]: [
|
|
36
46
|
REVIEWER_STATE.DRAFT_REVIEW_READY,
|
|
47
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
37
48
|
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
38
49
|
],
|
|
39
50
|
[REVIEWER_STATE.DRAFT_REVIEW_READY]: [
|
|
40
51
|
REVIEWER_STATE.DRAFT_REVIEW_POSTED,
|
|
52
|
+
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
41
53
|
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
42
54
|
],
|
|
43
55
|
[REVIEWER_STATE.DRAFT_REVIEW_POSTED]: [
|
|
44
56
|
REVIEWER_STATE.WAITING_FOR_USER_SUBMIT,
|
|
45
57
|
REVIEWER_STATE.REVIEW_INVALIDATED,
|
|
46
58
|
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
59
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
47
60
|
],
|
|
48
61
|
[REVIEWER_STATE.WAITING_FOR_USER_SUBMIT]: [
|
|
49
62
|
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
50
63
|
REVIEWER_STATE.REVIEW_INVALIDATED,
|
|
64
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
51
65
|
],
|
|
52
66
|
[REVIEWER_STATE.SUBMITTED_REVIEW]: [
|
|
53
67
|
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
54
68
|
REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST,
|
|
69
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
55
70
|
],
|
|
56
71
|
[REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP]: [
|
|
57
72
|
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
@@ -62,7 +77,10 @@ export const REVIEWER_TRANSITIONS = Object.freeze({
|
|
|
62
77
|
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
63
78
|
REVIEWER_STATE.SUBMITTED_REVIEW,
|
|
64
79
|
],
|
|
65
|
-
[REVIEWER_STATE.REVIEW_INVALIDATED]: [
|
|
80
|
+
[REVIEWER_STATE.REVIEW_INVALIDATED]: [
|
|
81
|
+
REVIEWER_STATE.REVIEW_REQUESTED,
|
|
82
|
+
REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
83
|
+
],
|
|
66
84
|
[REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
|
|
67
85
|
});
|
|
68
86
|
|