devflow-kit 2.2.0 → 2.4.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.
@@ -712,10 +712,95 @@ export async function stripUserSecurityDenyList(settingsPath) {
712
712
  await writeFileAtomicExclusive(settingsPath, stripped);
713
713
  return { removed };
714
714
  }
715
+ /** True for a non-null, non-array object literal. */
716
+ function isPlainObject(value) {
717
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
718
+ }
719
+ /**
720
+ * Every `command` string carried by a hook matcher, tolerating foreign shapes.
721
+ * Returns an empty array for anything that is not `{ hooks: [{ command: string }] }`.
722
+ */
723
+ function hookCommandsOf(matcher) {
724
+ if (!isPlainObject(matcher) || !Array.isArray(matcher.hooks))
725
+ return [];
726
+ return matcher.hooks
727
+ .filter(isPlainObject)
728
+ .map((h) => h.command)
729
+ .filter((c) => typeof c === 'string');
730
+ }
731
+ /**
732
+ * Merge Devflow's template hook entries and top-level fields into an existing
733
+ * parsed settings object. Idempotent by exact command string — a hook that is
734
+ * already present is skipped. Sets `statusLine` only when the user has no existing
735
+ * value. Attribution is NOT injected here — managed by the flags pipeline (D27).
736
+ * Mutates `existing` in place.
737
+ *
738
+ * D-SETTINGS-1: merge strategy — never replace; only add devflow entries that are absent.
739
+ * Returns { changed: true } when any field was added.
740
+ *
741
+ * `existing` comes from a hand-editable file, so every branch is shape-guarded:
742
+ * a `hooks` value (or per-event value) that is not the expected object/array shape
743
+ * is left untouched rather than overwritten or thrown on (applies PF-023 — validate
744
+ * at the sink that mutates).
745
+ *
746
+ * Exported for testing.
747
+ */
748
+ export function mergeDevflowSettingsTemplate(existing, template) {
749
+ let changed = false;
750
+ // Merge hook entries — idempotent by exact command string
751
+ const tmplHooks = isPlainObject(template.hooks) ? template.hooks : {};
752
+ const existingHooksRaw = existing.hooks;
753
+ if (existingHooksRaw === undefined || isPlainObject(existingHooksRaw)) {
754
+ const existingHooks = existingHooksRaw ?? {};
755
+ let hooksChanged = false;
756
+ for (const [event, matchers] of Object.entries(tmplHooks)) {
757
+ if (!Array.isArray(matchers))
758
+ continue;
759
+ for (const matcher of matchers) {
760
+ const cmd = hookCommandsOf(matcher)[0];
761
+ if (!cmd)
762
+ continue;
763
+ const current = existingHooks[event];
764
+ if (current !== undefined && !Array.isArray(current))
765
+ break; // foreign shape — leave the event alone
766
+ const eventArr = current ?? [];
767
+ if (eventArr.some((m) => hookCommandsOf(m).includes(cmd)))
768
+ continue;
769
+ eventArr.push(matcher);
770
+ existingHooks[event] = eventArr;
771
+ hooksChanged = true;
772
+ }
773
+ }
774
+ // Attach only when something was actually added — never introduce an empty
775
+ // `hooks` key into a settings.json that had none.
776
+ if (hooksChanged) {
777
+ existing.hooks = existingHooks;
778
+ changed = true;
779
+ }
780
+ }
781
+ // Set statusLine only if the user has none
782
+ if (existing.statusLine === undefined && template.statusLine !== undefined) {
783
+ existing.statusLine = template.statusLine;
784
+ changed = true;
785
+ }
786
+ // attribution: owned exclusively by the flags pipeline (applyFlags/stripFlags, D27) — a second writer here would race it.
787
+ return { changed };
788
+ }
715
789
  /**
716
790
  * Install or update settings.json with Devflow configuration.
717
- * Prompts interactively in TTY mode when settings already exist.
718
- * In non-TTY mode, skips override (safe default).
791
+ *
792
+ * Strategy (D-SETTINGS-1: merge, never overwrite wholesale):
793
+ * - Fresh file: write the template directly.
794
+ * - Existing file: MERGE devflow hook entries and fields into the parsed object.
795
+ * Idempotent by exact command string — existing hooks are never duplicated or removed.
796
+ * Preserves every user key (env, permissions, apiKeyHelper, model, etc.) untouched.
797
+ * - Parse failure: warn and skip; file left byte-identical (never clobber a broken file).
798
+ *
799
+ * The merge is additive only, so it runs without a confirmation prompt: init is called
800
+ * from inside an active spinner (a prompt would render on top of it), and declining
801
+ * could not protect the file anyway — init's own settings pass rewrites the hook set
802
+ * unconditionally right afterwards. A prompt whose answer changes nothing is worse
803
+ * than no prompt.
719
804
  *
720
805
  * The deny list is handled by init's dedicated security step
721
806
  * (applyUserSecurityDenyList / installManagedSettings) after installSettings completes.
@@ -741,38 +826,30 @@ export async function installSettings(claudeDir, rootDir, devflowDir, verbose) {
741
826
  }
742
827
  return;
743
828
  }
744
- // Settings exist — check if they already have hooks
745
- let hasHooks = false;
829
+ // Settings exist — parse and merge (never overwrite wholesale)
830
+ let existingParsed;
746
831
  try {
747
- const existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
748
- hasHooks = !!existing.hooks;
832
+ existingParsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
749
833
  }
750
- catch { /* parse error = treat as no hooks */ }
751
- if (hasHooks) {
752
- // Settings already configured with hooks — nothing to do
834
+ catch {
835
+ // Parse failure — warn and skip; file left byte-identical (D-SETTINGS-1)
836
+ if (verbose) {
837
+ p.log.warn('settings.json could not be parsed — Devflow hooks not added. ' +
838
+ 'Fix the JSON manually and re-run devflow init.');
839
+ }
753
840
  return;
754
841
  }
755
- // Settings exist without hooks — prompt in TTY, warn in non-TTY
756
- if (process.stdin.isTTY) {
757
- const confirmed = await p.confirm({
758
- message: 'settings.json exists without hooks (Working Memory needs hooks). Override?',
759
- initialValue: true,
760
- });
761
- if (p.isCancel(confirmed)) {
762
- p.cancel('Installation cancelled.');
763
- process.exit(0);
764
- }
765
- if (confirmed) {
766
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
767
- p.log.success('Settings overridden');
768
- }
769
- else {
770
- p.log.info('Keeping existing settings');
771
- }
842
+ const templateParsed = JSON.parse(settingsContent);
843
+ // Merge template into existing (mutates existingParsed in place).
844
+ // If nothing needs to be added the write is skipped entirely.
845
+ const { changed } = mergeDevflowSettingsTemplate(existingParsed, templateParsed);
846
+ if (!changed) {
847
+ // Already fully configured — nothing to do
848
+ return;
772
849
  }
773
- else {
774
- p.log.warn('Settings exist without hooks. Working Memory requires hooks.');
775
- p.log.info('Re-run interactively to configure, or manually add hooks to settings.json');
850
+ await writeFileAtomicExclusive(settingsPath, JSON.stringify(existingParsed, null, 2) + '\n');
851
+ if (verbose) {
852
+ p.log.success('Settings updated with Devflow hooks and HUD');
776
853
  }
777
854
  }
778
855
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devflow-kit",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "A meta-harness for Claude Code — turns a single coding agent into an engineering team: orchestration, parallel review, persistent memory, self-learning, and graph workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "@clack/prompts": "^0.9.1",
64
64
  "commander": "^12.0.0",
65
65
  "picocolors": "^1.1.1",
66
- "subswitch": "0.2.0"
66
+ "subswitch": "0.4.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@mdscript/mds": "0.2.0",
@@ -18,7 +18,7 @@ skills:
18
18
  You process the pending decisions queue for one project: claim it atomically, detect
19
19
  decision/pitfall patterns worth keeping, curate the existing ledger, and delete the claimed
20
20
  queue as your final act. You read and edit the data files directly — no script reads,
21
- validates, or applies anything on your behalf. The only executables you call are the three
21
+ validates, or applies anything on your behalf. The only executables you call are the four
22
22
  ledger ops below.
23
23
 
24
24
  ## Iron Law
@@ -26,11 +26,12 @@ ledger ops below.
26
26
  > **assign-anchor OWNS NUMBERING; render OWNS THE .md; NEVER HAND-EDIT decisions.md, pitfalls.md, or index.md**
27
27
  >
28
28
  > ADR and PF numbers are assigned exclusively by `assign-anchor`. The `.md` files are written
29
- > exclusively by `render-decisions.cjs` (invoked internally by `assign-anchor`/`retire-anchor`).
30
- > One `assign-anchor` invocation claims one number and re-renders all three files atomically
31
- > (decisions.md, pitfalls.md, index.md). To deprecate, supersede, or retire an entry, call
32
- > `retire-anchor <anchor_id> <status>` — never edit the `.md` files directly. Manual re-render
33
- > via `render-decisions.cjs render "$(pwd)"` also refreshes index.md.
29
+ > exclusively by `render-decisions.cjs` (invoked internally by `assign-anchor`/`retire-anchor`/`refresh-anchor`).
30
+ > One `assign-anchor` invocation claims one number and re-renders all three files
31
+ > (decisions.md, pitfalls.md, index.md — each write atomic; the sequence is not transactional:
32
+ > a crash between writes self-heals on the next op). To deprecate, supersede, or retire an entry, call
33
+ > `retire-anchor <anchor_id> <status>` — never edit the `.md` files directly. Every ledger op
34
+ > re-renders all three files internally; there is no separate render step for you to run.
34
35
 
35
36
  ## Environment
36
37
 
@@ -39,6 +40,7 @@ are relative to it. The ledger ops live at `$HOME/.devflow/scripts/hooks/json-he
39
40
 
40
41
  - `assign-anchor <type> <obs_id>` — claims the next ADR/PF number and re-renders all three `.md` files (decisions.md, pitfalls.md, index.md)
41
42
  - `retire-anchor <anchor_id> <status>` — flips a ledger row's rendered status and re-renders
43
+ - `refresh-anchor <anchor_id> [<anchor_id>...]` — variadic: re-projects one or more anchored log rows through the same projector as `assign-anchor` in a single lock/parse/render pass; use after reinforcing already-anchored observations (ADR-022)
42
44
  - `rotate-observations` — archives `observing` log rows older than 30 days
43
45
 
44
46
  Each op self-locks internally. Call them plainly — never wrap them in a lock of your own,
@@ -119,6 +121,23 @@ rewrite the whole file:
119
121
  timestamps are UTC ISO (`date -u +%Y-%m-%dT%H:%M:%SZ`). Estimate `confidence` honestly —
120
122
  it is curation metadata only, NOT a gate; do not inflate it.
121
123
 
124
+ **`details` grammar**: use `Key: value` segments separated by `;`. Recognised keys are per
125
+ type and disjoint — decisions: `context:`, `decision:`, `rationale:`; pitfalls: `area:`,
126
+ `issue:`, `impact:`, `resolution:`. A segment that begins with a key recognised FOR THAT TYPE
127
+ starts a new field; any other segment (including a key from the opposite type) is appended to
128
+ the previous field's value, so semicolons inside a value are preserved. Keep prose out of key
129
+ positions — do not start a value with text that looks like a recognised key for that type. The
130
+ parser has a recovery pass for legacy mid-segment keys.
131
+
132
+ **`amendments` field**: when reinforcing an already-anchored observation with a dated
133
+ correction or ratification that should remain visible as history (rather than silently
134
+ rewriting `details`), APPEND `{ "date": "YYYY-MM-DD", "note": "..." }` to the log row's
135
+ `amendments` array (create the array if absent). The shape is exactly `{date, note}` — the
136
+ schema guard rejects bare strings. Amendments render at the end of the entry body in
137
+ `decisions.md`/`pitfalls.md`; they never appear in `index.md` lines. A follow-up
138
+ `refresh-anchor <anchor_id>` is required to propagate the addition to the rendered files
139
+ (ADR-022).
140
+
122
141
  - **Reinforce an existing row** — use the Edit tool to replace that row's single line:
123
142
  increment `observations`, union `evidence` (dedupe, cap 10), update `last_seen`, and
124
143
  refresh `pattern`/`details`/`confidence` only where the new evidence sharpens them.
@@ -134,12 +153,32 @@ node "$HOME/.devflow/scripts/hooks/json-helper.cjs" assign-anchor "pitfall" "obs
134
153
  NEVER hand-edit `decisions.md` or `pitfalls.md`. NEVER invent an ADR-NNN/PF-NNN number
135
154
  yourself — `assign-anchor` is the only source of numbering.
136
155
 
156
+ **After reinforcing already-anchored observations**: once you have updated all target log rows
157
+ (incrementing `observations`, refreshing `pattern`/`details`, updating `last_seen`), collect
158
+ all anchor ids and make ONE variadic call:
159
+
160
+ ```bash
161
+ node "$HOME/.devflow/scripts/hooks/json-helper.cjs" refresh-anchor <anchor_id1> [<anchor_id2> ...]
162
+ ```
163
+
164
+ This re-projects all sharpened log rows in a single lock/parse/render pass, propagating
165
+ improvements to `decisions.md`/`pitfalls.md`/`index.md`. BATCH: do not call once per row — N
166
+ calls pay N full-corpus renders; one variadic call pays one. Refresh calls do not consume
167
+ curation slots; however, at most 10 anchors may be refreshed per run — stop if the cap is
168
+ reached.
169
+
137
170
  ## Part 2 — Curation
138
171
 
139
172
  Periodic housekeeping of the ledger and rendered `.md` files. Bounds: **≤5 curation changes
140
173
  per run**. **7-day protection window** — never touch any entry whose `date` field in the
141
174
  ledger (`.devflow/learning/decisions-ledger.jsonl`) is within the past 7 days. The window key
142
- is the ledger row's `date` field (YYYY-MM-DD), not anything in the `.md` file.
175
+ is the ledger row's `date` field (YYYY-MM-DD), not anything in the `.md` file. If the ledger
176
+ row lacks a `date` field (pitfall rows promoted before date-stamping was added), use the
177
+ observation log row's `last_seen` date for the window. If `last_seen` is also unavailable, the
178
+ entry predates date-stamping and is outside the protection window (no backfill: a fabricated date would be worse than an unprotected entry — ADR-022).
179
+ Example: a pitfall row with no ledger `date` whose log row has `last_seen: "2026-08-27"` → window
180
+ key 2026-08-27 (protected if within 7 days of today); no ledger `date` AND no log `last_seen`
181
+ → outside the window, eligible for curation.
143
182
 
144
183
  Ground yourself first, all by direct reads:
145
184
  - Active entries and counts: `decisions.md` / `pitfalls.md` — what is rendered is what is active.
@@ -148,6 +187,13 @@ Ground yourself first, all by direct reads:
148
187
  those files still exist (Glob). An entry whose referenced files are gone is a preferred
149
188
  retirement candidate — a signal to prefer, not an automatic retirement.
150
189
 
190
+ **PF-040 pointer-vs-citation gate**: before acting on a missing-path signal (a file cited in
191
+ `details`/`evidence` no longer exists), determine whether the reference is a live POINTER (a
192
+ file a reader should follow today) or a HISTORICAL CITATION (the file the entry recorded
193
+ deleting, replacing, or retiring). A missing live pointer is drift — repair the reference. A
194
+ missing historical citation is confirmation that the decision was implemented — leave the entry
195
+ intact.
196
+
151
197
  **Rotate stale observations first** (before selecting curation candidates):
152
198
 
153
199
  ```bash
@@ -180,10 +226,13 @@ node "$HOME/.devflow/scripts/hooks/json-helper.cjs" retire-anchor <anchor_id> <s
180
226
 
181
227
  `retire-anchor` is atomic and idempotent. Call it once per entry.
182
228
 
183
- **Citation preservation**: if an entry being retired has inbound `applies ADR-NNN` citations
184
- in other entries, update those entries' `pattern`/`details` to reference the surviving entry
185
- instead — edit those ledger rows directly (one line at a time), then re-render via
186
- `node "$HOME/.devflow/scripts/hooks/lib/render-decisions.cjs" render "$(pwd)"`.
229
+ **Citation preservation** (ADR-022 — log is content authority): if an entry being retired
230
+ has inbound `applies ADR-NNN` citations in other entries' `pattern`/`details`, update those
231
+ other entries to reference the surviving entry — do this by editing their **log rows** in
232
+ `decisions-log.jsonl` (one line at a time), then collecting all updated anchor ids and calling
233
+ ONCE: `node "$HOME/.devflow/scripts/hooks/json-helper.cjs" refresh-anchor <anchor_id1> [<anchor_id2> ...]`
234
+ Batch all ids into the single variadic call — one lock/parse/render pass for the whole set.
235
+ Never edit the ledger directly for content changes; the log is the authority.
187
236
 
188
237
  **Cap enforcement**: stop after 5 changes regardless of remaining candidates.
189
238
 
@@ -191,8 +240,9 @@ instead — edit those ledger rows directly (one line at a time), then re-render
191
240
 
192
241
  1. Run `rotate-observations` if you have not already this run (Part 2 covers it — never run
193
242
  it twice).
194
- 2. Delete the claim file as your FINAL act, strictly after every other write (bare `rm` is
195
- blocked by devflow's recommended deny-list — PF-003):
243
+ 2. Delete the claim file as your FINAL act, strictly after every other write (`rm -f` is
244
+ denied by devflow's recommended deny-list; `unlink` and a flagless `rm` both pass — use
245
+ `unlink` (PF-003)):
196
246
  `unlink .devflow/learning/.pending-turns.processing`
197
247
  If deletion is denied, finish normally and note the leftover claim file in your summary —
198
248
  the next run's stale-merge recovery folds it in.
@@ -28,10 +28,22 @@ You receive from orchestrator:
28
28
 
29
29
  1. **Read context per issue**: For each issue, Read 30 lines around the reported file:line to understand the actual code.
30
30
  2. **Apply Decisions**: Scan the DECISIONS_CONTEXT index to identify relevant ADR and PF entries. Read full bodies on demand. Cite `applies ADR-NNN` / `avoids PF-NNN` in your Reasoning column. Skip when DECISIONS_CONTEXT is empty or `(none)`. Use only verbatim IDs from the index — do not fabricate.
31
- 3. **Assign disposition**: Apply the blast-radius matrix below. Every issue gets exactly one verdict — none may vanish.
31
+ 3. **Assign disposition**: Run the duplicate grouping pre-pass, then apply the blast-radius matrix to each group's primary. Every issue gets exactly one verdict (DUPLICATE included) — none may vanish.
32
32
  4. **Document evidence**: FALSE_POSITIVE requires cited grep/file:line. BY_DESIGN requires an ADR or inline comment/doc citation.
33
33
  5. **Assign risk tier**: For every FIX_NOW issue, annotate Standard or Careful.
34
34
 
35
+ ## Duplicate Grouping Pre-Pass
36
+
37
+ Run this pre-pass **before** the disposition matrix. It is a relation between issues, not a matrix row.
38
+
39
+ 1. **Group by same defect**: cluster issues that share the same root cause — typically the same or adjacent file:line reported by different review foci, or the same logical error in different phrasings.
40
+ 2. **Select primary**: from each group, designate as primary the most specific and complete report — but when a group mixes security and non-security findings (a 'security member' is one that would trigger the Security Gate), the security member is always the primary. All other members are non-primary duplicates.
41
+ 3. **Security gate applies to the whole group**: if ANY member is a security finding, the group's primary passes through the Security Gate (→ FIX_NOW or ESCALATED only). Never downgrade a group because non-security members outnumber the security finding.
42
+ 4. **Non-primary members**: assign verdict **DUPLICATE** with `duplicate_of: <primary-id>`. Never chain — `duplicate_of` must reference a non-DUPLICATE issue. A DUPLICATE inherits its primary's outcome.
43
+ 5. **Single-member groups**: if an issue has no duplicates it is its own primary — apply the matrix directly.
44
+
45
+ Apply the disposition matrix to each group's **primary only**.
46
+
35
47
  ## Blast-Radius Disposition Matrix
36
48
 
37
49
  **First match wins. Apply in the order listed.**
@@ -117,6 +129,11 @@ Return the verdict ledger grouped by disposition:
117
129
  |----------|-----------|----------------------|
118
130
  | {id} | {file}:{line} | {why requires complete redesign} |
119
131
 
132
+ ### DUPLICATE
133
+ | Issue ID | Duplicate Of | File:Line | Reason |
134
+ |----------|-------------|-----------|--------|
135
+ | {id} | {primary-id} | {file}:{line} | {same defect as {primary-id}, reported by {focus}} |
136
+
120
137
  ### Summary
121
138
  - Total Issues: {n}
122
139
  - ESCALATED: {n}
@@ -125,6 +142,7 @@ Return the verdict ledger grouped by disposition:
125
142
  - BY_DESIGN: {n}
126
143
  - FIX_SEPARATE: {n}
127
144
  - TECH_DEBT: {n}
145
+ - DUPLICATE: {n}
128
146
  ```
129
147
 
130
148
  ## Boundaries
@@ -56,7 +56,7 @@ In multi-worktree mode, spawn all pre-flight agents **in a single message** (par
56
56
 
57
57
  **If BLOCKED:** In single-worktree mode, stop and report the blocker to user. If no reviews found, suggest `/code-review` or `/bug-analysis` first. In multi-worktree mode, report the failure but continue with other worktrees.
58
58
 
59
- **Extract from response:** `branch`, `branch_slug`, `pr_number`, `review_count`, `diff_files` per worktree.
59
+ **Extract from response:** `branch`, `base_branch`, `branch_slug`, `pr_number`, `review_count`, `diff_files` per worktree.
60
60
 
61
61
  **Fetch PR body** (after extracting `pr_number`):
62
62
  ```bash
@@ -138,7 +138,7 @@ Issues are extracted from `\{TARGET_DIR\}` only — never cross-reference review
138
138
  ### Phase 1b: Fetch External Review Threads (Compliance-gated)
139
139
 
140
140
  **Produces:** THREAD_MAP
141
- **Requires:** PR_INFO, COMPLIANCE_SKILL_INSTALLED
141
+ **Requires:** BRANCH_INFO, COMPLIANCE_SKILL_INSTALLED
142
142
 
143
143
  Skip this phase if `COMPLIANCE_SKILL_INSTALLED` is false.
144
144
 
@@ -157,7 +157,7 @@ Parse `THREAD_MAP` from Git agent output. If Git agent returns `TRACEABILITY: DE
157
157
  ### Phase 2: Global Triage
158
158
 
159
159
  **Produces:** TRIAGE_RESULTS
160
- **Requires:** ISSUES, DIFF_FILES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, PR_DESCRIPTION, BRANCH_INFO
160
+ **Requires:** ISSUES, DIFF_FILES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, PR_DESCRIPTION
161
161
 
162
162
  Spawn a single global Triage agent for ALL issues:
163
163
 
@@ -169,7 +169,7 @@ WORKTREE_PATH: {worktree_path} (omit if cwd)
169
169
  DECISIONS_CONTEXT: {decisions_context}
170
170
  FEATURE_KNOWLEDGE: {feature_knowledge}
171
171
  PR_DESCRIPTION: <pr-description>{pr_description}</pr-description>
172
- Triage every issue using the blast-radius disposition matrix. Assign exactly one verdict per issue.
172
+ Triage every issue: collapse duplicates first, then apply the blast-radius disposition matrix to each group's primary. Assign exactly one verdict per issue.
173
173
  Follow devflow:apply-decisions to Read full ADR/PF bodies on demand.
174
174
  Follow devflow:apply-feature-knowledge for FEATURE_KNOWLEDGE."
175
175
  ```
@@ -181,11 +181,12 @@ Wait for Triage agent to complete before proceeding. Parse verdict ledger from T
181
181
  - **BY_DESIGN**: Intentional code (with ADR or code doc citation)
182
182
  - **FIX_SEPARATE**: Valid but out of blast-radius scope (must become manage-debt ticket)
183
183
  - **TECH_DEBT**: Architectural overhaul only — LAST RESORT
184
+ - **DUPLICATE**: Collapsed duplicate issue — carries `duplicate_of: <primary-id>` referencing the non-DUPLICATE primary; inherits the primary's outcome
184
185
 
185
186
  Collect all decisions citations (ADR-NNN / PF-NNN) from Triage agent Reasoning columns.
186
187
 
187
188
  **Triage agent completeness assertion (avoids PF-002):** Verify the parsed ledger against ISSUES before proceeding:
188
- 1. Every issue `id` from ISSUES must appear in exactly one verdict bucket — none may vanish, none may appear in multiple buckets.
189
+ 1. Every issue `id` from ISSUES must appear in exactly one verdict bucket — none may vanish, none may appear in multiple buckets. DUPLICATE is a valid bucket; a valid DUPLICATE entry must name its `duplicate_of` primary (the `Duplicate Of` column of the ledger's DUPLICATE table) and that primary must be a non-DUPLICATE issue id. A missing `duplicate_of` or one that chains to another DUPLICATE is a **Triage agent failure** (retry-then-abort as below).
189
190
  2. If the Triage agent output is empty, contains a skill re-entrancy guard string (e.g., contains `already running`), or is missing any issue IDs from ISSUES: treat as a **Triage agent failure**:
190
191
  - Retry the Triage agent once with the same inputs.
191
192
  - If the retry also fails the completeness check: abort with a clear error message listing the missing issue IDs and failure reason — never proceed with dropped issues.
@@ -199,7 +200,7 @@ Collect all decisions citations (ADR-NNN / PF-NNN) from Triage agent Reasoning c
199
200
 
200
201
  If FIX_NOW list is empty: skip to Phase 5 — write full summary (Phase 5), run manage-debt (Phase 9) if FIX_SEPARATE/TECH_DEBT exist, run thread resolution + resolution comment (Phase 9b), run merge readiness (Phase 9c), display results (Phase 10).
201
202
 
202
- Otherwise, batch FIX_NOW issues for Code agent execution:
203
+ Otherwise, batch FIX_NOW issues for Code agent execution. **DUPLICATE issues are never dispatched** — they inherit the primary's outcome:
203
204
  - **Same-file issues** → one batch (one Code agent per file, sequential for same-file pairs)
204
205
  - **Distinct-file issues** → parallel Code agents
205
206
  - **Max 5 issues per batch** — chunk large sets
@@ -207,7 +208,7 @@ Otherwise, batch FIX_NOW issues for Code agent execution:
207
208
  ### Phase 4: Fix (Code agent × N)
208
209
 
209
210
  **Produces:** CODE_AGENT_RESULTS
210
- **Requires:** BATCHES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, BRANCH_INFO
211
+ **Requires:** BATCHES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE
211
212
 
212
213
  For each batch, spawn Code agent with `OPERATION: issue-fix` and `PUSH: false`:
213
214
 
@@ -236,12 +237,14 @@ Collect from each Code agent:
236
237
  ### Phase 5: Write resolution-summary.md
237
238
 
238
239
  **Produces:** RESOLUTION_FILE (early write for compaction safety)
239
- **Requires:** TRIAGE_RESULTS, CODE_AGENT_RESULTS
240
+ **Requires:** TRIAGE_RESULTS, CODE_AGENT_RESULTS, TARGET_DIR, BRANCH_INFO
240
241
 
241
242
  **Immediately write `resolution-summary.md`** to `\{TARGET_DIR\}` using the Write tool. Do this now — not in Phase 9 — while results are fresh in context. This ensures the record is persisted even if later phases (Simplify, Verification Gate, CI gate, Tech Debt) trigger context compaction.
242
243
 
243
244
  Set `Tracked` for FIX_SEPARATE and TECH_DEBT items to `(pending)` — to be backfilled after Phase 9 manage-debt.
244
245
 
246
+ DUPLICATE issues are listed **only** in `## Duplicates` — never in `## Fixed Issues`, `## False Positives`, `## By Design`, `## Fix Separately`, `## Deferred to Tech Debt`, `## Escalations`, or `## Blocked`. A duplicate of a FALSE_POSITIVE primary therefore leaves only the primary in the `False Positive` row and the `## False Positives` section; the same holds for every other outcome the duplicate inherits.
247
+
245
248
  Use the template from the Output Artifact section below.
246
249
 
247
250
  ### Phase 6: Simplify
@@ -334,7 +337,7 @@ Otherwise, for each worktree with fixes:
334
337
 
335
338
  **IMPORTANT**: Run sequentially across all worktrees (not in parallel) to avoid GitHub API conflicts.
336
339
 
337
- If any issues are FIX_SEPARATE or TECH_DEBT, spawn Git agent:
340
+ If any issues are FIX_SEPARATE or TECH_DEBT, spawn Git agent. **DUPLICATE issues never create their own debt tickets** — a duplicate of a deferred primary is covered by the primary's ticket:
338
341
 
339
342
  ```
340
343
  Agent(subagent_type="Git"):
@@ -359,6 +362,7 @@ Skip this step if `COMPLIANCE_SKILL_INSTALLED` is false or THREAD_MAP is empty.
359
362
 
360
363
  Prepare THREAD_MAP with verdicts from triage/code agent results:
361
364
  - For each `ext-\{N\}`: match to an issue verdict (FIXED, FALSE_POSITIVE, BY_DESIGN, ESCALATED) by `file:line` correlation
365
+ - If the matched issue has verdict DUPLICATE, use the **primary's** verdict and verification status for the reply — do not expose DUPLICATE to the thread author (avoids PF-024; caller-side mapping — git.md contracts unchanged)
362
366
  - Include `commit_sha` from Code agent results for FIXED verdicts
363
367
  - Unmatched threads: ESCALATED (human review)
364
368
 
@@ -399,7 +403,7 @@ Update `## Third-Party Threads` section in resolution-summary.md with thread res
399
403
  ### Phase 9c: Merge Readiness (Compliance-gated, Report-only)
400
404
 
401
405
  **Produces:** MERGE_READINESS_REPORT
402
- **Requires:** PR_INFO, COMPLIANCE_SKILL_INSTALLED
406
+ **Requires:** BRANCH_INFO, COMPLIANCE_SKILL_INSTALLED
403
407
 
404
408
  Skip this phase if `COMPLIANCE_SKILL_INSTALLED` is false.
405
409
 
@@ -418,7 +422,7 @@ If Git agent returns `TRACEABILITY: DEGRADED`: warn, proceed to Phase 10.
418
422
 
419
423
  ### Phase 10: Report
420
424
 
421
- **Requires:** TARGET_DIR
425
+ **Requires:** BRANCH_INFO, TARGET_DIR
422
426
 
423
427
  The resolution summary was already written to `\{TARGET_DIR\}/resolution-summary.md` in Phase 5 (updated by Phase 7 and Phase 9). Display results to the user:
424
428
 
@@ -438,6 +442,7 @@ The resolution summary was already written to `\{TARGET_DIR\}/resolution-summary
438
442
  | Deferred | {n} |
439
443
  | Blocked | {n} |
440
444
  | Escalated | {n} |
445
+ | Duplicates Collapsed | {n} |
441
446
 
442
447
  ### Verification
443
448
  Final gate: {PASS | FAILED after N attempts}
@@ -483,9 +488,9 @@ In multi-worktree mode, report results per worktree with aggregate summary.
483
488
  │ └─ Git agent (fetch-review-threads) → THREAD_MAP
484
489
  │
485
490
  ├─ Phase 2: Global Triage [Triage agent, opus, single agent]
486
- │ └─ ALL issues → verdict ledger by disposition
491
+ │ └─ ALL issues → verdict ledger by disposition (incl. DUPLICATE with duplicate_of)
487
492
  │
488
- ├─ Phase 3: Batch FIX_NOW issues (skip if empty)
493
+ ├─ Phase 3: Batch FIX_NOW issues (skip if empty; DUPLICATE issues never dispatched)
489
494
  │ └─ same-file sequential, distinct-file parallel, max 5/batch
490
495
  │
491
496
  ├─ Phase 4: Fix [Code agent × N, OPERATION: issue-fix, PUSH: false]
@@ -528,6 +533,8 @@ In multi-worktree mode, report results per worktree with aggregate summary.
528
533
  | Worktree pre-flight fails | Report failure, continue with other worktrees |
529
534
  | Empty FIX_NOW list | Skip Phases 3-4/6-8; still write full summary + run manage-debt if FIX_SEPARATE/TECH_DEBT exist |
530
535
  | ESCALATED security issues | Surfaced in ## Escalations + display callout; never routed to manage-debt |
536
+ | DUPLICATE verdict without duplicate_of, or chained to another DUPLICATE | Treated as Triage failure — same retry-then-abort as a vanished id |
537
+ | DUPLICATE issues in THREAD_MAP | Map ext-\{N\} to primary's verdict/verification status for thread reply |
531
538
  | Verification Gate FAILED after 2 attempts | Recorded as FAILED in ## Verification + blocking callout; CI gate skipped; proceed to Phase 9 (manage-debt) then Phase 10 (display) |
532
539
  | gh/GitHub absent | manage-debt fails gracefully; Tracked stays "(pending)" + noted — recorded, not dropped |
533
540
  | COMPLIANCE_SKILL_INSTALLED false | Phases 1b, 9b-step-1, and 9c are skipped; post-resolution-summary (Phase 9b step 2) still runs if a PR is known |
@@ -556,7 +563,7 @@ Written in Phase 5 (Collect Results) to `\{TARGET_DIR\}/resolution-summary.md`:
556
563
  ```markdown
557
564
  # Resolution Summary
558
565
 
559
- **Branch**: {branch} -> {base}
566
+ **Branch**: {branch} -> {base_branch}
560
567
  **Date**: {timestamp}
561
568
  **Review**: {TARGET_DIR}
562
569
  **Command**: /resolve
@@ -578,8 +585,9 @@ Written in Phase 5 (Collect Results) to `\{TARGET_DIR\}/resolution-summary.md`:
578
585
  | Deferred | {n} |
579
586
  | Blocked | {n} |
580
587
  | Escalated | {n} |
588
+ | Duplicates Collapsed | {n} |
581
589
 
582
- _(Note: `Deferred` = `## Fix Separately` count + `## Deferred to Tech Debt` count combined — the two sections are distinct by scope, but the Statistics row aggregates both for the convergence parser.)_
590
+ _(Note: `Deferred` = `## Fix Separately` count + `## Deferred to Tech Debt` count combined — the two sections are distinct by scope, but the Statistics row aggregates both for the convergence parser. `Total Issues` counts every triaged issue including collapsed duplicates; every row **between** `Total Issues` and `Duplicates Collapsed` counts UNIQUE (non-DUPLICATE) issues only, so `Total Issues` equals the sum of the rows below it. Excluding duplicates from `Fixed`, `False Positive`, and `Deferred` de-skews the fp\_ratio convergence formula in code-review without any parser change.)_
583
591
 
584
592
  ## Verification
585
593
  | Command | Result |
@@ -625,6 +633,11 @@ Final gate: PASS | FAILED after {n} attempts
625
633
  |-------|-----------|---------|
626
634
  | {description} | {file}:{line} | {why} |
627
635
 
636
+ ## Duplicates
637
+ | Issue | Duplicate Of | File:Line |
638
+ |-------|-------------|-----------|
639
+ | {description} | {primary-id} | {file}:{line} |
640
+
628
641
  ## Third-Party Threads
629
642
  | Thread | File:Line | Verdict | Status |
630
643
  |--------|-----------|---------|--------|
@@ -633,4 +646,4 @@ Final gate: PASS | FAILED after {n} attempts
633
646
 
634
647
  _(Omit `## Third-Party Threads` if `COMPLIANCE_SKILL_INSTALLED` is false or no external threads were found.)_
635
648
 
636
- **Statistics mapping (parser contract)**: the `Deferred` row = FIX_SEPARATE + TECH_DEBT (both deferral dispositions combined); By Design and Escalated are counted separately and excluded from `Deferred`. The `/code-review` convergence parser reads the `Deferred`, `Fixed`, and `False Positive` Statistics rows plus the `## Fixed Issues` / `## False Positives` headings — keep those labels byte-stable.
649
+ **Statistics mapping (parser contract)**: the `Deferred` row = FIX_SEPARATE + TECH_DEBT (both deferral dispositions combined); By Design and Escalated are counted separately and excluded from `Deferred`. The `Duplicates Collapsed` row is additive — the `/code-review` convergence parser reads only `Deferred`, `Fixed`, and `False Positive` rows plus `## Fixed Issues` / `## False Positives` headings — keep those labels byte-stable. All rows that the parser reads count UNIQUE (non-DUPLICATE) issues only, so collapsed duplicates do not inflate fp\_ratio.