@dev-loops/core 0.8.0 → 1.0.0-rc.1
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 +8 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +43 -4
- package/src/config/config.mjs +454 -5
- package/src/config/extension-defaults.yaml +7 -1
- package/src/debt/shape.mjs +0 -12
- package/src/loop/copilot-loop-state.mjs +38 -6
- package/src/loop/gate-carry-forward.mjs +244 -0
- package/src/loop/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +372 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +289 -0
- package/src/loop/ui-review-teardown.mjs +292 -0
package/src/config/config.mjs
CHANGED
|
@@ -20,11 +20,78 @@ const InputSourceConfig = z.strictObject({
|
|
|
20
20
|
default: z.enum(["tracker", "phase-docs"]),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Built-in tier aliases shipped with zero config. A tier alias maps a
|
|
24
|
+
// harness-neutral name (low/high) to a concrete per-harness model id; `null`
|
|
25
|
+
// means "inherit" (pass no model override → genuine no-op on that harness).
|
|
26
|
+
// Pi ships null on every built-in tier, so zero-config resolution is a no-op on
|
|
27
|
+
// Pi until an operator sets concrete Pi ids.
|
|
28
|
+
export const BUILTIN_TIER_ALIASES = Object.freeze(["low", "high"]);
|
|
29
|
+
|
|
30
|
+
const BUILTIN_TIERS = Object.freeze({
|
|
31
|
+
low: Object.freeze({ claude: "sonnet", pi: null }),
|
|
32
|
+
high: Object.freeze({ claude: "opus", pi: null }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Built-in role→tier policy: routine subagents run on the low tier, planning
|
|
36
|
+
// (refiner) and critical review (review, incl. gate fan-out angles via their
|
|
37
|
+
// review persona) run high, and the conductor (dev-loop) inherits (no override).
|
|
38
|
+
const BUILTIN_ROLE_TIERS = Object.freeze({
|
|
39
|
+
developer: "low",
|
|
40
|
+
docs: "low",
|
|
41
|
+
fixer: "low",
|
|
42
|
+
quality: "low",
|
|
43
|
+
refiner: "high",
|
|
44
|
+
review: "high",
|
|
45
|
+
"dev-loop": "inherit",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A tier alias's per-harness concrete model. Either harness may be a concrete
|
|
49
|
+
// model id or `null` (inherit / no-op on that harness). strictObject rejects
|
|
50
|
+
// unknown harness keys.
|
|
51
|
+
const ModelTierMapping = z
|
|
52
|
+
.strictObject({
|
|
53
|
+
claude: z.string().trim().min(1).nullable().optional(),
|
|
54
|
+
pi: z.string().trim().min(1).nullable().optional(),
|
|
55
|
+
})
|
|
56
|
+
// A tier mapping with both harnesses absent/null resolves to a null no-op on
|
|
57
|
+
// every harness — a silent dead alias that roleTiers could reference. Require
|
|
58
|
+
// at least one concrete harness model so an empty/all-null tier fails closed.
|
|
59
|
+
.refine((m) => typeof m.claude === "string" || typeof m.pi === "string", {
|
|
60
|
+
message: "tier mapping must set at least one of claude/pi to a non-null model id",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reject `models.roleTiers` entries that reference a tier alias which is neither
|
|
65
|
+
* a built-in alias (low/high), the literal "inherit", nor defined in this
|
|
66
|
+
* config's own `models.tiers`. Applied to both the merged and file-level
|
|
67
|
+
* ModelsConfig so a typo'd alias fails closed with a clear message.
|
|
68
|
+
* @param {Record<string, unknown>|undefined} models
|
|
69
|
+
* @param {z.RefinementCtx} ctx
|
|
70
|
+
*/
|
|
71
|
+
function refineRoleTiers(models, ctx) {
|
|
72
|
+
const known = new Set([...BUILTIN_TIER_ALIASES, ...Object.keys(models?.tiers ?? {})]);
|
|
73
|
+
for (const [role, tier] of Object.entries(models?.roleTiers ?? {})) {
|
|
74
|
+
if (tier !== "inherit" && !known.has(tier)) {
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: z.ZodIssueCode.custom,
|
|
77
|
+
path: ["roleTiers", role],
|
|
78
|
+
message: `unknown model tier alias "${tier}" — define it under models.tiers, use a built-in alias (${BUILTIN_TIER_ALIASES.join(", ")}), or "inherit"`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ModelsConfigBase = z.strictObject({
|
|
24
85
|
conductor: z.string().trim().min(1).optional(),
|
|
25
86
|
roles: z.record(z.string(), z.string().trim().min(1)).optional(),
|
|
87
|
+
// Tier alias → per-harness concrete model (null = inherit / no-op).
|
|
88
|
+
tiers: z.record(z.string().min(1), ModelTierMapping).optional(),
|
|
89
|
+
// Role / angle → tier alias (a built-in/custom alias or "inherit").
|
|
90
|
+
roleTiers: z.record(z.string().min(1), z.string().trim().min(1)).optional(),
|
|
26
91
|
});
|
|
27
92
|
|
|
93
|
+
const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
|
|
94
|
+
|
|
28
95
|
const RefinementConfig = z.strictObject({
|
|
29
96
|
fanOut: z.number().int().min(1).max(10),
|
|
30
97
|
mode: z.enum(["parallel", "sequential"]),
|
|
@@ -57,8 +124,11 @@ const GateConfig = z.strictObject({
|
|
|
57
124
|
|
|
58
125
|
const GatesConfig = z.strictObject({
|
|
59
126
|
draft: GateConfig.optional(),
|
|
60
|
-
// `requireCi` is
|
|
61
|
-
//
|
|
127
|
+
// `requireCi` is honored on both gates: default true keeps CI a precondition,
|
|
128
|
+
// false is an opt-out escape hatch so a repo with no CI is not held at the
|
|
129
|
+
// gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
|
|
130
|
+
// when false the CI verdict is ignored entirely at that boundary, including a
|
|
131
|
+
// real failure (not merely "green optional").
|
|
62
132
|
preApproval: GateConfig.optional(),
|
|
63
133
|
// Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
64
134
|
// not production code, so it should not carry the full draft → pre-approval →
|
|
@@ -178,6 +248,198 @@ const WorktreeConfig = z.strictObject({
|
|
|
178
248
|
linkOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
179
249
|
});
|
|
180
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
|
|
253
|
+
* lists pending migrations (one per line); `applyCommand` applies them.
|
|
254
|
+
*
|
|
255
|
+
* Destructive detection is EXPLICIT and status-format-dependent: the
|
|
256
|
+
* `destructivePattern` regex is matched (case-insensitive, per line) against the
|
|
257
|
+
* STATUS OUTPUT — not against the migration files. The shipped default
|
|
258
|
+
* (DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN) assumes SQL-bearing status output
|
|
259
|
+
* (DROP/TRUNCATE/DELETE FROM ...); against a status command that emits migration
|
|
260
|
+
* identifiers or filenames instead, it matches nothing and the destructive guard
|
|
261
|
+
* is inert. A project whose status output is NOT SQL therefore MUST set a
|
|
262
|
+
* `destructivePattern` that matches its own status format (e.g. a `destructive`/
|
|
263
|
+
* `down` marker), or make `statusCommand` emit the destructive SQL/marker — the
|
|
264
|
+
* default cannot detect what its status output never prints.
|
|
265
|
+
*/
|
|
266
|
+
const UiReviewMigrateConfig = z.strictObject({
|
|
267
|
+
statusCommand: z.string().trim().min(1),
|
|
268
|
+
applyCommand: z.string().trim().min(1),
|
|
269
|
+
destructivePattern: z
|
|
270
|
+
.string()
|
|
271
|
+
.trim()
|
|
272
|
+
.min(1)
|
|
273
|
+
.refine((p) => {
|
|
274
|
+
try {
|
|
275
|
+
// Validate under the exact flags the runtime compile uses at the
|
|
276
|
+
// destructive-migration safety boundary (inspectMigrations), so a
|
|
277
|
+
// pattern valid bare but invalid under `u` is rejected at load time.
|
|
278
|
+
new RegExp(p, "iu");
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}, "destructivePattern must be a valid regex")
|
|
284
|
+
.optional(),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Per-project dev-DB row-teardown recipe (Stage 5). The drive stamps each
|
|
289
|
+
* mutating step it drives with a drive-session id (advertised to the app on the
|
|
290
|
+
* DRIVE_SESSION_HEADER request header); this `deleteCommand` deletes exactly the
|
|
291
|
+
* rows the app tagged with that session — the id is passed in the
|
|
292
|
+
* UI_REVIEW_DRIVE_SESSION env var and the command runs in the provisioned
|
|
293
|
+
* worktree (dev DB only). Teardown runs it only on explicit confirmation.
|
|
294
|
+
*/
|
|
295
|
+
const UiReviewRowTeardownConfig = z.strictObject({
|
|
296
|
+
deleteCommand: z.string().trim().min(1),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Per-project boot recipe: a shell `command` that starts the branch's app and a
|
|
301
|
+
* `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
|
|
302
|
+
* sleep). No app is hard-coded — a project declares its own recipe. `cwd` is an
|
|
303
|
+
* optional worktree-relative subdir to run in.
|
|
304
|
+
*/
|
|
305
|
+
const UiReviewRunConfig = z.strictObject({
|
|
306
|
+
command: z.string().trim().min(1),
|
|
307
|
+
readyUrl: z
|
|
308
|
+
.string()
|
|
309
|
+
.trim()
|
|
310
|
+
.url()
|
|
311
|
+
.refine((u) => {
|
|
312
|
+
try {
|
|
313
|
+
const p = new URL(u).protocol;
|
|
314
|
+
return p === "http:" || p === "https:";
|
|
315
|
+
} catch {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}, "readyUrl must be an http(s) URL"),
|
|
319
|
+
readyTimeoutMs: z.number().int().min(1).max(600000).default(60000),
|
|
320
|
+
readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
|
|
321
|
+
cwd: z.string().trim().min(1).optional(),
|
|
322
|
+
migrate: UiReviewMigrateConfig.optional(),
|
|
323
|
+
rowTeardown: UiReviewRowTeardownConfig.optional(),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Per-project dev-login recipe (Stage 2). The drive stage obtains a session for
|
|
328
|
+
* the change's target role by driving this login form in the browser. Nothing
|
|
329
|
+
* is hard-coded here — a project declares its own login URL, field selectors,
|
|
330
|
+
* and the shared dev credential (never a real user secret; a dev-only password
|
|
331
|
+
* or role). `successSelector` is what proves the session was established;
|
|
332
|
+
* without it the drive stage cannot confirm auth and fails closed.
|
|
333
|
+
*/
|
|
334
|
+
const UiReviewLoginConfig = z.strictObject({
|
|
335
|
+
loginUrl: z
|
|
336
|
+
.string()
|
|
337
|
+
.trim()
|
|
338
|
+
.url()
|
|
339
|
+
.refine((u) => {
|
|
340
|
+
try {
|
|
341
|
+
const p = new URL(u).protocol;
|
|
342
|
+
return p === "http:" || p === "https:";
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}, "loginUrl must be an http(s) URL"),
|
|
347
|
+
usernameSelector: z.string().trim().min(1).optional(),
|
|
348
|
+
usernameValue: z.string().min(1).optional(),
|
|
349
|
+
passwordSelector: z.string().trim().min(1).optional(),
|
|
350
|
+
passwordValue: z.string().min(1).optional(),
|
|
351
|
+
submitSelector: z.string().trim().min(1),
|
|
352
|
+
successSelector: z.string().trim().min(1),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
/** A config-declared interstitial (cookie consent etc.) dismissed ONCE per
|
|
356
|
+
* browser context. */
|
|
357
|
+
const UiReviewInterstitialConfig = z.strictObject({
|
|
358
|
+
selector: z.string().trim().min(1),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/** One driven step. The action set is deliberately small and maps 1:1 to a
|
|
362
|
+
* Playwright page call in the harness — enough to render a page and exercise the
|
|
363
|
+
* create/edit/reorder/upload/toggle interactions plus dispatch a real event. */
|
|
364
|
+
const UiReviewFlowStepConfig = z.strictObject({
|
|
365
|
+
name: z.string().trim().min(1).optional(),
|
|
366
|
+
action: z.enum(["goto", "click", "fill", "select", "upload", "dispatch"]),
|
|
367
|
+
selector: z.string().trim().min(1).optional(),
|
|
368
|
+
path: z.string().trim().min(1).optional(),
|
|
369
|
+
value: z.string().optional(),
|
|
370
|
+
event: z.string().trim().min(1).optional(),
|
|
371
|
+
// Responsive/stateful captures: a declared viewport resizes the page before the
|
|
372
|
+
// step and bakes into the named-state slug, so the mobile vs desktop (or
|
|
373
|
+
// default vs error) render lands in a distinct reviewable directory. The route
|
|
374
|
+
// NAMES its interaction states — the drive never enumerates them itself.
|
|
375
|
+
viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
|
|
376
|
+
interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
|
|
377
|
+
}).superRefine((step, ctx) => {
|
|
378
|
+
// Every action but `goto` targets an element, so a missing selector is a
|
|
379
|
+
// config error, not a runtime step-failure. (`goto` uses `path`/url.)
|
|
380
|
+
if (step.action !== "goto" && (step.selector == null || step.selector.trim().length === 0)) {
|
|
381
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selector"], message: `step action "${step.action}" requires a selector` });
|
|
382
|
+
}
|
|
383
|
+
// Action-specific required fields. Rejecting these at parse time turns a silent
|
|
384
|
+
// wrong drive into a clear config error: a missing `goto.path` would drive "/",
|
|
385
|
+
// and a missing `upload.value` becomes setInputFiles(sel, "") which throws mid
|
|
386
|
+
// walk as a step-failure rather than a config problem.
|
|
387
|
+
if (step.action === "goto" && (step.path == null || step.path.trim().length === 0)) {
|
|
388
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["path"], message: `step action "goto" requires a path` });
|
|
389
|
+
}
|
|
390
|
+
if (step.action === "upload" && (step.value == null || step.value.trim().length === 0)) {
|
|
391
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: `step action "upload" requires a value (the file path to upload)` });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
/** An allowlisted changed flow. `pathPatterns` are plain substrings matched
|
|
396
|
+
* against the PR's changed file paths to decide whether the flow is in scope
|
|
397
|
+
* (the bounded changed-flow heuristic); a flow with none is always driven. */
|
|
398
|
+
const UiReviewFlowConfig = z.strictObject({
|
|
399
|
+
name: z.string().trim().min(1),
|
|
400
|
+
pathPatterns: z.array(z.string().trim().min(1)).optional(),
|
|
401
|
+
steps: z.array(UiReviewFlowStepConfig).min(1),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/** Bounded drive caps (Stage 2). Every field is optional and clamped to a
|
|
405
|
+
* ceiling at resolve time — a project may only tighten a cap, never loosen it. */
|
|
406
|
+
const UiReviewCapsConfig = z.strictObject({
|
|
407
|
+
maxScreenshots: z.number().int().min(1).optional(),
|
|
408
|
+
maxFlows: z.number().int().min(1).optional(),
|
|
409
|
+
maxStepsPerFlow: z.number().int().min(1).optional(),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* UI-review route config: the generic, per-project provision+boot recipe (Stage
|
|
414
|
+
* 1) plus the drive recipe (Stage 2: login, interstitials, changed-flow
|
|
415
|
+
* allowlist, and an optional server-log path/pattern for tailing). Absent (the
|
|
416
|
+
* default) means no recipe is declared — the corresponding stage stops with that
|
|
417
|
+
* as a stated reason rather than guessing how to run or drive the app.
|
|
418
|
+
*/
|
|
419
|
+
const UiReviewConfig = z.strictObject({
|
|
420
|
+
run: UiReviewRunConfig.optional(),
|
|
421
|
+
login: UiReviewLoginConfig.optional(),
|
|
422
|
+
interstitials: z.array(UiReviewInterstitialConfig).optional(),
|
|
423
|
+
flows: z.array(UiReviewFlowConfig).optional(),
|
|
424
|
+
caps: UiReviewCapsConfig.optional(),
|
|
425
|
+
// Filesystem path (worktree-relative or absolute) to the project's server log.
|
|
426
|
+
// The drive stage tails it so a swallowed 500 the UI hid is still recorded.
|
|
427
|
+
serverLogPath: z.string().trim().min(1).optional(),
|
|
428
|
+
serverLogExceptionPattern: z
|
|
429
|
+
.string()
|
|
430
|
+
.trim()
|
|
431
|
+
.min(1)
|
|
432
|
+
.refine((p) => {
|
|
433
|
+
try {
|
|
434
|
+
new RegExp(p, "iu");
|
|
435
|
+
return true;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}, "serverLogExceptionPattern must be a valid regex")
|
|
440
|
+
.optional(),
|
|
441
|
+
});
|
|
442
|
+
|
|
181
443
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
182
444
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
183
445
|
|
|
@@ -232,6 +494,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
232
494
|
personas: PersonasConfig.optional(),
|
|
233
495
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
234
496
|
worktree: WorktreeConfig.optional(),
|
|
497
|
+
uiReview: UiReviewConfig.optional(),
|
|
235
498
|
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
236
499
|
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
237
500
|
localPlanning: z.unknown().optional(),
|
|
@@ -293,7 +556,7 @@ export const FileConfigSchema = z.strictObject({
|
|
|
293
556
|
version: z.literal(1),
|
|
294
557
|
strategy: StrategyConfig.partial().optional(),
|
|
295
558
|
inputSource: InputSourceConfig.partial().optional(),
|
|
296
|
-
models:
|
|
559
|
+
models: ModelsConfigBase.partial().superRefine(refineRoleTiers).optional(),
|
|
297
560
|
refinement: RefinementConfig.partial().optional(),
|
|
298
561
|
gates: FileGatesConfig.optional(),
|
|
299
562
|
autonomy: AutonomyConfig.partial().optional(),
|
|
@@ -304,6 +567,7 @@ export const FileConfigSchema = z.strictObject({
|
|
|
304
567
|
personas: FilePersonasConfig.optional(),
|
|
305
568
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
306
569
|
worktree: WorktreeConfig.partial().optional(),
|
|
570
|
+
uiReview: UiReviewConfig.partial().optional(),
|
|
307
571
|
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
308
572
|
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
309
573
|
localPlanning: z.unknown().optional(),
|
|
@@ -341,6 +605,7 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
341
605
|
yagni: { persona: "review", defaultModel: null },
|
|
342
606
|
"contract-surface": { persona: "review", defaultModel: null },
|
|
343
607
|
"input-validation": { persona: "review", defaultModel: null },
|
|
608
|
+
"threat-model": { persona: "review", defaultModel: null },
|
|
344
609
|
"packaging-runtime": { persona: "review", defaultModel: null },
|
|
345
610
|
"state-concurrency": { persona: "review", defaultModel: null },
|
|
346
611
|
"renderer-security": { persona: "review", defaultModel: null },
|
|
@@ -412,6 +677,76 @@ export function resolveReviewerRole(config, angle) {
|
|
|
412
677
|
};
|
|
413
678
|
}
|
|
414
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Resolve the concrete model for a subagent role/angle on a given harness, or
|
|
682
|
+
* `null` (inherit → pass no model override).
|
|
683
|
+
*
|
|
684
|
+
* Precedence:
|
|
685
|
+
* 1. `models.roles[role]` — concrete per-role/angle override (highest).
|
|
686
|
+
* 2. Tier alias, mapped through `models.tiers[tier][harness]` (or built-in
|
|
687
|
+
* tiers); `inherit`/absent/null → `null`. The alias depends on `kind`:
|
|
688
|
+
* - `kind: "angle"` (gate review dispatch): an explicit
|
|
689
|
+
* `models.roleTiers[role]` override, else the `review` tier. A gate
|
|
690
|
+
* review runs at review quality even when the angle's name collides with
|
|
691
|
+
* a routine role — e.g. the `docs` angle resolves via the `review` tier
|
|
692
|
+
* (high), not the `docs` writer role's low tier. (Its persona/agent still
|
|
693
|
+
* comes from `resolveReviewerRole`; only the tier is forced to review.)
|
|
694
|
+
* - `kind: "role"`/absent (routine subagent): `models.roleTiers[role]` (or
|
|
695
|
+
* the built-in role tier), else — when the name is not a named role — the
|
|
696
|
+
* tier for its review persona (so a non-colliding gate angle passed
|
|
697
|
+
* without `kind` still resolves high via `review`).
|
|
698
|
+
*
|
|
699
|
+
* Callers dispatching a gate review angle whose name may collide with a routine
|
|
700
|
+
* role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
|
|
701
|
+
* downgrade; role dispatch leaves `kind` unset.
|
|
702
|
+
*
|
|
703
|
+
* Zero-config is a genuine no-op on Pi (built-in tiers are null for pi) and
|
|
704
|
+
* reproduces the standing policy on Claude (routine=low, refiner/review=high,
|
|
705
|
+
* dev-loop=inherit).
|
|
706
|
+
*
|
|
707
|
+
* @param {DevLoopConfig} config
|
|
708
|
+
* @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
|
|
709
|
+
* @returns {string|null}
|
|
710
|
+
*/
|
|
711
|
+
export function resolveRoleModel(config, { role, harness, kind } = {}) {
|
|
712
|
+
if (!role || (harness !== "claude" && harness !== "pi")) return null;
|
|
713
|
+
|
|
714
|
+
// 1. Concrete per-role/angle override wins outright (over any tier).
|
|
715
|
+
const concrete = config?.models?.roles?.[role];
|
|
716
|
+
if (typeof concrete === "string" && concrete.trim().length > 0) {
|
|
717
|
+
return concrete.trim();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// 2. Resolve a tier alias for this role/angle.
|
|
721
|
+
const roleTiers = { ...BUILTIN_ROLE_TIERS, ...(config?.models?.roleTiers ?? {}) };
|
|
722
|
+
let tierAlias;
|
|
723
|
+
if (kind === "angle") {
|
|
724
|
+
// Gate review angle: an explicit per-angle override wins, else the review
|
|
725
|
+
// tier — a gate review is review-quality regardless of a coincidental
|
|
726
|
+
// routine-role persona name (the `docs` angle must not inherit `docs`→low).
|
|
727
|
+
tierAlias = config?.models?.roleTiers?.[role] ?? roleTiers.review;
|
|
728
|
+
} else {
|
|
729
|
+
tierAlias = roleTiers[role];
|
|
730
|
+
if (tierAlias === undefined) {
|
|
731
|
+
// Not a named role — treat as a gate angle and inherit its review
|
|
732
|
+
// persona's tier (critical angles resolve high via the `review` persona).
|
|
733
|
+
const { persona } = resolveReviewerRole(config, role);
|
|
734
|
+
tierAlias = roleTiers[persona];
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!tierAlias || tierAlias === "inherit") return null;
|
|
738
|
+
|
|
739
|
+
// Deep-merge the alias mapping so a partial override (e.g. `{ pi: "..." }`,
|
|
740
|
+
// which the schema allows) preserves the untouched built-in harness key rather
|
|
741
|
+
// than erasing the whole {claude,pi} mapping and resolving null for that harness.
|
|
742
|
+
const builtinMapping = BUILTIN_TIERS[tierAlias];
|
|
743
|
+
const configMapping = config?.models?.tiers?.[tierAlias];
|
|
744
|
+
if (!builtinMapping && !configMapping) return null;
|
|
745
|
+
const mapping = { ...builtinMapping, ...configMapping };
|
|
746
|
+
const model = mapping[harness];
|
|
747
|
+
return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
|
|
748
|
+
}
|
|
749
|
+
|
|
415
750
|
// ============================================================================
|
|
416
751
|
// Error types
|
|
417
752
|
// ============================================================================
|
|
@@ -952,7 +1287,13 @@ export function resolveRefinement(config) {
|
|
|
952
1287
|
const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
|
|
953
1288
|
const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
|
|
954
1289
|
const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
|
|
955
|
-
|
|
1290
|
+
// #1337: centralize the pre-approval CI opt-out here so every caller that
|
|
1291
|
+
// builds its interpreter refinement config from `resolveRefinement(config)`
|
|
1292
|
+
// (detect-copilot-loop-state, copilot-pr-handoff, gate coordination, etc.)
|
|
1293
|
+
// reliably honors `gates.preApproval.requireCi: false` — otherwise a CI-less
|
|
1294
|
+
// repo would still be interpreted as waiting_for_ci / blocked in those tools.
|
|
1295
|
+
const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
|
|
1296
|
+
return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
|
|
956
1297
|
}
|
|
957
1298
|
|
|
958
1299
|
/**
|
|
@@ -1385,6 +1726,114 @@ export function resolveWorktreeConfig(config) {
|
|
|
1385
1726
|
return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
|
|
1386
1727
|
}
|
|
1387
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* Default destructive-migration signal: SQL statements that drop or wipe data.
|
|
1731
|
+
* Matched (case-insensitive, per line) against the migration STATUS OUTPUT. This
|
|
1732
|
+
* default only detects destructive intent when the status output is itself
|
|
1733
|
+
* SQL-bearing; against status output that lists migration identifiers/filenames
|
|
1734
|
+
* it matches nothing and the guard is inert (no false positives, but also no
|
|
1735
|
+
* protection). Such a project MUST override via
|
|
1736
|
+
* `uiReview.run.migrate.destructivePattern` to match its own status format (or
|
|
1737
|
+
* emit the destructive SQL/marker from `statusCommand`).
|
|
1738
|
+
*/
|
|
1739
|
+
export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
|
|
1740
|
+
"\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Resolve the ui-review provision+boot run recipe from the merged config.
|
|
1744
|
+
*
|
|
1745
|
+
* Returns null when no `uiReview.run.command` is declared — the provision+boot
|
|
1746
|
+
* stage treats that as a stated stop reason (no app is ever guessed). Numeric
|
|
1747
|
+
* probe bounds fall back to sane defaults defensively: zod `.partial()` is
|
|
1748
|
+
* shallow (it does not drop nested numeric defaults), so a schema-validated
|
|
1749
|
+
* config already carries them — the fallback covers programmatically-built
|
|
1750
|
+
* config objects that bypass schema defaulting, not the `.partial()` path.
|
|
1751
|
+
*
|
|
1752
|
+
* @param {DevLoopConfig} config
|
|
1753
|
+
* @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
|
|
1754
|
+
* readyIntervalMs: number, cwd: string|null,
|
|
1755
|
+
* migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string },
|
|
1756
|
+
* rowTeardown: null | { deleteCommand: string } }}
|
|
1757
|
+
*/
|
|
1758
|
+
export function resolveUiReviewRunRecipe(config) {
|
|
1759
|
+
const run = config?.uiReview?.run;
|
|
1760
|
+
if (!run || typeof run.command !== "string" || run.command.trim().length === 0) return null;
|
|
1761
|
+
if (typeof run.readyUrl !== "string" || run.readyUrl.trim().length === 0) return null;
|
|
1762
|
+
const migrate = run.migrate
|
|
1763
|
+
? {
|
|
1764
|
+
statusCommand: run.migrate.statusCommand,
|
|
1765
|
+
applyCommand: run.migrate.applyCommand,
|
|
1766
|
+
destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
|
|
1767
|
+
}
|
|
1768
|
+
: null;
|
|
1769
|
+
const rowTeardown =
|
|
1770
|
+
run.rowTeardown && typeof run.rowTeardown.deleteCommand === "string" && run.rowTeardown.deleteCommand.trim().length > 0
|
|
1771
|
+
? { deleteCommand: run.rowTeardown.deleteCommand.trim() }
|
|
1772
|
+
: null;
|
|
1773
|
+
return {
|
|
1774
|
+
command: run.command.trim(),
|
|
1775
|
+
readyUrl: run.readyUrl.trim(),
|
|
1776
|
+
readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
|
|
1777
|
+
readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
|
|
1778
|
+
cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
|
|
1779
|
+
migrate,
|
|
1780
|
+
rowTeardown,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Default server-log exception signal for the drive stage's log tail. Matched
|
|
1786
|
+
* (case-insensitive, per line) against the tailed server-log text. This is a
|
|
1787
|
+
* HEURISTIC default tuned for common framework logs (a 5xx status, an
|
|
1788
|
+
* uncaught/unhandled marker, an exception/traceback). A project whose log format
|
|
1789
|
+
* these miss MUST override `uiReview.serverLogExceptionPattern` to match its own
|
|
1790
|
+
* server log — the default cannot detect what its log never prints.
|
|
1791
|
+
*/
|
|
1792
|
+
export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
|
|
1793
|
+
"\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Resolve the ui-review drive recipe (Stage 2) from the merged config.
|
|
1797
|
+
*
|
|
1798
|
+
* Returns null when no `uiReview.login` is declared — the drive stage treats
|
|
1799
|
+
* that as a stated stop reason (it cannot authenticate, so it drives nothing).
|
|
1800
|
+
* The server-log exception pattern falls back to the shipped heuristic default
|
|
1801
|
+
* when a `serverLogPath` is set without an explicit pattern.
|
|
1802
|
+
*
|
|
1803
|
+
* @param {DevLoopConfig} config
|
|
1804
|
+
* @returns {null | { login: object, interstitials: object[], flows: object[],
|
|
1805
|
+
* caps: object, serverLogPath: string|null, serverLogExceptionPattern: string }}
|
|
1806
|
+
*/
|
|
1807
|
+
export function resolveUiReviewDriveRecipe(config) {
|
|
1808
|
+
const ui = config?.uiReview;
|
|
1809
|
+
const login = ui?.login;
|
|
1810
|
+
if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
|
|
1811
|
+
if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
|
|
1812
|
+
if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
|
|
1813
|
+
const serverLogPath = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
|
|
1814
|
+
return {
|
|
1815
|
+
login: {
|
|
1816
|
+
loginUrl: login.loginUrl.trim(),
|
|
1817
|
+
usernameSelector: login.usernameSelector ?? null,
|
|
1818
|
+
usernameValue: login.usernameValue ?? null,
|
|
1819
|
+
passwordSelector: login.passwordSelector ?? null,
|
|
1820
|
+
passwordValue: login.passwordValue ?? null,
|
|
1821
|
+
submitSelector: login.submitSelector.trim(),
|
|
1822
|
+
successSelector: login.successSelector.trim(),
|
|
1823
|
+
},
|
|
1824
|
+
interstitials: Array.isArray(ui.interstitials)
|
|
1825
|
+
? ui.interstitials.map((i) => ({ selector: i.selector }))
|
|
1826
|
+
: [],
|
|
1827
|
+
flows: Array.isArray(ui.flows) ? ui.flows : [],
|
|
1828
|
+
caps: ui.caps ?? {},
|
|
1829
|
+
serverLogPath,
|
|
1830
|
+
serverLogExceptionPattern:
|
|
1831
|
+
typeof ui.serverLogExceptionPattern === "string" && ui.serverLogExceptionPattern.trim().length > 0
|
|
1832
|
+
? ui.serverLogExceptionPattern.trim()
|
|
1833
|
+
: DEFAULT_SERVER_LOG_EXCEPTION_PATTERN,
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1388
1837
|
/**
|
|
1389
1838
|
* Resolve the human-handoff config from the merged dev-loop config (#920).
|
|
1390
1839
|
*
|
|
@@ -39,6 +39,7 @@ gates:
|
|
|
39
39
|
- gate-evidence
|
|
40
40
|
- no-op
|
|
41
41
|
- input-validation
|
|
42
|
+
- threat-model
|
|
42
43
|
- packaging-runtime
|
|
43
44
|
- state-concurrency
|
|
44
45
|
- renderer-security
|
|
@@ -352,6 +353,12 @@ personas:
|
|
|
352
353
|
Review this change for renderer security. Check HTML text escaping, URL encoding, attribute encoding, JSON/script embedding, and rendering of user-controlled content. Treat titles, names, URLs, statuses, errors, and external payload fields as untrusted. Flag raw interpolation into HTML or attributes and tests that expect unsafe output.
|
|
353
354
|
defaultModel: null
|
|
354
355
|
|
|
356
|
+
threat-model:
|
|
357
|
+
persona: review
|
|
358
|
+
prompt: >-
|
|
359
|
+
Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it.
|
|
360
|
+
defaultModel: null
|
|
361
|
+
|
|
355
362
|
determinism:
|
|
356
363
|
persona: review
|
|
357
364
|
prompt: >-
|
|
@@ -396,7 +403,6 @@ personas:
|
|
|
396
403
|
The PR body is the implementation contract — it must have:
|
|
397
404
|
- A Summary section explaining what changed and why
|
|
398
405
|
- 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
406
|
- An Acceptance criteria section with the linked issue acceptance criteria
|
|
401
407
|
- A Definition of done section
|
|
402
408
|
- A Non-goals section
|
package/src/debt/shape.mjs
CHANGED
|
@@ -200,15 +200,3 @@ export function shapeFindings(findings) {
|
|
|
200
200
|
return { outcome, artifact, findingId: f.id };
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Run the full pipeline: cluster → score → shape, return shaped artifacts.
|
|
206
|
-
*
|
|
207
|
-
* @param {Array<object>} signals — debt_signal-compatible array
|
|
208
|
-
* @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
|
|
209
|
-
*/
|
|
210
|
-
export async function runPipeline(signals) {
|
|
211
|
-
const { clusterSignalsEnriched } = await import("./cluster.mjs");
|
|
212
|
-
const findings = clusterSignalsEnriched(signals);
|
|
213
|
-
return shapeFindings(findings);
|
|
214
|
-
}
|
|
@@ -136,7 +136,25 @@ export const NEXT_ACTIONS = Object.freeze({
|
|
|
136
136
|
|
|
137
137
|
const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Canonical snapshot request-status enum (single source of truth). Any request
|
|
141
|
+
* outcome plumbed into the shared loop contract MUST normalize to one of these;
|
|
142
|
+
* richer request-tool outcomes (round_cap_reached, suppressed_*, etc.) collapse
|
|
143
|
+
* to "none" here because they mean "no active Copilot request is in flight".
|
|
144
|
+
*/
|
|
145
|
+
export const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collapse an arbitrary request-tool outcome to the canonical snapshot
|
|
149
|
+
* request-status enum. Unrecognized statuses (round cap / suppression
|
|
150
|
+
* diagnostics) map to "none" so they never leak into the shared contract.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|undefined} status
|
|
153
|
+
* @returns {string} a member of VALID_REVIEW_REQUEST_STATUSES
|
|
154
|
+
*/
|
|
155
|
+
export function toSharedRequestStatus(status) {
|
|
156
|
+
return VALID_REVIEW_REQUEST_STATUSES.has(status) ? status : "none";
|
|
157
|
+
}
|
|
140
158
|
const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
|
|
141
159
|
const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
142
160
|
|
|
@@ -341,6 +359,9 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
|
341
359
|
* @param {number} [refinementConfig.lowSignalRoundThreshold]
|
|
342
360
|
* @param {number} [refinementConfig.lowSignalMaxComments]
|
|
343
361
|
* @param {number} [refinementConfig.maxCopilotRounds]
|
|
362
|
+
* @param {boolean} [refinementConfig.preApprovalRequireCi] - #1337: default true. When false,
|
|
363
|
+
* the pre-approval CI precondition is opted out, so a non-draft PR with a pending/none/failure
|
|
364
|
+
* CI verdict is not routed to waiting_for_ci / blocked_needs_user_decision (it is past the draft gate).
|
|
344
365
|
* @returns {{
|
|
345
366
|
* state: string,
|
|
346
367
|
* allowedTransitions: string[],
|
|
@@ -353,6 +374,17 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
|
353
374
|
export function interpretLoopState(snapshot, refinementConfig) {
|
|
354
375
|
const s = normalizeSnapshot(snapshot);
|
|
355
376
|
|
|
377
|
+
// Pre-approval CI opt-out (#1337): when `gates.preApproval.requireCi` is false,
|
|
378
|
+
// the CI verdict must not gate progression at the pre-approval boundary. A
|
|
379
|
+
// non-draft PR is past the draft gate, so this is the applicable knob — treat
|
|
380
|
+
// pending/none/failure CI as non-blocking here so a repo with no CI is not
|
|
381
|
+
// routed to WAITING_FOR_CI / BLOCKED_NEEDS_USER_DECISION before the downstream
|
|
382
|
+
// gate-coordination guards (which already honor this flag) are ever reached.
|
|
383
|
+
// Default true preserves current behavior for every caller that does not thread it.
|
|
384
|
+
const preApprovalRequireCi = refinementConfig?.preApprovalRequireCi !== false;
|
|
385
|
+
const ciBlocks = preApprovalRequireCi && isBlockedCiStatus(s.ciStatus);
|
|
386
|
+
const ciWaits = preApprovalRequireCi && isWaitingCiStatus(s.ciStatus);
|
|
387
|
+
|
|
356
388
|
let state;
|
|
357
389
|
|
|
358
390
|
if (!s.prExists) {
|
|
@@ -403,7 +435,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
403
435
|
&& state !== STATE.NO_PR && state !== STATE.DONE
|
|
404
436
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
405
437
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
406
|
-
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
|
|
438
|
+
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
|
|
407
439
|
const cleanThreads = s.unresolvedThreadCount === 0;
|
|
408
440
|
if (cleanThreads && ciClean) {
|
|
409
441
|
// Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
|
|
@@ -430,18 +462,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
430
462
|
state = STATE.WAITING_FOR_COPILOT_REVIEW;
|
|
431
463
|
} else if (s.copilotReviewPresent) {
|
|
432
464
|
// Copilot has reviewed at least once; all threads resolved
|
|
433
|
-
if (
|
|
465
|
+
if (ciBlocks) {
|
|
434
466
|
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
435
|
-
} else if (
|
|
467
|
+
} else if (ciWaits) {
|
|
436
468
|
state = STATE.WAITING_FOR_CI;
|
|
437
469
|
} else {
|
|
438
470
|
state = STATE.READY_TO_REREQUEST_REVIEW;
|
|
439
471
|
}
|
|
440
472
|
} else {
|
|
441
473
|
// No Copilot review yet; not currently requested
|
|
442
|
-
if (
|
|
474
|
+
if (ciBlocks) {
|
|
443
475
|
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
444
|
-
} else if (
|
|
476
|
+
} else if (ciWaits) {
|
|
445
477
|
state = STATE.WAITING_FOR_CI;
|
|
446
478
|
} else {
|
|
447
479
|
state = STATE.PR_READY_NO_FEEDBACK;
|