@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.7.2",
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 comments from a subagent are blocked. Use the sanctioned " +
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, or scripts/github/comment-issue.mjs. " +
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 comments run directly via `gh` (not the sanctioned node
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`, or `gh pr 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 three verb
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
- `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`,
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
- function formatRefinementBlockedReason(linkedIssue, status) {
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
+ }