@dev-loops/core 0.7.2 → 0.9.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.9.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",
@@ -56,6 +57,11 @@
56
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
57
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
58
59
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
60
+ "./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
61
+ "./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
62
+ "./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
63
+ "./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
64
+ "./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
59
65
  "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
60
66
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
61
67
  "./projects/resolve-project": "./src/projects/resolve-project.mjs",
@@ -88,6 +88,28 @@ export function rewriteCliInvocation(body, version) {
88
88
  return String(body).split("node <dev-loops-package-root>/cli/index.mjs").join(`npx dev-loops@${version}`);
89
89
  }
90
90
 
91
+ /**
92
+ * Rewrite repo-root `../docs/…` *inline* markdown links `](../docs/…)` in a generated *command*
93
+ * body so they resolve from the generated file's deeper location. Only the inline `](…)` link form
94
+ * is rewritten (reference-style `[label]: …` and HTML `<a href>` links are left as-is) — command
95
+ * bodies only use inline links, so that is the sole form that occurs. Source commands live at
96
+ * `commands/<name>.command.md`, so `../docs/x` resolves to repo-root `docs/x`; the generated
97
+ * wrapper lives one level deeper at `.claude/commands/<name>.md`, where `../docs/x` would wrongly
98
+ * resolve to `.claude/docs/x` (there is no such dir). Repo-root `docs/` is NOT mirrored into
99
+ * `.claude/`, so the link must gain one `../` to reach repo-root: `../docs/x` → `../../docs/x`.
100
+ *
101
+ * Scoped to `../docs/` on purpose. Other `../…` command links point at subtrees the generator
102
+ * mirrors under `.claude/` (e.g. `../skills/docs/x` → the bundled `.claude/skills/docs/x`), whose
103
+ * relative depth is preserved verbatim — shifting those would break them. Skills need no rewrite
104
+ * at all for the same reason (their `../docs/x` targets the bundled `.claude/skills/docs/x`).
105
+ *
106
+ * @param {string} body
107
+ * @returns {string}
108
+ */
109
+ export function rewriteCommandRepoLinks(body) {
110
+ return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
111
+ }
112
+
91
113
  /**
92
114
  * Map a single Pi tool name to its Claude tool name(s).
93
115
  * @param {string} name
@@ -180,7 +202,7 @@ export function transformAgent({ source, raw, version = "latest" }) {
180
202
  */
