@dev-loops/core 0.9.0 → 1.0.0-rc.2
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 +3 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +34 -17
- package/src/config/config.mjs +265 -67
- package/src/config/extension-defaults.yaml +7 -0
- 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/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-drive.mjs +28 -4
- package/src/loop/ui-review-report.mjs +19 -17
- package/src/loop/ui-review-teardown.mjs +52 -10
package/src/config/config.mjs
CHANGED
|
@@ -13,52 +13,123 @@ import { z } from "zod";
|
|
|
13
13
|
// ============================================================================
|
|
14
14
|
|
|
15
15
|
const StrategyConfig = z.strictObject({
|
|
16
|
-
default: z.enum(["local-first", "github-first"]),
|
|
16
|
+
default: z.enum(["local-first", "github-first"]).describe("Default work-intake strategy: local-first starts from a repo plan file, github-first from a tracked issue."),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
const InputSourceConfig = z.strictObject({
|
|
20
|
-
default: z.enum(["tracker", "phase-docs"]),
|
|
20
|
+
default: z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs."),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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({
|
|
85
|
+
conductor: z.string().trim().min(1).describe("Model override for the conductor (dev-loop) session; absent = inherit the session model.").optional(),
|
|
86
|
+
roles: z.record(z.string(), z.string().trim().min(1)).describe("Concrete per-role/angle model overrides (highest precedence, above tiers).").optional(),
|
|
87
|
+
// Tier alias → per-harness concrete model (null = inherit / no-op).
|
|
88
|
+
tiers: z.record(z.string().min(1), ModelTierMapping).describe("Tier alias → per-harness concrete model; null on a harness means inherit (no override).").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)).describe("Role or gate angle → tier alias: a built-in alias (low, high), a custom models.tiers alias, or \"inherit\".").optional(),
|
|
26
91
|
});
|
|
27
92
|
|
|
93
|
+
const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
|
|
94
|
+
|
|
28
95
|
const RefinementConfig = z.strictObject({
|
|
29
|
-
fanOut: z.number().int().min(1).max(10),
|
|
30
|
-
mode: z.enum(["parallel", "sequential"]),
|
|
31
|
-
maxCopilotRounds: z.number().int().nonnegative().default(5),
|
|
32
|
-
stopOnLowSignal: z.boolean().default(false),
|
|
33
|
-
lowSignalRoundThreshold: z.number().int().nonnegative().default(3),
|
|
34
|
-
lowSignalMaxComments: z.number().int().nonnegative().default(2),
|
|
35
|
-
roles: z.array(z.string().trim().min(1)).optional(),
|
|
96
|
+
fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
|
|
97
|
+
mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
|
|
98
|
+
maxCopilotRounds: z.number().int().nonnegative().default(5).describe("Automated Copilot review rounds before converging; 0 disables Copilot review."),
|
|
99
|
+
stopOnLowSignal: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
|
|
100
|
+
lowSignalRoundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
|
|
101
|
+
lowSignalMaxComments: z.number().int().nonnegative().default(2).describe("A round with at most this many comments counts as low-signal."),
|
|
102
|
+
roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
|
|
36
103
|
});
|
|
37
104
|
|
|
38
105
|
const GateConfig = z.strictObject({
|
|
39
|
-
angles: z.array(z.string().trim().min(1)).optional(),
|
|
40
|
-
excludeAngles: z.array(z.string().trim().min(1)).default([]),
|
|
41
|
-
mandatoryAngles: z.array(z.string().trim().min(1)).default([]),
|
|
42
|
-
required: z.boolean().default(true),
|
|
43
|
-
requireCi: z.boolean().default(true),
|
|
106
|
+
angles: z.array(z.string().trim().min(1)).describe("Review lenses this gate fans out to.").optional(),
|
|
107
|
+
excludeAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles removed from the resolved angle list."),
|
|
108
|
+
mandatoryAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles that always run, regardless of diff-based dynamic selection."),
|
|
109
|
+
required: z.boolean().default(true).describe("Whether this gate must run."),
|
|
110
|
+
requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
|
|
44
111
|
blockCleanOnFindingSeverities: z
|
|
45
112
|
.array(z.enum(["must-fix", "worth-fixing-now", "defer"]))
|
|
46
113
|
.min(1)
|
|
47
|
-
.default(["must-fix"])
|
|
48
|
-
|
|
114
|
+
.default(["must-fix"])
|
|
115
|
+
.describe("Finding severities that block a clean gate verdict."),
|
|
116
|
+
dynamicAngles: z.boolean().default(false).describe("Enable diff-driven dynamic angle resolution for this gate."),
|
|
49
117
|
// Additive counterpart to the subtractive dynamicAngles path (#1048): when
|
|
50
118
|
// true, the context-builder may also ADD catalog angles — from
|
|
51
119
|
// resolveAnglePool() (gates.anglePool, or else the union of the persona
|
|
52
120
|
// registry and this config's own configured angles) — that change-category
|
|
53
121
|
// heuristics recommend but that are not already in this gate's configured
|
|
54
122
|
// pool. Default false preserves today's subtractive-only behavior exactly.
|
|
55
|
-
additiveAngles: z.boolean().default(false),
|
|
123
|
+
additiveAngles: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool."),
|
|
56
124
|
});
|
|
57
125
|
|
|
58
126
|
const GatesConfig = z.strictObject({
|
|
59
127
|
draft: GateConfig.optional(),
|
|
60
|
-
// `requireCi` is
|
|
61
|
-
//
|
|
128
|
+
// `requireCi` is honored on both gates: default true keeps CI a precondition,
|
|
129
|
+
// false is an opt-out escape hatch so a repo with no CI is not held at the
|
|
130
|
+
// gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
|
|
131
|
+
// when false the CI verdict is ignored entirely at that boundary, including a
|
|
132
|
+
// real failure (not merely "green optional").
|
|
62
133
|
preApproval: GateConfig.optional(),
|
|
63
134
|
// Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
64
135
|
// not production code, so it should not carry the full draft → pre-approval →
|
|
@@ -109,12 +180,12 @@ const GatesConfig = z.strictObject({
|
|
|
109
180
|
const AutonomyConfig = z.strictObject({
|
|
110
181
|
stopAt: z.array(
|
|
111
182
|
z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
|
|
112
|
-
),
|
|
183
|
+
).describe("Checkpoints that require operator confirmation before the loop proceeds (default: [\"merge\"])."),
|
|
113
184
|
// When true, merge is a fixed, non-overridable human action: the agent never
|
|
114
185
|
// runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
|
|
115
186
|
// any per-run merge authorization (envelope flag / explicit instruction) is
|
|
116
187
|
// ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
|
|
117
|
-
humanMergeOnly: z.boolean().optional(),
|
|
188
|
+
humanMergeOnly: z.boolean().describe("Merge stays a fixed human-only action: the agent never merges and any per-run merge authorization is ignored (fails closed).").optional(),
|
|
118
189
|
});
|
|
119
190
|
|
|
120
191
|
/**
|
|
@@ -137,33 +208,42 @@ const ApprovalConfig = z.strictObject({
|
|
|
137
208
|
});
|
|
138
209
|
|
|
139
210
|
const WorkflowConfig = z.strictObject({
|
|
140
|
-
asyncStartMode: z.enum(["required", "allowed"]).default("required"),
|
|
141
|
-
requireRetrospective: z.boolean(),
|
|
142
|
-
requireDraftFirst: z.boolean(),
|
|
143
|
-
devModeDefault: z.boolean(),
|
|
211
|
+
asyncStartMode: z.enum(["required", "allowed"]).default("required").describe("Whether the async start contract is required or merely allowed."),
|
|
212
|
+
requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before a loop completes."),
|
|
213
|
+
requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
|
|
214
|
+
devModeDefault: z.boolean().describe("Default new loops to dev mode."),
|
|
144
215
|
});
|
|
145
216
|
|
|
146
217
|
const LocalImplementationConfig = z.strictObject({
|
|
147
218
|
/** Opt into light mode for small scoped changes */
|
|
148
219
|
lightMode: z.strictObject({
|
|
149
|
-
enabled: z.boolean(),
|
|
150
|
-
maxFiles: z.number().int().min(1),
|
|
151
|
-
maxLines: z.number().int().min(1),
|
|
220
|
+
enabled: z.boolean().describe("Opt small scoped changes into the lightweight dispatch path."),
|
|
221
|
+
maxFiles: z.number().int().min(1).describe("Light mode applies only when the change touches at most this many files."),
|
|
222
|
+
maxLines: z.number().int().min(1).describe("Light mode applies only when the change stays within this many lines."),
|
|
152
223
|
// Copilot review round cap for light-dispatched PRs (#1210). Composes with
|
|
153
224
|
// (does not replace) refinement.maxCopilotRounds — see
|
|
154
225
|
// resolveEffectiveCopilotRoundCap.
|
|
155
|
-
maxCopilotRounds: z.number().int().nonnegative().default(1),
|
|
226
|
+
maxCopilotRounds: z.number().int().nonnegative().default(1).describe("Copilot round cap for light-dispatched PRs; composes as min(this, refinement.maxCopilotRounds)."),
|
|
227
|
+
}).optional(),
|
|
228
|
+
/**
|
|
229
|
+
* Opt into issue-less PR-first (`--lightweight` with no --issue) at ANY
|
|
230
|
+
* change scope. Decoupled from lightMode: gate dispatch still resolves
|
|
231
|
+
* inline vs full_fanout from scope on its own, so over-threshold issue-less
|
|
232
|
+
* PRs get the full fan-out and the full-PR Copilot round cap.
|
|
233
|
+
*/
|
|
234
|
+
issueless: z.strictObject({
|
|
235
|
+
enabled: z.boolean().describe("Opt into issue-less PR-first dispatch at any change scope; gate dispatch still resolves inline vs full fan-out from scope on its own."),
|
|
156
236
|
}).optional(),
|
|
157
237
|
});
|
|
158
238
|
|
|
159
239
|
/** Queue mode config */
|
|
160
240
|
const QueueConfig = z.strictObject({
|
|
161
|
-
maxParallel: z.number().int().min(1).max(10).default(3),
|
|
162
|
-
maxAutoFiledIssues: z.number().int().min(0).max(100).default(10),
|
|
163
|
-
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
|
|
164
|
-
projectNumber: z.number().int().positive().optional(),
|
|
165
|
-
boardTitle: z.string().trim().min(1).optional(),
|
|
166
|
-
archiveOlderThanDays: z.number().int().positive().optional(),
|
|
241
|
+
maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
|
|
242
|
+
maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
|
|
243
|
+
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
|
|
244
|
+
projectNumber: z.number().int().positive().describe("GitHub Projects board number (explicit opt-in to Projects-based queue ordering).").optional(),
|
|
245
|
+
boardTitle: z.string().trim().min(1).describe("GitHub Projects board title (explicit opt-in to Projects-based queue ordering).").optional(),
|
|
246
|
+
archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
|
|
167
247
|
});
|
|
168
248
|
|
|
169
249
|
/**
|
|
@@ -174,8 +254,8 @@ const QueueConfig = z.strictObject({
|
|
|
174
254
|
* data). Both optional; empty/absent is a valid no-op.
|
|
175
255
|
*/
|
|
176
256
|
const WorktreeConfig = z.strictObject({
|
|
177
|
-
copyOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
178
|
-
linkOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
257
|
+
copyOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs copied into a fresh worktree (isolated per worktree — use for mutable files).").optional(),
|
|
258
|
+
linkOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs symlinked to the main checkout (shared — read-only data only).").optional(),
|
|
179
259
|
});
|
|
180
260
|
|
|
181
261
|
/**
|
|
@@ -214,6 +294,18 @@ const UiReviewMigrateConfig = z.strictObject({
|
|
|
214
294
|
.optional(),
|
|
215
295
|
});
|
|
216
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Per-project dev-DB row-teardown recipe (Stage 5). The drive stamps each
|
|
299
|
+
* mutating step it drives with a drive-session id (advertised to the app on the
|
|
300
|
+
* DRIVE_SESSION_HEADER request header); this `deleteCommand` deletes exactly the
|
|
301
|
+
* rows the app tagged with that session — the id is passed in the
|
|
302
|
+
* UI_REVIEW_DRIVE_SESSION env var and the command runs in the provisioned
|
|
303
|
+
* worktree (dev DB only). Teardown runs it only on explicit confirmation.
|
|
304
|
+
*/
|
|
305
|
+
const UiReviewRowTeardownConfig = z.strictObject({
|
|
306
|
+
deleteCommand: z.string().trim().min(1),
|
|
307
|
+
});
|
|
308
|
+
|
|
217
309
|
/**
|
|
218
310
|
* Per-project boot recipe: a shell `command` that starts the branch's app and a
|
|
219
311
|
* `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
|
|
@@ -238,6 +330,7 @@ const UiReviewRunConfig = z.strictObject({
|
|
|
238
330
|
readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
|
|
239
331
|
cwd: z.string().trim().min(1).optional(),
|
|
240
332
|
migrate: UiReviewMigrateConfig.optional(),
|
|
333
|
+
rowTeardown: UiReviewRowTeardownConfig.optional(),
|
|
241
334
|
});
|
|
242
335
|
|
|
243
336
|
/**
|
|
@@ -285,6 +378,12 @@ const UiReviewFlowStepConfig = z.strictObject({
|
|
|
285
378
|
path: z.string().trim().min(1).optional(),
|
|
286
379
|
value: z.string().optional(),
|
|
287
380
|
event: z.string().trim().min(1).optional(),
|
|
381
|
+
// Responsive/stateful captures: a declared viewport resizes the page before the
|
|
382
|
+
// step and bakes into the named-state slug, so the mobile vs desktop (or
|
|
383
|
+
// default vs error) render lands in a distinct reviewable directory. The route
|
|
384
|
+
// NAMES its interaction states — the drive never enumerates them itself.
|
|
385
|
+
viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
|
|
386
|
+
interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
|
|
288
387
|
}).superRefine((step, ctx) => {
|
|
289
388
|
// Every action but `goto` targets an element, so a missing selector is a
|
|
290
389
|
// config error, not a runtime step-failure. (`goto` uses `path`/url.)
|
|
@@ -366,17 +465,19 @@ const PersonasConfig = z.record(z.string().min(1), PersonaEntry);
|
|
|
366
465
|
|
|
367
466
|
// Partial nested gate entries for file-level config (allows overriding only
|
|
368
467
|
// requireCi/required/angles without restating the whole gate object).
|
|
369
|
-
const FileGateConfig = GateConfig.partial();
|
|
370
468
|
const FileGatesConfig = z.strictObject({
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
469
|
+
// Each gate gets its own GateConfig.partial() instance rather than three
|
|
470
|
+
// .describe() clones of one shared partial, so no underlying def is shared
|
|
471
|
+
// and per-gate metadata renders unambiguously.
|
|
472
|
+
draft: GateConfig.partial().describe("Draft gate config (runs before a PR leaves draft).").optional(),
|
|
473
|
+
preApproval: GateConfig.partial().describe("Pre-approval gate config (final re-review before the merge handoff).").optional(),
|
|
474
|
+
spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
|
|
475
|
+
requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
|
|
476
|
+
requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
|
|
477
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).describe("Cap on parallel gate fan-out reviewers; overflow runs in sequential batches.").optional(),
|
|
478
|
+
postFindingsComments: z.boolean().describe("Post consolidated gate findings as a marker-tagged PR comment (default true).").optional(),
|
|
479
|
+
anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection.").optional(),
|
|
480
|
+
rejectForeignAngles: z.boolean().describe("Reject fan-out provenance naming angles outside the gate's configured pool (default true).").optional(),
|
|
380
481
|
});
|
|
381
482
|
|
|
382
483
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -438,6 +539,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
438
539
|
}),
|
|
439
540
|
localImplementation: Object.freeze({
|
|
440
541
|
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
|
|
542
|
+
issueless: Object.freeze({ enabled: false }),
|
|
441
543
|
}),
|
|
442
544
|
queue: Object.freeze({
|
|
443
545
|
maxParallel: 3,
|
|
@@ -464,21 +566,21 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
464
566
|
// ============================================================================
|
|
465
567
|
|
|
466
568
|
export const FileConfigSchema = z.strictObject({
|
|
467
|
-
version: z.literal(1),
|
|
468
|
-
strategy: StrategyConfig.partial().optional(),
|
|
469
|
-
inputSource: InputSourceConfig.partial().optional(),
|
|
470
|
-
models:
|
|
471
|
-
refinement: RefinementConfig.partial().optional(),
|
|
472
|
-
gates: FileGatesConfig.optional(),
|
|
473
|
-
autonomy: AutonomyConfig.partial().optional(),
|
|
474
|
-
approval: ApprovalConfig.partial().optional(),
|
|
475
|
-
workflow: WorkflowConfig.partial().optional(),
|
|
476
|
-
localImplementation: LocalImplementationConfig.partial().optional(),
|
|
477
|
-
queue: QueueConfig.partial().optional(),
|
|
478
|
-
personas: FilePersonasConfig.optional(),
|
|
479
|
-
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
480
|
-
worktree: WorktreeConfig.partial().optional(),
|
|
481
|
-
uiReview: UiReviewConfig.partial().optional(),
|
|
569
|
+
version: z.literal(1).describe("Config format version; always 1."),
|
|
570
|
+
strategy: StrategyConfig.partial().describe("Work-intake strategy defaults.").optional(),
|
|
571
|
+
inputSource: InputSourceConfig.partial().describe("Spec source for local-first work.").optional(),
|
|
572
|
+
models: ModelsConfigBase.partial().superRefine(refineRoleTiers).describe("Model routing: conductor override, per-role/angle overrides, tier aliases, and role→tier policy.").optional(),
|
|
573
|
+
refinement: RefinementConfig.partial().describe("Refinement fan-out and Copilot review-round behavior.").optional(),
|
|
574
|
+
gates: FileGatesConfig.describe("Gate review configuration: per-gate angle sets plus fan-out enforcement knobs.").optional(),
|
|
575
|
+
autonomy: AutonomyConfig.partial().describe("How far the loop proceeds without operator confirmation.").optional(),
|
|
576
|
+
approval: ApprovalConfig.partial().describe("Approval / merge-handoff behavior (human-handoff offer).").optional(),
|
|
577
|
+
workflow: WorkflowConfig.partial().describe("Workflow posture: draft-first, retrospectives, dev mode, async start.").optional(),
|
|
578
|
+
localImplementation: LocalImplementationConfig.partial().describe("Local implementation dispatch (light mode for small scoped changes).").optional(),
|
|
579
|
+
queue: QueueConfig.partial().describe("Queue mode: parallelism, auto-filing caps, and Projects board opt-in.").optional(),
|
|
580
|
+
personas: FilePersonasConfig.describe("Gate-angle → reviewer persona registry overrides (angle name → persona, prompt, default model).").optional(),
|
|
581
|
+
internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
|
|
582
|
+
worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
|
|
583
|
+
uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
|
|
482
584
|
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
483
585
|
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
484
586
|
localPlanning: z.unknown().optional(),
|
|
@@ -516,6 +618,7 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
516
618
|
yagni: { persona: "review", defaultModel: null },
|
|
517
619
|
"contract-surface": { persona: "review", defaultModel: null },
|
|
518
620
|
"input-validation": { persona: "review", defaultModel: null },
|
|
621
|
+
"threat-model": { persona: "review", defaultModel: null },
|
|
519
622
|
"packaging-runtime": { persona: "review", defaultModel: null },
|
|
520
623
|
"state-concurrency": { persona: "review", defaultModel: null },
|
|
521
624
|
"renderer-security": { persona: "review", defaultModel: null },
|
|
@@ -587,6 +690,76 @@ export function resolveReviewerRole(config, angle) {
|
|
|
587
690
|
};
|
|
588
691
|
}
|
|
589
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Resolve the concrete model for a subagent role/angle on a given harness, or
|
|
695
|
+
* `null` (inherit → pass no model override).
|
|
696
|
+
*
|
|
697
|
+
* Precedence:
|
|
698
|
+
* 1. `models.roles[role]` — concrete per-role/angle override (highest).
|
|
699
|
+
* 2. Tier alias, mapped through `models.tiers[tier][harness]` (or built-in
|
|
700
|
+
* tiers); `inherit`/absent/null → `null`. The alias depends on `kind`:
|
|
701
|
+
* - `kind: "angle"` (gate review dispatch): an explicit
|
|
702
|
+
* `models.roleTiers[role]` override, else the `review` tier. A gate
|
|
703
|
+
* review runs at review quality even when the angle's name collides with
|
|
704
|
+
* a routine role — e.g. the `docs` angle resolves via the `review` tier
|
|
705
|
+
* (high), not the `docs` writer role's low tier. (Its persona/agent still
|
|
706
|
+
* comes from `resolveReviewerRole`; only the tier is forced to review.)
|
|
707
|
+
* - `kind: "role"`/absent (routine subagent): `models.roleTiers[role]` (or
|
|
708
|
+
* the built-in role tier), else — when the name is not a named role — the
|
|
709
|
+
* tier for its review persona (so a non-colliding gate angle passed
|
|
710
|
+
* without `kind` still resolves high via `review`).
|
|
711
|
+
*
|
|
712
|
+
* Callers dispatching a gate review angle whose name may collide with a routine
|
|
713
|
+
* role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
|
|
714
|
+
* downgrade; role dispatch leaves `kind` unset.
|
|
715
|
+
*
|
|
716
|
+
* Zero-config is a genuine no-op on Pi (built-in tiers are null for pi) and
|
|
717
|
+
* reproduces the standing policy on Claude (routine=low, refiner/review=high,
|
|
718
|
+
* dev-loop=inherit).
|
|
719
|
+
*
|
|
720
|
+
* @param {DevLoopConfig} config
|
|
721
|
+
* @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
|
|
722
|
+
* @returns {string|null}
|
|
723
|
+
*/
|
|
724
|
+
export function resolveRoleModel(config, { role, harness, kind } = {}) {
|
|
725
|
+
if (!role || (harness !== "claude" && harness !== "pi")) return null;
|
|
726
|
+
|
|
727
|
+
// 1. Concrete per-role/angle override wins outright (over any tier).
|
|
728
|
+
const concrete = config?.models?.roles?.[role];
|
|
729
|
+
if (typeof concrete === "string" && concrete.trim().length > 0) {
|
|
730
|
+
return concrete.trim();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 2. Resolve a tier alias for this role/angle.
|
|
734
|
+
const roleTiers = { ...BUILTIN_ROLE_TIERS, ...(config?.models?.roleTiers ?? {}) };
|
|
735
|
+
let tierAlias;
|
|
736
|
+
if (kind === "angle") {
|
|
737
|
+
// Gate review angle: an explicit per-angle override wins, else the review
|
|
738
|
+
// tier — a gate review is review-quality regardless of a coincidental
|
|
739
|
+
// routine-role persona name (the `docs` angle must not inherit `docs`→low).
|
|
740
|
+
tierAlias = config?.models?.roleTiers?.[role] ?? roleTiers.review;
|
|
741
|
+
} else {
|
|
742
|
+
tierAlias = roleTiers[role];
|
|
743
|
+
if (tierAlias === undefined) {
|
|
744
|
+
// Not a named role — treat as a gate angle and inherit its review
|
|
745
|
+
// persona's tier (critical angles resolve high via the `review` persona).
|
|
746
|
+
const { persona } = resolveReviewerRole(config, role);
|
|
747
|
+
tierAlias = roleTiers[persona];
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (!tierAlias || tierAlias === "inherit") return null;
|
|
751
|
+
|
|
752
|
+
// Deep-merge the alias mapping so a partial override (e.g. `{ pi: "..." }`,
|
|
753
|
+
// which the schema allows) preserves the untouched built-in harness key rather
|
|
754
|
+
// than erasing the whole {claude,pi} mapping and resolving null for that harness.
|
|
755
|
+
const builtinMapping = BUILTIN_TIERS[tierAlias];
|
|
756
|
+
const configMapping = config?.models?.tiers?.[tierAlias];
|
|
757
|
+
if (!builtinMapping && !configMapping) return null;
|
|
758
|
+
const mapping = { ...builtinMapping, ...configMapping };
|
|
759
|
+
const model = mapping[harness];
|
|
760
|
+
return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
|
|
761
|
+
}
|
|
762
|
+
|
|
590
763
|
// ============================================================================
|
|
591
764
|
// Error types
|
|
592
765
|
// ============================================================================
|
|
@@ -1127,7 +1300,13 @@ export function resolveRefinement(config) {
|
|
|
1127
1300
|
const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
|
|
1128
1301
|
const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
|
|
1129
1302
|
const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
|
|
1130
|
-
|
|
1303
|
+
// #1337: centralize the pre-approval CI opt-out here so every caller that
|
|
1304
|
+
// builds its interpreter refinement config from `resolveRefinement(config)`
|
|
1305
|
+
// (detect-copilot-loop-state, copilot-pr-handoff, gate coordination, etc.)
|
|
1306
|
+
// reliably honors `gates.preApproval.requireCi: false` — otherwise a CI-less
|
|
1307
|
+
// repo would still be interpreted as waiting_for_ci / blocked in those tools.
|
|
1308
|
+
const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
|
|
1309
|
+
return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
|
|
1131
1310
|
}
|
|
1132
1311
|
|
|
1133
1312
|
/**
|
|
@@ -1258,6 +1437,19 @@ export function resolveLightMode(config) {
|
|
|
1258
1437
|
};
|
|
1259
1438
|
}
|
|
1260
1439
|
|
|
1440
|
+
/**
|
|
1441
|
+
* Resolve the issue-less PR-first any-scope opt-in (#1349).
|
|
1442
|
+
*
|
|
1443
|
+
* True only when `localImplementation.issueless.enabled` is exactly `true`;
|
|
1444
|
+
* absent, false, or malformed values resolve to false (fail closed).
|
|
1445
|
+
*
|
|
1446
|
+
* @param {DevLoopConfig} config
|
|
1447
|
+
* @returns {boolean}
|
|
1448
|
+
*/
|
|
1449
|
+
export function resolveIssuelessEnabled(config) {
|
|
1450
|
+
return config?.localImplementation?.issueless?.enabled === true;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1261
1453
|
/**
|
|
1262
1454
|
* Resolve the effective Copilot review round cap for a PR (#1210).
|
|
1263
1455
|
*
|
|
@@ -1586,7 +1778,8 @@ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
|
|
|
1586
1778
|
* @param {DevLoopConfig} config
|
|
1587
1779
|
* @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
|
|
1588
1780
|
* readyIntervalMs: number, cwd: string|null,
|
|
1589
|
-
* migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string }
|
|
1781
|
+
* migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string },
|
|
1782
|
+
* rowTeardown: null | { deleteCommand: string } }}
|
|
1590
1783
|
*/
|
|
1591
1784
|
export function resolveUiReviewRunRecipe(config) {
|
|
1592
1785
|
const run = config?.uiReview?.run;
|
|
@@ -1599,6 +1792,10 @@ export function resolveUiReviewRunRecipe(config) {
|
|
|
1599
1792
|
destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
|
|
1600
1793
|
}
|
|
1601
1794
|
: null;
|
|
1795
|
+
const rowTeardown =
|
|
1796
|
+
run.rowTeardown && typeof run.rowTeardown.deleteCommand === "string" && run.rowTeardown.deleteCommand.trim().length > 0
|
|
1797
|
+
? { deleteCommand: run.rowTeardown.deleteCommand.trim() }
|
|
1798
|
+
: null;
|
|
1602
1799
|
return {
|
|
1603
1800
|
command: run.command.trim(),
|
|
1604
1801
|
readyUrl: run.readyUrl.trim(),
|
|
@@ -1606,6 +1803,7 @@ export function resolveUiReviewRunRecipe(config) {
|
|
|
1606
1803
|
readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
|
|
1607
1804
|
cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
|
|
1608
1805
|
migrate,
|
|
1806
|
+
rowTeardown,
|
|
1609
1807
|
};
|
|
1610
1808
|
}
|
|
1611
1809
|
|
|
@@ -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: >-
|
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;
|