@dev-loops/core 0.7.2 → 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 +2 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/handoff-envelope.mjs +3 -0
- package/src/loop/issue-refinement-artifact.mjs +37 -0
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +20 -2
- package/src/loop/refinement-grill-state.mjs +173 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
|
|
42
42
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
43
43
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
44
|
+
"./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
|
|
44
45
|
"./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
|
|
45
46
|
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
46
47
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
@@ -61,7 +61,7 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
|
61
61
|
* clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
|
|
62
62
|
* gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
|
|
63
63
|
* entirely. Everything else passes through.
|
|
64
|
-
* - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
|
|
64
|
+
* - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY when the call
|
|
65
65
|
* originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
|
|
66
66
|
* Sanctioned external writes flow through node wrappers (gate-verdict comments via
|
|
67
67
|
* `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
|
|
@@ -85,7 +85,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
85
85
|
return ALLOW;
|
|
86
86
|
}
|
|
87
87
|
// Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
|
|
88
|
-
// `gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
88
|
+
// `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
89
89
|
// sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
|
|
90
90
|
if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
|
|
91
91
|
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
@@ -101,9 +101,10 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
101
101
|
return {
|
|
102
102
|
decision: "deny",
|
|
103
103
|
reason:
|
|
104
|
-
"Ad-hoc GitHub issue/PR creation and
|
|
104
|
+
"Ad-hoc GitHub issue/PR creation, comments, and edits from a subagent are blocked. Use the sanctioned " +
|
|
105
105
|
"node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
|
|
106
|
-
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync,
|
|
106
|
+
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync, issue comments via " +
|
|
107
|
+
"scripts/github/comment-issue.mjs, or issue-body edits via scripts/github/edit-issue.mjs. " +
|
|
107
108
|
"Direct `gh issue create` is reserved for the main agent / operator.",
|
|
108
109
|
};
|
|
109
110
|
}
|
|
@@ -234,18 +234,19 @@ function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
|
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
236
|
* The raw external-write verb forms that must be blocked when originating from a subagent:
|
|
237
|
-
* ad-hoc GitHub issue/PR creation and
|
|
238
|
-
* wrappers). Each entry is `[subcmd, verb]`.
|
|
237
|
+
* ad-hoc GitHub issue/PR creation, comments, and edits run directly via `gh` (not the sanctioned
|
|
238
|
+
* node wrappers). Each entry is `[subcmd, verb]`.
|
|
239
239
|
*/
|
|
240
240
|
const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
|
|
241
241
|
["issue", "create"],
|
|
242
242
|
["issue", "comment"],
|
|
243
|
+
["issue", "edit"],
|
|
243
244
|
["pr", "comment"],
|
|
244
245
|
]);
|
|
245
246
|
|
|
246
247
|
/**
|
|
247
|
-
* Whether `command` contains a raw `gh issue create`, `gh issue comment`,
|
|
248
|
-
* invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
248
|
+
* Whether `command` contains a raw `gh issue create`, `gh issue comment`, `gh issue edit`, or
|
|
249
|
+
* `gh pr comment` invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
249
250
|
* blocks these when they originate from a subagent context. Node-wrapper commands
|
|
250
251
|
* (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
|
|
251
252
|
* @param {string} command @returns {boolean}
|
|
@@ -255,8 +256,8 @@ export function commandContainsRawExternalWrite(command) {
|
|
|
255
256
|
}
|
|
256
257
|
|
|
257
258
|
/**
|
|
258
|
-
* Return `{ segment, explicitRepo }` for every raw external-write segment across all
|
|
259
|
-
* forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
|
|
259
|
+
* Return `{ segment, explicitRepo }` for every raw external-write segment across all four verb
|
|
260
|
+
* forms (`gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment`). PreToolUse gate use only —
|
|
260
261
|
* lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
|
|
261
262
|
* later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
|
|
262
263
|
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
@@ -280,6 +280,9 @@ function deriveRequiredReads(bundle, resolverOutput) {
|
|
|
280
280
|
* (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
|
|
281
281
|
* model (deriveSpecSource coerces it to null).
|
|
282
282
|
*/
|
|
283
|
+
// Distinct from refinementArtifact.specSource (linked_issue|pr_body|plan_file,
|
|
284
|
+
// REFINEMENT_ARTIFACT_SPEC_SOURCE in packages/core/src/loop/pr-gate-coordination.mjs):
|
|
285
|
+
// same field name, different object, different value space — intentionally separate enums.
|
|
283
286
|
export const CANONICAL_SPEC_SOURCE = Object.freeze({
|
|
284
287
|
PHASE_DOC: "phase_doc",
|
|
285
288
|
PR_BODY: "pr_body",
|
|
@@ -378,6 +378,8 @@ function extractClosingIssueNumbers(body) {
|
|
|
378
378
|
// backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
|
|
379
379
|
// ponytail: not full CommonMark span matching; an unbalanced stray backtick
|
|
380
380
|
// over-strips toward fail-closed, which is the safe direction for this gate.
|
|
381
|
+
// Revisit with a real CommonMark span parser only if valid closing refs in
|
|
382
|
+
// backtick-heavy bodies start being over-stripped into false negatives.
|
|
381
383
|
const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
|
|
382
384
|
const seen = new Set();
|
|
383
385
|
const numbers = [];
|
|
@@ -499,6 +501,41 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
|
|
|
499
501
|
};
|
|
500
502
|
}
|
|
501
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Decide what an enqueue caller should do with a refinement-artifact result,
|
|
506
|
+
* so an un-refined item never lands in the Next Up pickup column in the first
|
|
507
|
+
* place. The draft gate remains the backstop for whatever slips through.
|
|
508
|
+
*
|
|
509
|
+
* Pure decision table, no I/O:
|
|
510
|
+
* - target isn't the pickup column, or the artifact is present → enqueue
|
|
511
|
+
* as requested.
|
|
512
|
+
* - pickup target, artifact missing, interactive caller → block (caller
|
|
513
|
+
* throws; no mutation).
|
|
514
|
+
* - pickup target, artifact missing, headless/auto caller → divert (caller
|
|
515
|
+
* parks the item in the non-pickup column instead of failing the run).
|
|
516
|
+
*
|
|
517
|
+
* @param {{ artifact: ReturnType<typeof detectIssueRefinementArtifact>, targetIsPickup: boolean, auto?: boolean }} input
|
|
518
|
+
* @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
|
|
519
|
+
*/
|
|
520
|
+
export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
|
|
521
|
+
// `artifact.finding === null` is the explicit "has ANY refinement artifact"
|
|
522
|
+
// signal (AC checklist OR DoD checklist OR linked doc) — clearer than reading
|
|
523
|
+
// `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
|
|
524
|
+
if (!targetIsPickup || artifact.finding === null) {
|
|
525
|
+
return { action: "enqueue" };
|
|
526
|
+
}
|
|
527
|
+
const missing = [
|
|
528
|
+
"Acceptance criteria section",
|
|
529
|
+
"Definition of done section",
|
|
530
|
+
"linked refinement doc",
|
|
531
|
+
];
|
|
532
|
+
const reason =
|
|
533
|
+
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
534
|
+
"Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
|
|
535
|
+
"(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
|
|
536
|
+
return { action: auto ? "divert" : "block", reason, missing };
|
|
537
|
+
}
|
|
538
|
+
|
|
502
539
|
/**
|
|
503
540
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
504
541
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -43,6 +43,28 @@ export const PLAN_FILE_PROMOTE_ACTION = Object.freeze({
|
|
|
43
43
|
*/
|
|
44
44
|
export const PLAN_FILE_PR_FRONT_MATTER_KEY = "prNumber";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Build the plan-file promotion marker sentence: the single source of truth
|
|
48
|
+
* for the PR-body text that names the committed plan doc as the spec-of-record.
|
|
49
|
+
* `buildPromotionPrBody` emits it and `PLAN_FILE_PROMOTION_DOC_PATH_PATTERN`
|
|
50
|
+
* (below) parses it back out — keep the two in lockstep.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} docPath repo-relative path of the committed plan doc
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function buildPlanFilePromotionMarker(docPath) {
|
|
56
|
+
return `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Matches the marker sentence `buildPlanFilePromotionMarker` produces and
|
|
61
|
+
* captures the plan doc path. The captured path is bounded to a single line
|
|
62
|
+
* and a `.md` suffix so a multi-line/unbounded body cannot smuggle an
|
|
63
|
+
* oversized or cross-line "path".
|
|
64
|
+
*/
|
|
65
|
+
export const PLAN_FILE_PROMOTION_DOC_PATH_PATTERN =
|
|
66
|
+
/Spec-of-record: the committed plan doc `([^`\n]{1,200}?\.md)`/u;
|
|
67
|
+
|
|
46
68
|
/**
|
|
47
69
|
* Minimal additive front-matter support for plan files (an escalated extension
|
|
48
70
|
* to P1's format): a leading `---\n...\n---\n` block of simple `key: value`
|
|
@@ -219,7 +241,7 @@ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definiti
|
|
|
219
241
|
const safeAc = neutralizeIssueCloseKeywords(ac);
|
|
220
242
|
const safeDod = neutralizeIssueCloseKeywords(dod);
|
|
221
243
|
return [
|
|
222
|
-
|
|
244
|
+
buildPlanFilePromotionMarker(docPath),
|
|
223
245
|
"This PR was opened by PR-FIRST promotion; no tracker issue exists.",
|
|
224
246
|
"",
|
|
225
247
|
"## Acceptance criteria",
|
|
@@ -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
|
}
|
|
@@ -864,7 +882,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
864
882
|
allowedNextActions,
|
|
865
883
|
forbiddenActions,
|
|
866
884
|
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
867
|
-
reason: formatRefinementBlockedReason(refinementLinkedIssue, refinementArtifactStatus),
|
|
885
|
+
reason: formatRefinementBlockedReason(refinementLinkedIssue, refinementArtifactStatus, refinementArtifact),
|
|
868
886
|
mergeStateStatus,
|
|
869
887
|
conflictFiles,
|
|
870
888
|
refinementArtifact,
|
|
@@ -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
|
+
}
|