181
203
  export function transformCommand({ source, raw, version = "latest" }) {
182
204
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
183
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
205
+ const body = rewriteCommandRepoLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
184
206
 
185
207
  const lines = ["---"];
186
208
  if (frontmatter.description != null) {
@@ -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
  }
@@ -178,6 +178,179 @@ const WorktreeConfig = z.strictObject({
178
178
  linkOnInit: z.array(z.string().trim().min(1)).optional(),
179
179
  });
180
180
 
181
+ /**
182
+ * Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
183
+ * lists pending migrations (one per line); `applyCommand` applies them.
184
+ *
185
+ * Destructive detection is EXPLICIT and status-format-dependent: the
186
+ * `destructivePattern` regex is matched (case-insensitive, per line) against the
187
+ * STATUS OUTPUT — not against the migration files. The shipped default
188
+ * (DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN) assumes SQL-bearing status output
189
+ * (DROP/TRUNCATE/DELETE FROM ...); against a status command that emits migration
190
+ * identifiers or filenames instead, it matches nothing and the destructive guard
191
+ * is inert. A project whose status output is NOT SQL therefore MUST set a
192
+ * `destructivePattern` that matches its own status format (e.g. a `destructive`/
193
+ * `down` marker), or make `statusCommand` emit the destructive SQL/marker — the
194
+ * default cannot detect what its status output never prints.
195
+ */
196
+ const UiReviewMigrateConfig = z.strictObject({
197
+ statusCommand: z.string().trim().min(1),
198
+ applyCommand: z.string().trim().min(1),
199
+ destructivePattern: z
200
+ .string()
201
+ .trim()
202
+ .min(1)
203
+ .refine((p) => {
204
+ try {
205
+ // Validate under the exact flags the runtime compile uses at the
206
+ // destructive-migration safety boundary (inspectMigrations), so a
207
+ // pattern valid bare but invalid under `u` is rejected at load time.
208
+ new RegExp(p, "iu");
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }, "destructivePattern must be a valid regex")
214
+ .optional(),
215
+ });
216
+
217
+ /**
218
+ * Per-project boot recipe: a shell `command` that starts the branch's app and a
219
+ * `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
220
+ * sleep). No app is hard-coded — a project declares its own recipe. `cwd` is an
221
+ * optional worktree-relative subdir to run in.
222
+ */
223
+ const UiReviewRunConfig = z.strictObject({
224
+ command: z.string().trim().min(1),
225
+ readyUrl: z
226
+ .string()
227
+ .trim()
228
+ .url()
229
+ .refine((u) => {
230
+ try {
231
+ const p = new URL(u).protocol;
232
+ return p === "http:" || p === "https:";
233
+ } catch {
234
+ return false;
235
+ }
236
+ }, "readyUrl must be an http(s) URL"),
237
+ readyTimeoutMs: z.number().int().min(1).max(600000).default(60000),
238
+ readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
239
+ cwd: z.string().trim().min(1).optional(),
240
+ migrate: UiReviewMigrateConfig.optional(),
241
+ });
242
+
243
+ /**
244
+ * Per-project dev-login recipe (Stage 2). The drive stage obtains a session for
245
+ * the change's target role by driving this login form in the browser. Nothing
246
+ * is hard-coded here — a project declares its own login URL, field selectors,
247
+ * and the shared dev credential (never a real user secret; a dev-only password
248
+ * or role). `successSelector` is what proves the session was established;
249
+ * without it the drive stage cannot confirm auth and fails closed.
250
+ */
251
+ const UiReviewLoginConfig = z.strictObject({
252
+ loginUrl: z
253
+ .string()
254
+ .trim()
255
+ .url()
256
+ .refine((u) => {
257
+ try {
258
+ const p = new URL(u).protocol;
259
+ return p === "http:" || p === "https:";
260
+ } catch {
261
+ return false;
262
+ }
263
+ }, "loginUrl must be an http(s) URL"),
264
+ usernameSelector: z.string().trim().min(1).optional(),
265
+ usernameValue: z.string().min(1).optional(),
266
+ passwordSelector: z.string().trim().min(1).optional(),
267
+ passwordValue: z.string().min(1).optional(),
268
+ submitSelector: z.string().trim().min(1),
269
+ successSelector: z.string().trim().min(1),
270
+ });
271
+
272
+ /** A config-declared interstitial (cookie consent etc.) dismissed ONCE per
273
+ * browser context. */
274
+ const UiReviewInterstitialConfig = z.strictObject({
275
+ selector: z.string().trim().min(1),
276
+ });
277
+
278
+ /** One driven step. The action set is deliberately small and maps 1:1 to a
279
+ * Playwright page call in the harness — enough to render a page and exercise the
280
+ * create/edit/reorder/upload/toggle interactions plus dispatch a real event. */
281
+ const UiReviewFlowStepConfig = z.strictObject({
282
+ name: z.string().trim().min(1).optional(),
283
+ action: z.enum(["goto", "click", "fill", "select", "upload", "dispatch"]),
284
+ selector: z.string().trim().min(1).optional(),
285
+ path: z.string().trim().min(1).optional(),
286
+ value: z.string().optional(),
287
+ event: z.string().trim().min(1).optional(),
288
+ }).superRefine((step, ctx) => {
289
+ // Every action but `goto` targets an element, so a missing selector is a
290
+ // config error, not a runtime step-failure. (`goto` uses `path`/url.)
291
+ if (step.action !== "goto" && (step.selector == null || step.selector.trim().length === 0)) {
292
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selector"], message: `step action "${step.action}" requires a selector` });
293
+ }
294
+ // Action-specific required fields. Rejecting these at parse time turns a silent
295
+ // wrong drive into a clear config error: a missing `goto.path` would drive "/",
296
+ // and a missing `upload.value` becomes setInputFiles(sel, "") which throws mid
297
+ // walk as a step-failure rather than a config problem.
298
+ if (step.action === "goto" && (step.path == null || step.path.trim().length === 0)) {
299
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["path"], message: `step action "goto" requires a path` });
300
+ }
301
+ if (step.action === "upload" && (step.value == null || step.value.trim().length === 0)) {
302
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: `step action "upload" requires a value (the file path to upload)` });
303
+ }
304
+ });
305
+
306
+ /** An allowlisted changed flow. `pathPatterns` are plain substrings matched
307
+ * against the PR's changed file paths to decide whether the flow is in scope
308
+ * (the bounded changed-flow heuristic); a flow with none is always driven. */
309
+ const UiReviewFlowConfig = z.strictObject({
310
+ name: z.string().trim().min(1),
311
+ pathPatterns: z.array(z.string().trim().min(1)).optional(),
312
+ steps: z.array(UiReviewFlowStepConfig).min(1),
313
+ });
314
+
315
+ /** Bounded drive caps (Stage 2). Every field is optional and clamped to a
316
+ * ceiling at resolve time — a project may only tighten a cap, never loosen it. */
317
+ const UiReviewCapsConfig = z.strictObject({
318
+ maxScreenshots: z.number().int().min(1).optional(),
319
+ maxFlows: z.number().int().min(1).optional(),
320
+ maxStepsPerFlow: z.number().int().min(1).optional(),
321
+ });
322
+
323
+ /**
324
+ * UI-review route config: the generic, per-project provision+boot recipe (Stage
325
+ * 1) plus the drive recipe (Stage 2: login, interstitials, changed-flow
326
+ * allowlist, and an optional server-log path/pattern for tailing). Absent (the
327
+ * default) means no recipe is declared — the corresponding stage stops with that
328
+ * as a stated reason rather than guessing how to run or drive the app.
329
+ */
330
+ const UiReviewConfig = z.strictObject({
331
+ run: UiReviewRunConfig.optional(),
332
+ login: UiReviewLoginConfig.optional(),
333
+ interstitials: z.array(UiReviewInterstitialConfig).optional(),
334
+ flows: z.array(UiReviewFlowConfig).optional(),
335
+ caps: UiReviewCapsConfig.optional(),
336
+ // Filesystem path (worktree-relative or absolute) to the project's server log.
337
+ // The drive stage tails it so a swallowed 500 the UI hid is still recorded.
338
+ serverLogPath: z.string().trim().min(1).optional(),
339
+ serverLogExceptionPattern: z
340
+ .string()
341
+ .trim()
342
+ .min(1)
343
+ .refine((p) => {
344
+ try {
345
+ new RegExp(p, "iu");
346
+ return true;
347
+ } catch {
348
+ return false;
349
+ }
350
+ }, "serverLogExceptionPattern must be a valid regex")
351
+ .optional(),
352
+ });
353
+
181
354
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
182
355
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
183
356
 
