@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 +7 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/handoff-envelope.mjs +30 -0
- package/src/loop/issue-refinement-artifact.mjs +42 -0
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +20 -2
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/refinement-grill-state.mjs +173 -0
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +348 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +287 -0
- package/src/loop/ui-review-teardown.mjs +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
}
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
|
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 }[]}
|
|
@@ -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
|
-
|
|
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,
|