@ai-dev-methodologies/rlp-desk 0.10.1 → 0.11.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/docs/blueprints/sv-architecture-rethink.md +84 -0
- package/docs/multi-mission-orchestration.md +154 -0
- package/docs/plans/rlp-desk-0.11-handoff-7fixes.md +352 -0
- package/docs/plans/rlp-desk-elegant-papert-agent-a8cd695ffca2a3ad8.md +84 -0
- package/docs/plans/rlp-desk-elegant-papert.md +270 -0
- package/docs/protocol-reference.md +82 -0
- package/package.json +1 -1
- package/src/commands/rlp-desk.md +5 -0
- package/src/governance.md +160 -0
- package/src/node/reporting/campaign-reporting.mjs +4 -0
- package/src/node/run.mjs +23 -1
- package/src/node/runner/campaign-main-loop.mjs +284 -9
package/src/governance.md
CHANGED
|
@@ -248,6 +248,63 @@ Verifier records WHY each judgment was made in `verify-verdict.json`:
|
|
|
248
248
|
- Without reasoning, Verifier's verdict is an unsubstantiated judgment
|
|
249
249
|
- Both are archived in `logs/<slug>/` per existing audit trail pattern
|
|
250
250
|
|
|
251
|
+
### Cost Log (US-023 R11 P2-K)
|
|
252
|
+
`logs/<slug>/cost-log.jsonl` always has at least one entry per campaign. tmux mode runs the estimated path (no LLM SDK token counters), so when prompt/claim/verdict bytes are all zero the entry's `note` field is set to `no_actual_usage_recorded`. Audit pipelines branch on `note` to distinguish "iteration ran but tokens not captured" (tmux estimated path) from "logging broken" (file empty / writer never called). The runner registers `trap '_emit_final_cost_log; cleanup' EXIT INT TERM` so an unconditional final entry is appended even if the campaign exits via an early-return path.
|
|
253
|
+
|
|
254
|
+
### A4 Fallback Audit (US-017 R5 P0-D)
|
|
255
|
+
When Worker writes done-claim.json but forgets iter-signal.json, the runner auto-generates a verify signal as A4 fallback. This produces an opaque `summary="auto-generated by A4 fallback (done-claim without signal)"` that erases debugging context.
|
|
256
|
+
|
|
257
|
+
- Each A4 fallback invocation appends a JSONL entry to `logs/<slug>/a4-fallback-audit.jsonl` (event=`a4_fallback`, iter, us_id, source).
|
|
258
|
+
- **Recommended ratio < 10%** of total iterations (per mission). Above this threshold, Worker prompt mandate (Step N+1) is failing — investigate prompt clarity or Worker model.
|
|
259
|
+
- Verifier sets `meta.iter_signal_quality='auto_generated'` when it detects an A4 fallback summary so audit pipelines can join the signal-quality dimension to verdicts.
|
|
260
|
+
|
|
261
|
+
### BLOCKED Surfacing
|
|
262
|
+
A BLOCKED outcome MUST surface its reason on **FIVE channels at once**: (1) sentinel file (markdown `<slug>-blocked.md` + JSON sidecar `<slug>-blocked.json`), (2) status.json, (3) Leader's stderr console, (4) campaign report, (5) memory.md/latest.md hygiene update (worker mandate per US-020 R8 P1-H — `Blocking History` entry in memory.md and `Known Issues` update in latest.md before the sentinel is written). Sentinel-only is silent failure; operators (and wrappers) must see WHY without grep'ing memo files. The leader propagates `verdict.reason || verdict.summary` into the sentinel reason field, the JSON sidecar, the return object, and the campaign report. The 5th channel survives across iterations: the next worker reads memory.md before re-attempting, preventing same-block-reason loops.
|
|
263
|
+
|
|
264
|
+
When the worker writes a sentinel without performing the 5th-channel hygiene update (memory.md/latest.md mtime older than 5 minutes at sentinel-write time), the runner stamps `meta.blocked_hygiene_violated=true` on the JSON sidecar and emits an analytics event so audit pipelines can track hygiene compliance.
|
|
265
|
+
|
|
266
|
+
### Failure Taxonomy (P1-D)
|
|
267
|
+
BLOCKED writes a JSON sidecar (`<slug>-blocked.json`) alongside the markdown sentinel so wrappers can `jq .reason_category` instead of regex'ing free text. Schema:
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"schema_version": "2.0",
|
|
272
|
+
"slug": "<slug>",
|
|
273
|
+
"us_id": "<us_id or ALL>",
|
|
274
|
+
"blocked_at_iter": <int>,
|
|
275
|
+
"blocked_at_utc": "<iso8601>",
|
|
276
|
+
"reason_category": "metric_failure | cross_us_dep | context_limit | infra_failure | repeat_axis | mission_abort",
|
|
277
|
+
"reason_detail": "<full reason text>",
|
|
278
|
+
"failure_category": "spec | implementation | integration | flaky | null",
|
|
279
|
+
"recoverable": true | false,
|
|
280
|
+
"suggested_action": "next_mission_chain | restart | retry_after_fix | terminal_alert"
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Wrapper contract (binding)**:
|
|
285
|
+
- `reason_category` is **PRIMARY** — wrappers MUST branch on this field for recovery decisions.
|
|
286
|
+
- `failure_category` is **SECONDARY, diagnostic only** — do NOT branch on it; logging/triage only.
|
|
287
|
+
|
|
288
|
+
**Category → wrapper recovery action mapping** (defaults set by writer; wrappers may override but should follow):
|
|
289
|
+
- `metric_failure` → `retry_after_fix` (fix PRD/code, retry; recoverable=true)
|
|
290
|
+
- `cross_us_dep` → `retry_after_fix` (move AC to later US or switch to batch mode; recoverable=true)
|
|
291
|
+
- `infra_failure` → `restart` (CLI/network/spawn issue; recoverable=true)
|
|
292
|
+
- `context_limit` → `next_mission_chain` (current mission stale; recoverable=false)
|
|
293
|
+
- `repeat_axis` → `next_mission_chain` (model ceiling reached on this axis; recoverable=false)
|
|
294
|
+
- `mission_abort` → `terminal_alert` (flywheel guard exhausted; recoverable=false)
|
|
295
|
+
|
|
296
|
+
**Cross-US token list (cross_us_dep classifier)** — verifier verdict / worker signal text matching ANY of these is classified as `cross_us_dep`:
|
|
297
|
+
- English: `depends on US-`, `blocking US-`, `awaits US-`, `post-iter US-`, `requires US-N`, `cross-US`
|
|
298
|
+
- Korean: `US-N 산출물`, `신규 US-`, `post-iter`
|
|
299
|
+
|
|
300
|
+
**Write Order Contract (atomicity invariant)**:
|
|
301
|
+
1. JSON sidecar written FIRST (`fs.writeFile` / `atomic_write`).
|
|
302
|
+
2. markdown sentinel written SECOND.
|
|
303
|
+
3. Invariant: **markdown exists ⇒ JSON exists** (writer enforces order).
|
|
304
|
+
4. Wrappers SHOULD watch markdown sentinel, then read JSON sidecar. If JSON not yet visible (rare), retry up to 5 × 50ms before failing.
|
|
305
|
+
|
|
306
|
+
`atomic_write` provides per-file rename atomicity; cross-file ordering is enforced by the explicit two-call sequence.
|
|
307
|
+
|
|
251
308
|
## 2. Roles
|
|
252
309
|
|
|
253
310
|
### Leader (current session)
|
|
@@ -486,6 +543,7 @@ for iteration in 1..max_iter:
|
|
|
486
543
|
⑥½ Flywheel direction review (when --flywheel on-fail and consecutive_failures > 0)
|
|
487
544
|
- Dispatch Flywheel agent (fresh context, --flywheel-model)
|
|
488
545
|
- Read flywheel-signal.json for direction decision (hold/pivot/reduce/expand)
|
|
546
|
+
- Optional `next_mission_candidate` field (string | null): when present, the leader propagates it to status.json so consumer wrappers can chain the next mission without code edits. See docs/multi-mission-orchestration.md.
|
|
489
547
|
- If --flywheel-guard on:
|
|
490
548
|
- Dispatch Guard agent (fresh context, --flywheel-guard-model)
|
|
491
549
|
- Read flywheel-guard-verdict.json:
|
|
@@ -544,6 +602,10 @@ Worker completes US-001 → signal verify (us_id: "US-001")
|
|
|
544
602
|
|
|
545
603
|
**Batch mode** (`--verify-mode batch`) preserves legacy behavior: Worker signals `verify` only after all work is done, and the Verifier checks all AC at once.
|
|
546
604
|
|
|
605
|
+
**Cross-US dependency rule (per-us only):** In per-us mode each AC must reference only the same US or earlier verified US' artifacts. Future-US references (e.g. "post-iter US-(N+M) batch", "new US-(M) artifact") make the AC unsatisfiable inside a single per-us iteration and are rejected at init time (`init_ralph_desk.zsh` exits 2). Fold cross-US verification into the last measurement US, or run with `--verify-mode batch`.
|
|
606
|
+
|
|
607
|
+
**Cross-mission us_id leak prevention (US-022 R10 P2-J):** When the same `$DESK` directory hosts back-to-back missions, an `iter-signal.json` left over from the prior mission can carry a `us_id` (e.g. `US-005`) that has no corresponding section in the new mission's PRD (`US-001` through `US-003`). The runner would then scope-lock the next iteration to a non-existent US and block. `init_ralph_desk.zsh` runs `_quarantine_stale_signal` (lib_ralph_desk.zsh) which moves any signal whose `us_id` is absent from the new mission's PRD into `.sisyphus/quarantine/iter-signal.<epoch>.json` instead of `rm`-ing it. The PRD US-list extractor `_extract_prd_us_list` recognises three heading variants (`## US-005:`, `## US-005 -`, bare `## US-005`) so legitimate references are not false-flagged. The quarantine file is preserved so the operator can recover when the leak was actually intentional handoff state.
|
|
608
|
+
|
|
547
609
|
## 7b. Cross-Engine Consensus Verification
|
|
548
610
|
|
|
549
611
|
Controlled by `--consensus off|all|final-only` (default: `off`).
|
|
@@ -625,6 +687,93 @@ If `cb_threshold` or more consecutive fix attempts fail for the same US:
|
|
|
625
687
|
|
|
626
688
|
In tmux mode: Leader writes `<slug>-escalation.md` with the report and sets BLOCKED sentinel with reason "architecture-escalation."
|
|
627
689
|
|
|
690
|
+
## 7e. Lane Enforcement (P1-E)
|
|
691
|
+
|
|
692
|
+
Default mode is **WARN-only** (`LANE_MODE=warn`). The opt-in `--lane-strict`
|
|
693
|
+
flag (or `LANE_MODE=strict`) escalates lane violations to BLOCKED, but the
|
|
694
|
+
escalation is **downgraded** to `recoverable=true` + `suggested_action=retry_after_fix`
|
|
695
|
+
(NOT `terminal_alert`) so an inaccurate mtime audit does not terminally
|
|
696
|
+
kill a campaign.
|
|
697
|
+
|
|
698
|
+
### Decision tree
|
|
699
|
+
|
|
700
|
+
| Detection | Default (`warn`) | `--lane-strict` |
|
|
701
|
+
|-----------|-----------------|-----------------|
|
|
702
|
+
| PRD / test-spec / memory mtime changed during a worker iteration | analytics event `event_type=lane_violation_warning` + `log_warn` + audit log entry. Loop continues. | All of the WARN actions PLUS sentinel BLOCKED with `reason_category=infra_failure`, `recoverable=true`, `suggested_action=retry_after_fix`. |
|
|
703
|
+
|
|
704
|
+
### Channels (Silent failure 0)
|
|
705
|
+
|
|
706
|
+
WARN mode is NOT silent — violations always emit on three channels:
|
|
707
|
+
1. analytics jsonl event (`lane_violation_warning`)
|
|
708
|
+
2. leader stderr (`log_warn`)
|
|
709
|
+
3. audit log file `~/.claude/ralph-desk/logs/<slug>/lane-audit.json`
|
|
710
|
+
|
|
711
|
+
The audit log is initialized to `[]` at campaign start so the file always exists;
|
|
712
|
+
each violation appends an entry `{file, mtime_before, mtime_after, iter, lane_mode}`.
|
|
713
|
+
|
|
714
|
+
### Why downgrade in strict mode
|
|
715
|
+
|
|
716
|
+
mtime audit is best-effort heuristic — it cannot accurately attribute the
|
|
717
|
+
modifier (worker vs leader vs external editor). Running an inaccurate
|
|
718
|
+
detector with `terminal_alert` would hand it the power to permanently
|
|
719
|
+
terminate a campaign. The downgrade keeps `recoverable=true` so wrappers
|
|
720
|
+
can re-launch after operator review.
|
|
721
|
+
|
|
722
|
+
### Non-goals
|
|
723
|
+
|
|
724
|
+
- chmod-based enforcement (would break test fixtures and consumer envs).
|
|
725
|
+
- git_blame-based actor identification (best-effort hint only; verifier IL-2
|
|
726
|
+
is the real lane gate via worker process audit).
|
|
727
|
+
- Auto-launching missions on violation (consumer wrapper responsibility).
|
|
728
|
+
|
|
729
|
+
## 7f. Test Density Enforcement (US-018 R6 P1-F)
|
|
730
|
+
|
|
731
|
+
Default mode is **WARN-default** (`TEST_DENSITY_MODE=warn`). The opt-in `--test-density-strict` flag escalates a `< 3 tests/AC` finding to a non-zero `init` exit. Worker prompt mandates **>= 3 tests per AC** (happy + negative + boundary categories — IL-4). The test-spec must encode the same density. When the test-spec encodes fewer (e.g., 1 test per AC) the contract collapses: Worker following the prompt fails IL-4, Worker following the spec fails the prompt.
|
|
732
|
+
|
|
733
|
+
`init_ralph_desk.zsh` runs `_lint_test_density` (lib_ralph_desk.zsh) on the generated PRD + test-spec pair before campaign launch.
|
|
734
|
+
|
|
735
|
+
### Decision tree
|
|
736
|
+
|
|
737
|
+
| Detection | Default (`warn`) | `--test-density-strict` |
|
|
738
|
+
|-----------|-----------------|-----------------|
|
|
739
|
+
| Any US has `test_count < 3 * ac_count` | log_warn to stderr + audit log entry (`logs/<slug>/test-density-audit.jsonl`). Init exits 0. | All WARN actions PLUS init exits 1 with the same message. |
|
|
740
|
+
|
|
741
|
+
### Why no downgrade in strict mode
|
|
742
|
+
|
|
743
|
+
Test density is a *static* property of the test-spec, deterministically measurable, and observed before any worker runs. There is no risk asymmetry comparable to the lane-mtime audit (which is best-effort heuristic). Strict mode is a hard fail because the failure is unambiguous: too few tests for the AC count.
|
|
744
|
+
|
|
745
|
+
### Categorization (happy + negative + boundary)
|
|
746
|
+
|
|
747
|
+
The `>= 3 tests / AC` rule is a coverage floor, not a ceiling. Worker should distribute tests across:
|
|
748
|
+
- **happy**: standard input → expected output
|
|
749
|
+
- **negative**: malformed/missing input → defined error
|
|
750
|
+
- **boundary**: edge of allowed range, off-by-one, empty/max collections
|
|
751
|
+
|
|
752
|
+
If any category is missing for an AC the test-spec generator should densify before init. The runtime gate only counts; the categorization is enforced by the verifier's Test Coverage Audit (governance §1f Verifier reasoning).
|
|
753
|
+
|
|
754
|
+
## 7g. Signal Vocabulary Extension (US-019 R7 P1-G)
|
|
755
|
+
|
|
756
|
+
The base signal vocabulary (`continue | verify | blocked`) is binary at the iteration level: every AC in the current US either passes together or the whole iteration blocks. When unblocked ACs share an iteration with a single unsatisfiable AC the all-or-nothing semantic discards real progress.
|
|
757
|
+
|
|
758
|
+
`verify_partial` lets the worker emit progress and a deferral in one signal:
|
|
759
|
+
|
|
760
|
+
```json
|
|
761
|
+
{
|
|
762
|
+
"iteration": N,
|
|
763
|
+
"status": "verify_partial",
|
|
764
|
+
"us_id": "US-001",
|
|
765
|
+
"verified_acs": ["AC1", "AC2"],
|
|
766
|
+
"deferred_acs": ["AC3"],
|
|
767
|
+
"defer_reason": "AC3 depends on US-003 batch artifacts; cross-US"
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
- Verifier evaluates **only** `verified_acs`. `deferred_acs` are out-of-scope (not fail).
|
|
772
|
+
- Deferred ACs queue for the next iteration or the final ALL verify pass.
|
|
773
|
+
- The runner downgrades to `blocked` with reason `verify_partial_malformed` (reason_category `mission_abort`, recoverable=true, suggested_action=retry_after_fix) when `verified_acs` is missing or empty — the verifier has nothing to evaluate, so silent acceptance would be a false GREEN.
|
|
774
|
+
|
|
775
|
+
The downgrade is intentionally recoverable: the malformed signal is a worker-side prompt regression, not an environment failure, and the operator can fix it in-place.
|
|
776
|
+
|
|
628
777
|
## 8. Circuit Breaker
|
|
629
778
|
|
|
630
779
|
| Condition | Verdict |
|
|
@@ -633,12 +782,23 @@ In tmux mode: Leader writes `<slug>-escalation.md` with the report and sets BLOC
|
|
|
633
782
|
| Same acceptance criterion fails 2 consecutive iterations | Upgrade model, retry once (Agent mode only; tmux: same model retry); if still failing → Architecture Escalation (§7¾) → BLOCKED |
|
|
634
783
|
| `cb_threshold` (default: 6) consecutive **fail** verdicts on `cb_threshold` unique criterion IDs | Upgrade to opus, retry once; if still failing → BLOCKED (adjustable via `--cb-threshold`; when `--consensus` is not `off`, effective threshold doubles automatically: default 6 → 12) |
|
|
635
784
|
| max_iter reached | TIMEOUT (report to user) |
|
|
785
|
+
| Same canonical block reason fires `BLOCK_CB_THRESHOLD` (default: 3) times in a row | Mission abort (`.sisyphus/mission-abort.json` + non-zero exit). US-021 R9 P2-I `consecutive_blocks` counter. |
|
|
636
786
|
|
|
637
787
|
The Leader tracks `consecutive_failures` in `status.json`:
|
|
638
788
|
- Increments on `fail`, resets on `pass`, **unchanged by `request_info`**.
|
|
639
789
|
- "Same error" = same acceptance criterion ID in two consecutive **fail** verdicts (`request_info` does not break or contribute to this chain).
|
|
640
790
|
- "Diverse failures" = `cb_threshold` most recent `fail` verdicts each have a unique criterion ID.
|
|
641
791
|
|
|
792
|
+
### consecutive_blocks (US-021 R9 P2-I)
|
|
793
|
+
|
|
794
|
+
`consecutive_failures` only counts `fail` verdicts; a worker that signals `blocked` does not advance it, so a contract defect (e.g., test-spec/PRD mismatch) can repeat silently for many iterations. `consecutive_blocks` closes that hole.
|
|
795
|
+
|
|
796
|
+
- Counter increments when the **canonical** block reason matches the previous block's reason (`_canonical_block_reason` strips wrapper prefixes like `hygiene_violated:` and `wrapped:` before comparison so R8 hygiene wrappers don't fragment the chain).
|
|
797
|
+
- Counter resets to 1 when a *different* canonical reason fires.
|
|
798
|
+
- `infra_failure` category is **exempt** — transient API/tmux/process failures are environment problems, not contract defects, and shouldn't trip the abort.
|
|
799
|
+
- The very first iteration (`ITERATION <= 1`) is **exempt** — mission setup blocks (e.g., missing PRD, init misconfig) shouldn't terminate before the first real attempt.
|
|
800
|
+
- When the counter reaches `BLOCK_CB_THRESHOLD` the runner writes `.sisyphus/mission-abort.json` (`{reason, count, last_reason, threshold, timestamp}`) and exits non-zero so wrappers can chain to the next mission instead of looping.
|
|
801
|
+
|
|
642
802
|
## 8½. Self-Verification Feedback Loop
|
|
643
803
|
|
|
644
804
|
When `--with-self-verification` is enabled, the SV report feeds back into the next brainstorm cycle:
|
|
@@ -165,6 +165,8 @@ export async function generateCampaignReport({
|
|
|
165
165
|
now = new Date(),
|
|
166
166
|
gitDiffProvider = defaultGitDiffProvider,
|
|
167
167
|
svSummary = 'N/A — --with-self-verification not enabled',
|
|
168
|
+
blockedReason = null,
|
|
169
|
+
blockedCategory = null,
|
|
168
170
|
}) {
|
|
169
171
|
await fs.mkdir(path.dirname(reportFile), { recursive: true });
|
|
170
172
|
await versionFile(reportFile, reportVersionPath);
|
|
@@ -197,6 +199,8 @@ export async function generateCampaignReport({
|
|
|
197
199
|
'',
|
|
198
200
|
'## Execution Summary',
|
|
199
201
|
`- Terminal state: ${terminalState}`,
|
|
202
|
+
...(blockedReason ? [`- Blocked reason: ${blockedReason}`] : []),
|
|
203
|
+
...(blockedCategory ? [`- Blocked category: ${blockedCategory}`] : []),
|
|
200
204
|
`- Iterations run: ${status.iteration ?? 0}`,
|
|
201
205
|
`- Elapsed: ${elapsed}`,
|
|
202
206
|
'',
|
package/src/node/run.mjs
CHANGED
|
@@ -21,6 +21,8 @@ const RUN_DEFAULTS = {
|
|
|
21
21
|
lockWorkerModel: false,
|
|
22
22
|
autonomous: false,
|
|
23
23
|
withSelfVerification: false,
|
|
24
|
+
laneStrict: false,
|
|
25
|
+
testDensityStrict: false,
|
|
24
26
|
flywheel: 'off',
|
|
25
27
|
flywheelModel: 'opus',
|
|
26
28
|
flywheelGuard: 'off',
|
|
@@ -60,6 +62,8 @@ function buildHelpText() {
|
|
|
60
62
|
' --iter-timeout N',
|
|
61
63
|
' --debug',
|
|
62
64
|
' --autonomous',
|
|
65
|
+
' --lane-strict',
|
|
66
|
+
' --test-density-strict',
|
|
63
67
|
' --with-self-verification',
|
|
64
68
|
' --flywheel off|on-fail',
|
|
65
69
|
' --flywheel-model MODEL',
|
|
@@ -147,6 +151,14 @@ function parseRunOptions(args, cwd) {
|
|
|
147
151
|
case '--autonomous':
|
|
148
152
|
options.autonomous = true;
|
|
149
153
|
break;
|
|
154
|
+
case '--lane-strict':
|
|
155
|
+
// P1-E lane enforcement opt-in. Default WARN. governance §7¾.
|
|
156
|
+
options.laneStrict = true;
|
|
157
|
+
break;
|
|
158
|
+
case '--test-density-strict':
|
|
159
|
+
// US-018 R6 P1-F test density enforcement opt-in. Default WARN. governance §7f.
|
|
160
|
+
options.testDensityStrict = true;
|
|
161
|
+
break;
|
|
150
162
|
case '--with-self-verification':
|
|
151
163
|
options.withSelfVerification = true;
|
|
152
164
|
break;
|
|
@@ -209,7 +221,17 @@ async function runRunCommand(args, deps) {
|
|
|
209
221
|
|
|
210
222
|
const slug = args[0];
|
|
211
223
|
const options = parseRunOptions(args.slice(1), deps.cwd);
|
|
212
|
-
await deps.runCampaign(slug, options);
|
|
224
|
+
const result = await deps.runCampaign(slug, options);
|
|
225
|
+
// governance §1f BLOCKED Surfacing: surface the blocked reason on stderr so
|
|
226
|
+
// the operator (or wrapper script) does not have to grep memo files.
|
|
227
|
+
if (result && result.status === 'blocked') {
|
|
228
|
+
// P1-D 4-channel surfacing: include category so wrappers can see
|
|
229
|
+
// reason_category alongside the textual reason without parsing JSON.
|
|
230
|
+
const reason = result.reason ? ` — ${result.reason}` : '';
|
|
231
|
+
const cat = result.category ? `, category=${result.category}` : '';
|
|
232
|
+
write(deps.stderr, `Campaign BLOCKED for ${slug} (US=${result.usId}${cat})${reason}`);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
213
235
|
write(deps.stdout, `Campaign started for ${slug}`);
|
|
214
236
|
return 0;
|
|
215
237
|
}
|
|
@@ -64,6 +64,7 @@ function buildPaths(rootDir, slug) {
|
|
|
64
64
|
flywheelSignalFile: path.join(deskRoot, 'memos', `${slug}-flywheel-signal.json`),
|
|
65
65
|
flywheelGuardPromptFile: path.join(deskRoot, 'prompts', `${slug}.flywheel-guard.prompt.md`),
|
|
66
66
|
flywheelGuardVerdictFile: path.join(deskRoot, 'memos', `${slug}-flywheel-guard-verdict.json`),
|
|
67
|
+
laneAuditFile: path.join(campaignLogDir, 'lane-audit.json'),
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -253,6 +254,10 @@ async function readCurrentState(paths, slug, options) {
|
|
|
253
254
|
final_verifier_model: status.final_verifier_model ?? options.finalVerifierModel ?? 'opus',
|
|
254
255
|
verified_us: status.verified_us ?? [],
|
|
255
256
|
consecutive_failures: status.consecutive_failures ?? 0,
|
|
257
|
+
// US-021 R9 P2-I consecutive_blocks counter (governance §8). Tracks repeated
|
|
258
|
+
// same-canonical-reason worker blocks; verify_fail uses consecutive_failures.
|
|
259
|
+
consecutive_blocks: status.consecutive_blocks ?? 0,
|
|
260
|
+
last_block_reason: status.last_block_reason ?? '',
|
|
256
261
|
current_us: status.current_us ?? null,
|
|
257
262
|
session_name: status.session_name ?? null,
|
|
258
263
|
leader_pane_id: status.leader_pane_id ?? null,
|
|
@@ -334,9 +339,171 @@ async function dispatchVerifier({
|
|
|
334
339
|
return promptFile;
|
|
335
340
|
}
|
|
336
341
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
342
|
+
// P1-E Lane Enforcement (governance §7e). WARN-only by default; opt-in
|
|
343
|
+
// strict escalates lane violations to BLOCKED with downgraded action
|
|
344
|
+
// (recoverable=true, retry_after_fix). audit log file is initialized to
|
|
345
|
+
// `[]` so the file always exists, simplifying wrapper polling.
|
|
346
|
+
async function _initLaneAuditLog(paths) {
|
|
347
|
+
await fs.mkdir(path.dirname(paths.laneAuditFile), { recursive: true });
|
|
348
|
+
if (!(await exists(paths.laneAuditFile))) {
|
|
349
|
+
await fs.writeFile(paths.laneAuditFile, '[]\n', 'utf8');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// US-020 R8 P1-H Blocked exit hygiene (governance §1f, 5th channel).
|
|
354
|
+
// Worker must update memory.md (Blocking History) and latest.md (Known Issues)
|
|
355
|
+
// before signalling blocked. We compare mtimes against `now`; either file older
|
|
356
|
+
// than 5 minutes means the worker skipped the hygiene step. Returns true when violated.
|
|
357
|
+
async function _checkBlockedHygiene(paths, now = Date.now()) {
|
|
358
|
+
const threshold = 5 * 60 * 1000; // 5 minutes
|
|
359
|
+
const targets = [paths.memoryFile, paths.contextFile].filter(Boolean);
|
|
360
|
+
for (const file of targets) {
|
|
361
|
+
try {
|
|
362
|
+
const stat = await fs.stat(file);
|
|
363
|
+
if (now - stat.mtimeMs > threshold) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Missing file counts as violated — worker had nothing to update.
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function _snapshotLaneMtimes(paths) {
|
|
375
|
+
// PRD / test-spec are read-only artifacts the worker MUST NOT modify.
|
|
376
|
+
// memos and context are leader-owned; worker writes them via signal
|
|
377
|
+
// files only, never by direct edit.
|
|
378
|
+
const targets = [paths.prdFile, paths.testSpecFile, paths.contextFile];
|
|
379
|
+
const snapshot = {};
|
|
380
|
+
for (const file of targets) {
|
|
381
|
+
try {
|
|
382
|
+
const stat = await fs.stat(file);
|
|
383
|
+
snapshot[file] = stat.mtimeMs;
|
|
384
|
+
} catch {
|
|
385
|
+
snapshot[file] = null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return snapshot;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function _checkLaneViolations(paths, snapshotBefore, snapshotAfter, state, options) {
|
|
392
|
+
const violations = [];
|
|
393
|
+
for (const [file, before] of Object.entries(snapshotBefore)) {
|
|
394
|
+
const after = snapshotAfter[file];
|
|
395
|
+
if (before !== null && after !== null && after !== before) {
|
|
396
|
+
violations.push({
|
|
397
|
+
file,
|
|
398
|
+
mtime_before: before,
|
|
399
|
+
mtime_after: after,
|
|
400
|
+
iter: state.iteration ?? 0,
|
|
401
|
+
lane_mode: options.laneStrict ? 'strict' : 'warn',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (violations.length === 0) return null;
|
|
406
|
+
// Append to audit log (best-effort).
|
|
407
|
+
try {
|
|
408
|
+
const existing = JSON.parse(await fs.readFile(paths.laneAuditFile, 'utf8'));
|
|
409
|
+
await fs.writeFile(paths.laneAuditFile, `${JSON.stringify([...existing, ...violations], null, 2)}\n`, 'utf8');
|
|
410
|
+
} catch {
|
|
411
|
+
// log file corrupted or missing — re-initialize and write fresh entries.
|
|
412
|
+
await fs.writeFile(paths.laneAuditFile, `${JSON.stringify(violations, null, 2)}\n`, 'utf8');
|
|
413
|
+
}
|
|
414
|
+
return violations;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// P1-D Cross-US dependency token list (governance §1f). Keep in sync with
|
|
418
|
+
// the zsh helper _classify_cross_us_or_metric in lib_ralph_desk.zsh.
|
|
419
|
+
const CROSS_US_TOKEN_RE = /depends on US-|blocking US-|awaits US-|post-iter US-|requires US-\d+|cross-US|US-\d+ 산출물|신규 US-|post-iter/i;
|
|
420
|
+
|
|
421
|
+
// P1-D Failure Taxonomy classifier. governance §1f locks the 6 reason_category
|
|
422
|
+
// values + recoverable + suggested_action defaults per source. wrapper MUST
|
|
423
|
+
// branch on reason_category; failure_category is diagnostic only.
|
|
424
|
+
function _classifyBlock(source, { verdict, state, slug } = {}) {
|
|
425
|
+
let category;
|
|
426
|
+
let recoverable;
|
|
427
|
+
let action;
|
|
428
|
+
let failureCategory = null;
|
|
429
|
+
switch (source) {
|
|
430
|
+
case 'flywheel_inconclusive':
|
|
431
|
+
case 'flywheel_exhausted':
|
|
432
|
+
category = 'mission_abort';
|
|
433
|
+
recoverable = false;
|
|
434
|
+
action = 'terminal_alert';
|
|
435
|
+
break;
|
|
436
|
+
case 'model_upgrade':
|
|
437
|
+
category = 'repeat_axis';
|
|
438
|
+
recoverable = false;
|
|
439
|
+
action = 'next_mission_chain';
|
|
440
|
+
break;
|
|
441
|
+
case 'verifier': {
|
|
442
|
+
const text = `${verdict?.reason ?? ''} ${verdict?.summary ?? ''}`;
|
|
443
|
+
category = CROSS_US_TOKEN_RE.test(text) ? 'cross_us_dep' : 'metric_failure';
|
|
444
|
+
recoverable = true;
|
|
445
|
+
action = 'retry_after_fix';
|
|
446
|
+
failureCategory = verdict?.failure_category ?? null;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
default:
|
|
450
|
+
category = 'metric_failure';
|
|
451
|
+
recoverable = false;
|
|
452
|
+
action = 'terminal_alert';
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
reason_category: category,
|
|
456
|
+
failure_category: failureCategory,
|
|
457
|
+
recoverable,
|
|
458
|
+
suggested_action: action,
|
|
459
|
+
iteration: state?.iteration ?? 0,
|
|
460
|
+
slug,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function writeSentinel(filePath, status, usId, reason, classification = null, paths = null) {
|
|
465
|
+
// governance §1f BLOCKED Surfacing: BLOCKED is surfaced on FIVE channels —
|
|
466
|
+
// sentinel (markdown + JSON sidecar), status, console (stderr), report,
|
|
467
|
+
// and (US-020 R8 P1-H, 5th channel) memory.md/latest.md hygiene update.
|
|
468
|
+
// Legacy 1-line parsers still work because line 1 is unchanged.
|
|
469
|
+
const lines = [`${status.toUpperCase()}: ${usId}`];
|
|
470
|
+
if (reason) lines.push(`Reason: ${reason}`);
|
|
471
|
+
if (classification?.reason_category) {
|
|
472
|
+
lines.push(`Category: ${classification.reason_category}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// P1-D Write Order Contract:
|
|
476
|
+
// 1. JSON sidecar FIRST (atomic per-file rename via writeFile).
|
|
477
|
+
// 2. markdown sentinel SECOND.
|
|
478
|
+
// Invariant: markdown exists ⇒ JSON exists. Wrappers watch markdown,
|
|
479
|
+
// then read JSON; if JSON not yet visible (rare race), retry up to 5×50ms.
|
|
480
|
+
if (status === 'blocked' && classification) {
|
|
481
|
+
const jsonPath = filePath.replace(/\.md$/, '.json');
|
|
482
|
+
let hygieneViolated = false;
|
|
483
|
+
if (paths) {
|
|
484
|
+
try {
|
|
485
|
+
hygieneViolated = await _checkBlockedHygiene(paths);
|
|
486
|
+
} catch {
|
|
487
|
+
hygieneViolated = false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const jsonBody = {
|
|
491
|
+
schema_version: '2.0',
|
|
492
|
+
slug: classification.slug ?? null,
|
|
493
|
+
us_id: usId,
|
|
494
|
+
blocked_at_iter: classification.iteration ?? 0,
|
|
495
|
+
blocked_at_utc: new Date().toISOString(),
|
|
496
|
+
reason_category: classification.reason_category,
|
|
497
|
+
reason_detail: reason ?? null,
|
|
498
|
+
failure_category: classification.failure_category ?? null,
|
|
499
|
+
recoverable: classification.recoverable ?? false,
|
|
500
|
+
suggested_action: classification.suggested_action ?? 'terminal_alert',
|
|
501
|
+
meta: { blocked_hygiene_violated: hygieneViolated },
|
|
502
|
+
};
|
|
503
|
+
await fs.writeFile(jsonPath, `${JSON.stringify(jsonBody, null, 2)}\n`, 'utf8');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8');
|
|
340
507
|
}
|
|
341
508
|
|
|
342
509
|
async function runFinalSequentialVerify({
|
|
@@ -456,6 +623,9 @@ export async function run(slug, options = {}) {
|
|
|
456
623
|
analyticsFile: paths.analyticsFile,
|
|
457
624
|
statusFile: paths.statusFile,
|
|
458
625
|
});
|
|
626
|
+
// P1-E Lane Enforcement: initialize audit log to `[]` so the file always
|
|
627
|
+
// exists. Wrappers can then poll/tail without ENOENT special-cases.
|
|
628
|
+
await _initLaneAuditLog(paths);
|
|
459
629
|
|
|
460
630
|
if (await exists(paths.blockedSentinel)) {
|
|
461
631
|
throw new Error(`Campaign ${slug} is blocked. Run clean first.`);
|
|
@@ -495,7 +665,49 @@ export async function run(slug, options = {}) {
|
|
|
495
665
|
|
|
496
666
|
let fixContractPath = null;
|
|
497
667
|
|
|
668
|
+
// P1-E Lane Enforcement: snapshot lane mtimes before each iteration,
|
|
669
|
+
// compare at the top of the next iteration. Drift on read-only artifacts
|
|
670
|
+
// (PRD, test-spec, context) emits a lane_violation_warning event + audit
|
|
671
|
+
// log entry. governance §7e. Strict mode escalation hook is wired below
|
|
672
|
+
// (sentinel BLOCKED with infra_failure + recoverable=true downgrade).
|
|
673
|
+
let _laneSnapshot = await _snapshotLaneMtimes(paths);
|
|
674
|
+
|
|
498
675
|
while (state.iteration <= maxIterations) {
|
|
676
|
+
// Audit drift from the prior iteration before doing anything new.
|
|
677
|
+
const _laneSnapshotAfter = await _snapshotLaneMtimes(paths);
|
|
678
|
+
const _laneViolations = await _checkLaneViolations(paths, _laneSnapshot, _laneSnapshotAfter, state, options);
|
|
679
|
+
if (_laneViolations) {
|
|
680
|
+
for (const v of _laneViolations) {
|
|
681
|
+
await appendIterationAnalytics(paths, state, state.current_us ?? 'ALL', 'lane_violation_warning', { ...options, lane_violation: v });
|
|
682
|
+
}
|
|
683
|
+
if (options.laneStrict) {
|
|
684
|
+
// Strict mode: escalate to BLOCKED with downgrade
|
|
685
|
+
// (recoverable=true, retry_after_fix). governance §7e justifies
|
|
686
|
+
// the downgrade — the mtime audit is best-effort and should not
|
|
687
|
+
// terminally kill a campaign.
|
|
688
|
+
state.phase = 'blocked';
|
|
689
|
+
const laneReason = `lane_violation: ${_laneViolations.length} read-only artifact(s) modified during prior iteration`;
|
|
690
|
+
const laneClassification = {
|
|
691
|
+
reason_category: 'infra_failure',
|
|
692
|
+
failure_category: null,
|
|
693
|
+
recoverable: true,
|
|
694
|
+
suggested_action: 'retry_after_fix',
|
|
695
|
+
iteration: state.iteration,
|
|
696
|
+
slug,
|
|
697
|
+
};
|
|
698
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us ?? 'ALL', laneReason, laneClassification, paths);
|
|
699
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
700
|
+
return {
|
|
701
|
+
status: 'blocked',
|
|
702
|
+
usId: state.current_us ?? 'ALL',
|
|
703
|
+
reason: laneReason,
|
|
704
|
+
category: 'infra_failure',
|
|
705
|
+
statusFile: paths.statusFile,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
_laneSnapshot = _laneSnapshotAfter;
|
|
710
|
+
|
|
499
711
|
state.current_us = getNextUs(usList, state.verified_us, state.current_us);
|
|
500
712
|
if (state.current_us === 'ALL') {
|
|
501
713
|
const finalResult = await runFinalSequentialVerify({
|
|
@@ -575,6 +787,11 @@ export async function run(slug, options = {}) {
|
|
|
575
787
|
});
|
|
576
788
|
|
|
577
789
|
state.last_flywheel_decision = flywheelSignal.decision;
|
|
790
|
+
// P0-A multi-mission orchestration: optionally captured from flywheel signal.
|
|
791
|
+
// null when the flywheel did not suggest a next mission. Consumer wrappers
|
|
792
|
+
// poll status.next_mission_candidate to chain missions without code edits.
|
|
793
|
+
// See docs/multi-mission-orchestration.md.
|
|
794
|
+
state.next_mission_candidate = flywheelSignal.next_mission_candidate ?? null;
|
|
578
795
|
await fs.unlink(paths.flywheelSignalFile).catch(() => {});
|
|
579
796
|
|
|
580
797
|
// Flywheel Guard (independent validation of flywheel decision)
|
|
@@ -601,12 +818,28 @@ export async function run(slug, options = {}) {
|
|
|
601
818
|
|
|
602
819
|
if (guardVerdict.verdict === 'inconclusive') {
|
|
603
820
|
state.phase = 'blocked';
|
|
604
|
-
|
|
821
|
+
const guardReason = 'flywheel-guard-escalate-inconclusive';
|
|
822
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us, guardReason, _classifyBlock('flywheel_inconclusive', { state, slug }), paths);
|
|
605
823
|
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
824
|
+
// governance §1f three-channel: sentinel + report + return value all
|
|
825
|
+
// carry the same blocked reason. SV is intentionally not generated
|
|
826
|
+
// here because the guard fires before the iteration runs to
|
|
827
|
+
// completion; the campaign report uses the default SV message.
|
|
828
|
+
await generateCampaignReport({
|
|
829
|
+
slug,
|
|
830
|
+
reportFile: paths.reportFile,
|
|
831
|
+
prdFile: paths.prdFile,
|
|
832
|
+
statusFile: paths.statusFile,
|
|
833
|
+
analyticsFile: paths.analyticsFile,
|
|
834
|
+
now: resolveNow(options.now),
|
|
835
|
+
blockedReason: guardReason,
|
|
836
|
+
blockedCategory: 'mission_abort',
|
|
837
|
+
});
|
|
606
838
|
return {
|
|
607
839
|
status: 'blocked',
|
|
608
840
|
usId: state.current_us,
|
|
609
|
-
reason:
|
|
841
|
+
reason: guardReason,
|
|
842
|
+
category: 'mission_abort',
|
|
610
843
|
guardIssues: guardVerdict.issues,
|
|
611
844
|
statusFile: paths.statusFile,
|
|
612
845
|
};
|
|
@@ -615,12 +848,25 @@ export async function run(slug, options = {}) {
|
|
|
615
848
|
if (guardVerdict.verdict === 'fail') {
|
|
616
849
|
if (state.flywheel_guard_count[state.current_us] >= 3) {
|
|
617
850
|
state.phase = 'blocked';
|
|
618
|
-
|
|
851
|
+
const exhaustReason = 'flywheel-guard-retries-exhausted';
|
|
852
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us, exhaustReason, _classifyBlock('flywheel_exhausted', { state, slug }), paths);
|
|
619
853
|
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
854
|
+
// governance §1f three-channel: see comment above.
|
|
855
|
+
await generateCampaignReport({
|
|
856
|
+
slug,
|
|
857
|
+
reportFile: paths.reportFile,
|
|
858
|
+
prdFile: paths.prdFile,
|
|
859
|
+
statusFile: paths.statusFile,
|
|
860
|
+
analyticsFile: paths.analyticsFile,
|
|
861
|
+
now: resolveNow(options.now),
|
|
862
|
+
blockedReason: exhaustReason,
|
|
863
|
+
blockedCategory: 'mission_abort',
|
|
864
|
+
});
|
|
620
865
|
return {
|
|
621
866
|
status: 'blocked',
|
|
622
867
|
usId: state.current_us,
|
|
623
|
-
reason:
|
|
868
|
+
reason: exhaustReason,
|
|
869
|
+
category: 'mission_abort',
|
|
624
870
|
guardIssues: guardVerdict.issues,
|
|
625
871
|
statusFile: paths.statusFile,
|
|
626
872
|
};
|
|
@@ -679,6 +925,24 @@ export async function run(slug, options = {}) {
|
|
|
679
925
|
}
|
|
680
926
|
}
|
|
681
927
|
|
|
928
|
+
// US-019 R7 P1-G: verify_partial malformed downgrade.
|
|
929
|
+
// verify_partial requires verified_acs[] to be a non-empty array. Otherwise the verifier
|
|
930
|
+
// has nothing to evaluate and we must treat the signal as broken contract → blocked.
|
|
931
|
+
if (signal && signal.status === 'verify_partial') {
|
|
932
|
+
const acs = Array.isArray(signal.verified_acs) ? signal.verified_acs : null;
|
|
933
|
+
if (!acs || acs.length === 0) {
|
|
934
|
+
const malformedUs = signal.us_id ?? state.current_us;
|
|
935
|
+
const malformedClassification = {
|
|
936
|
+
reason_category: 'mission_abort',
|
|
937
|
+
recoverable: true,
|
|
938
|
+
suggested_action: 'retry_after_fix',
|
|
939
|
+
failure_category: 'spec',
|
|
940
|
+
};
|
|
941
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', malformedUs, 'verify_partial_malformed', malformedClassification, paths);
|
|
942
|
+
return { status: 'blocked', usId: malformedUs, reason: 'verify_partial_malformed', category: 'mission_abort' };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
682
946
|
const usId = signal.us_id ?? state.current_us;
|
|
683
947
|
const verifierModel = deriveVerifierModel(usId, options);
|
|
684
948
|
state.phase = 'verifier';
|
|
@@ -720,7 +984,9 @@ export async function run(slug, options = {}) {
|
|
|
720
984
|
|
|
721
985
|
if (verdict.verdict === 'blocked') {
|
|
722
986
|
state.phase = 'blocked';
|
|
723
|
-
|
|
987
|
+
const blockedReason = verdict.reason || verdict.summary || 'verifier-blocked';
|
|
988
|
+
const blockedClassification = _classifyBlock('verifier', { verdict, state, slug });
|
|
989
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', usId, blockedReason, blockedClassification, paths);
|
|
724
990
|
await appendIterationAnalytics(paths, state, usId, 'blocked', options);
|
|
725
991
|
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
726
992
|
let svSummary;
|
|
@@ -747,10 +1013,14 @@ export async function run(slug, options = {}) {
|
|
|
747
1013
|
analyticsFile: paths.analyticsFile,
|
|
748
1014
|
now: resolveNow(options.now),
|
|
749
1015
|
svSummary,
|
|
1016
|
+
blockedReason,
|
|
1017
|
+
blockedCategory: blockedClassification.reason_category,
|
|
750
1018
|
});
|
|
751
1019
|
return {
|
|
752
1020
|
status: 'blocked',
|
|
753
1021
|
usId,
|
|
1022
|
+
reason: blockedReason,
|
|
1023
|
+
category: blockedClassification.reason_category,
|
|
754
1024
|
statusFile: paths.statusFile,
|
|
755
1025
|
};
|
|
756
1026
|
}
|
|
@@ -760,7 +1030,8 @@ export async function run(slug, options = {}) {
|
|
|
760
1030
|
const upgradedModel = nextWorkerModel(options.workerModel ?? state.worker_model, state.consecutive_failures);
|
|
761
1031
|
if (upgradedModel === 'BLOCKED') {
|
|
762
1032
|
state.phase = 'blocked';
|
|
763
|
-
|
|
1033
|
+
const upgradeReason = `model-upgrade-exhausted (worker_model=${state.worker_model}, consecutive_failures=${state.consecutive_failures})`;
|
|
1034
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', usId, upgradeReason, _classifyBlock('model_upgrade', { state, slug }), paths);
|
|
764
1035
|
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
765
1036
|
let svSummary;
|
|
766
1037
|
if (options.withSelfVerification) {
|
|
@@ -786,10 +1057,14 @@ export async function run(slug, options = {}) {
|
|
|
786
1057
|
analyticsFile: paths.analyticsFile,
|
|
787
1058
|
now: resolveNow(options.now),
|
|
788
1059
|
svSummary,
|
|
1060
|
+
blockedReason: upgradeReason,
|
|
1061
|
+
blockedCategory: 'repeat_axis',
|
|
789
1062
|
});
|
|
790
1063
|
return {
|
|
791
1064
|
status: 'blocked',
|
|
792
1065
|
usId,
|
|
1066
|
+
reason: upgradeReason,
|
|
1067
|
+
category: 'repeat_axis',
|
|
793
1068
|
statusFile: paths.statusFile,
|
|
794
1069
|
};
|
|
795
1070
|
}
|