@@ -232,6 +405,7 @@ export const DevLoopConfigSchema = z.strictObject({
232
405
  personas: PersonasConfig.optional(),
233
406
  internalPathPatterns: InternalPatternsConfig.optional(),
234
407
  worktree: WorktreeConfig.optional(),
408
+ uiReview: UiReviewConfig.optional(),
235
409
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
236
410
  // still carry a localPlanning block keep parsing. Accepted, never read.
237
411
  localPlanning: z.unknown().optional(),
@@ -304,6 +478,7 @@ export const FileConfigSchema = z.strictObject({
304
478
  personas: FilePersonasConfig.optional(),
305
479
  internalPathPatterns: InternalPatternsConfig.optional(),
306
480
  worktree: WorktreeConfig.partial().optional(),
481
+ uiReview: UiReviewConfig.partial().optional(),
307
482
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
308
483
  // still carry a localPlanning block keep parsing. Accepted, never read.
309
484
  localPlanning: z.unknown().optional(),
@@ -1385,6 +1560,108 @@ export function resolveWorktreeConfig(config) {
1385
1560
  return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1386
1561
  }
1387
1562
 
1563
+ /**
1564
+ * Default destructive-migration signal: SQL statements that drop or wipe data.
1565
+ * Matched (case-insensitive, per line) against the migration STATUS OUTPUT. This
1566
+ * default only detects destructive intent when the status output is itself
1567
+ * SQL-bearing; against status output that lists migration identifiers/filenames
1568
+ * it matches nothing and the guard is inert (no false positives, but also no
1569
+ * protection). Such a project MUST override via
1570
+ * `uiReview.run.migrate.destructivePattern` to match its own status format (or
1571
+ * emit the destructive SQL/marker from `statusCommand`).
1572
+ */
1573
+ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
1574
+ "\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
1575
+
1576
+ /**
1577
+ * Resolve the ui-review provision+boot run recipe from the merged config.
1578
+ *
1579
+ * Returns null when no `uiReview.run.command` is declared — the provision+boot
1580
+ * stage treats that as a stated stop reason (no app is ever guessed). Numeric
1581
+ * probe bounds fall back to sane defaults defensively: zod `.partial()` is
1582
+ * shallow (it does not drop nested numeric defaults), so a schema-validated
1583
+ * config already carries them — the fallback covers programmatically-built
1584
+ * config objects that bypass schema defaulting, not the `.partial()` path.
1585
+ *
1586
+ * @param {DevLoopConfig} config
1587
+ * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
1588
+ * readyIntervalMs: number, cwd: string|null,
1589
+ * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string } }}
1590
+ */
1591
+ export function resolveUiReviewRunRecipe(config) {
1592
+ const run = config?.uiReview?.run;
1593
+ if (!run || typeof run.command !== "string" || run.command.trim().length === 0) return null;
1594
+ if (typeof run.readyUrl !== "string" || run.readyUrl.trim().length === 0) return null;
1595
+ const migrate = run.migrate
1596
+ ? {
1597
+ statusCommand: run.migrate.statusCommand,
1598
+ applyCommand: run.migrate.applyCommand,
1599
+ destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
1600
+ }
1601
+ : null;
1602
+ return {
1603
+ command: run.command.trim(),
1604
+ readyUrl: run.readyUrl.trim(),
1605
+ readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
1606
+ readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
1607
+ cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
1608
+ migrate,
1609
+ };
1610
+ }
1611
+
1612
+ /**
1613
+ * Default server-log exception signal for the drive stage's log tail. Matched
1614
+ * (case-insensitive, per line) against the tailed server-log text. This is a
1615
+ * HEURISTIC default tuned for common framework logs (a 5xx status, an
1616
+ * uncaught/unhandled marker, an exception/traceback). A project whose log format
1617
+ * these miss MUST override `uiReview.serverLogExceptionPattern` to match its own
1618
+ * server log — the default cannot detect what its log never prints.
1619
+ */
1620
+ export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
1621
+ "\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
1622
+
1623
+ /**
1624
+ * Resolve the ui-review drive recipe (Stage 2) from the merged config.
1625
+ *
1626
+ * Returns null when no `uiReview.login` is declared — the drive stage treats
1627
+ * that as a stated stop reason (it cannot authenticate, so it drives nothing).
1628
+ * The server-log exception pattern falls back to the shipped heuristic default
1629
+ * when a `serverLogPath` is set without an explicit pattern.
1630
+ *
1631
+ * @param {DevLoopConfig} config
1632
+ * @returns {null | { login: object, interstitials: object[], flows: object[],
1633
+ * caps: object, serverLogPath: string|null, serverLogExceptionPattern: string }}
1634
+ */
1635
+ export function resolveUiReviewDriveRecipe(config) {
1636
+ const ui = config?.uiReview;
1637
+ const login = ui?.login;
1638
+ if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
1639
+ if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
1640
+ if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
1641
+ const serverLogPath = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
1642
+ return {
1643
+ login: {
1644
+ loginUrl: login.loginUrl.trim(),
1645
+ usernameSelector: login.usernameSelector ?? null,
1646
+ usernameValue: login.usernameValue ?? null,
1647
+ passwordSelector: login.passwordSelector ?? null,
1648
+ passwordValue: login.passwordValue ?? null,
1649
+ submitSelector: login.submitSelector.trim(),
1650
+ successSelector: login.successSelector.trim(),
1651
+ },
1652
+ interstitials: Array.isArray(ui.interstitials)
1653
+ ? ui.interstitials.map((i) => ({ selector: i.selector }))
1654
+ : [],
1655
+ flows: Array.isArray(ui.flows) ? ui.flows : [],
1656
+ caps: ui.caps ?? {},
1657
+ serverLogPath,
1658
+ serverLogExceptionPattern:
1659
+ typeof ui.serverLogExceptionPattern === "string" && ui.serverLogExceptionPattern.trim().length > 0
1660
+ ? ui.serverLogExceptionPattern.trim()
1661
+ : DEFAULT_SERVER_LOG_EXCEPTION_PATTERN,
1662
+ };
1663
+ }
1664
+
1388
1665
  /**
1389
1666
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1390
1667
  *
@@ -396,7 +396,6 @@ personas:
396
396
  The PR body is the implementation contract — it must have:
397
397
  - A Summary section explaining what changed and why
398
398
  - A Scope and context section defining the boundary of the change
399
- - A File-by-file changes section listing every touched file and what changed in it
400
399
  - An Acceptance criteria section with the linked issue acceptance criteria
401
400
  - A Definition of done section
402
401
  - A Non-goals section
@@ -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 }[]}
@@ -44,6 +44,13 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
44
44
  [INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH]: ["merge"],
45
45
  [INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL]: ["merge"],
46
46
  [INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION]: [],
47
+ [INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW]: [
48
+ "no-product-code-writes",
49
+ "worktree-only",
50
+ "outward-review-pending",
51
+ "ack-destructive-migrations",
52
+ "merge",
53
+ ],
47
54
  });
48
55
 
49
56
  // ---------------------------------------------------------------------------
@@ -134,6 +141,26 @@ register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
134
141
  activeNoticeAfterMs: WATCH_ACTIVE_NOTICE_MS,
135
142
  });
136
143
 
144
+ // ui_review — running-app review sibling of reviewer/fixer. Scaffold slice:
145
+ // self-validation only, no drive/report/provision/boot logic. The criteria
146
+ // capture the route-specific review boundaries (no product-code writes,
147
+ // worktree isolation, outward review stays pending/draft, destructive
148
+ // migrations acknowledged before running) so the dispatched agent self-checks
149
+ // them; the generic finalization stop rules (e.g. merge) are layered on
150
+ // separately and are not restated here.
151
+ register(INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW, "default", {
152
+ criteria: [
153
+ { id: "no-product-code-writes", must: "No product code is written; the UI-review route only observes and reports on the running app.", severity: "required" },
154
+ { id: "worktree-only", must: "All work stays inside the isolated worktree; nothing is written outside it.", severity: "required" },
155
+ { id: "outward-review-pending", must: "Any outward review stays pending/draft; no approval or merge is emitted from the UI-review route.", severity: "required" },
156
+ { id: "ack-destructive-migrations", must: "Destructive migrations are explicitly acknowledged before they are run.", severity: "required" },
157
+ ],
158
+ evidence: ["commands-run", "validation-output"],
159
+ maxFinalizationTurns: 4,
160
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
161
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
162
+ });
163
+
137
164
  // Remaining strategies get a generic acceptance template
138
165
  function registerGeneric(strategy) {
139
166
  register(strategy, "default", {
@@ -280,6 +307,9 @@ function deriveRequiredReads(bundle, resolverOutput) {
280
307
  * (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
281
308
  * model (deriveSpecSource coerces it to null).
282
309
  */
310
+ // Distinct from refinementArtifact.specSource (linked_issue|pr_body|plan_file,
311
+ // REFINEMENT_ARTIFACT_SPEC_SOURCE in packages/core/src/loop/pr-gate-coordination.mjs):
312
+ // same field name, different object, different value space — intentionally separate enums.
283
313
  export const CANONICAL_SPEC_SOURCE = Object.freeze({
284
314
  PHASE_DOC: "phase_doc",
285
315
  PR_BODY: "pr_body",
@@ -29,6 +29,15 @@ export const REFINEMENT_SOURCE = Object.freeze({
29
29
 
30
30
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
31
31
 
32
+ // The three artifact sources, any ONE of which satisfies the refinement gate.
33
+ // Single source of truth for the "missing" vocabulary reported when none is
34
+ // present — consumed by the enqueue gate and the parked-unrefined discovery.
35
+ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
36
+ "Acceptance criteria section",
37
+ "Definition of done section",
38
+ "linked refinement doc",
39
+ ]);
40
+
32
41
  /**
33
42
  * Canonical list of section headings that satisfy the refinement check.
34
43
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -378,6 +387,8 @@ function extractClosingIssueNumbers(body) {
378
387
  // backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
379
388
  // ponytail: not full CommonMark span matching; an unbalanced stray backtick
380
389
  // over-strips toward fail-closed, which is the safe direction for this gate.
390
+ // Revisit with a real CommonMark span parser only if valid closing refs in
391
+ // backtick-heavy bodies start being over-stripped into false negatives.
381
392
  const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
382
393
  const seen = new Set();
383
394
  const numbers = [];
@@ -499,6 +510,37 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
499
510
  };
500
511
  }
501
512
 
513
+ /**
514
+ * Decide what an enqueue caller should do with a refinement-artifact result,
515
+ * so an un-refined item never lands in the Next Up pickup column in the first
516
+ * place. The draft gate remains the backstop for whatever slips through.
517
+ *
518
+ * Pure decision table, no I/O:
519
+ * - target isn't the pickup column, or the artifact is present → enqueue
520
+ * as requested.
521
+ * - pickup target, artifact missing, interactive caller → block (caller
522
+ * throws; no mutation).
523
+ * - pickup target, artifact missing, headless/auto caller → divert (caller
524
+ * parks the item in the non-pickup column instead of failing the run).
525
+ *
526
+ * @param {{ artifact: ReturnType<typeof detectIssueRefinementArtifact>, targetIsPickup: boolean, auto?: boolean }} input
527
+ * @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
528
+ */
529
+ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
530
+ // `artifact.finding === null` is the explicit "has ANY refinement artifact"
531
+ // signal (AC checklist OR DoD checklist OR linked doc) — clearer than reading
532
+ // `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
533
+ if (!targetIsPickup || artifact.finding === null) {
534
+ return { action: "enqueue" };
535
+ }
536
+ const missing = [...REFINEMENT_ARTIFACT_SOURCES];
537
+ const reason =
538
+ `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
539
+ "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
540
+ "(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
541
+ return { action: auto ? "divert" : "block", reason, missing };
542
+ }
543
+
502
544
  /**
503
545
  * Map a draft-gate refinement check to the result surface consumed by
504
546
  * `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,