instar 1.3.11 → 1.3.12

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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * transitions.ts — TS port of the feedback-factory lifecycle state machine.
3
+ *
4
+ * Scar (a) (the evidence gate, processor side) + part of scar (c)/(d). Byte-exact
5
+ * port of `V2_STATES` (:1000), `V2_TRANSITIONS` (:1009), `TRANSITION_GATES`
6
+ * (:1028), `can_transition` (:1045), and `detect_cycling` (:1139) from the
7
+ * reference `the-portal/.claude/scripts/feedback-processor.py`. These are the
8
+ * pure decision functions: which lifecycle transitions are legal, what evidence
9
+ * a terminal transition demands, the chronic circuit-breaker, and whether a
10
+ * cluster is cycling. The DB-coupled drivers (cmd_transition, the version-anchored
11
+ * half of can_transition_to_verified) are later increments — this module is the
12
+ * pure, parity-testable core.
13
+ *
14
+ * The reason strings interpolate Python's `sorted(set)` list-repr; pyListRepr()
15
+ * reproduces it (single-quoted, comma-space, alphabetical) so the parity harness
16
+ * matches BOTH the `allowed` decision and the `reason` text byte-for-byte.
17
+ */
18
+ /** Python V2_STATES (:1000) — set membership only; order irrelevant. */
19
+ export declare const V2_STATES: ReadonlySet<string>;
20
+ /** Python V2_TRANSITIONS (:1009) — allowed target states per current state. */
21
+ export declare const V2_TRANSITIONS: Readonly<Record<string, ReadonlySet<string>>>;
22
+ /** Python TRANSITION_GATES (:1028) — extra hard gates beyond state legality. */
23
+ export declare const TRANSITION_GATES: Readonly<Record<string, {
24
+ required_context: string;
25
+ error: string;
26
+ }>>;
27
+ export interface TransitionContext {
28
+ dispatch_id?: string;
29
+ recurrenceCount?: number;
30
+ [k: string]: unknown;
31
+ }
32
+ /**
33
+ * Port of Python `can_transition` (:1045). Returns `[allowed, reason]`.
34
+ * Pure: no I/O. Mirrors the reference's check order exactly (invalid target →
35
+ * unknown current → illegal transition → evidence gate → hard gate → chronic
36
+ * circuit-breaker), so the first-failing reason matches the reference.
37
+ */
38
+ export declare function canTransition(currentStatus: string, newStatus: string, justification?: string | null, context?: TransitionContext | null): [boolean, string];
39
+ /** Port of Python `detect_cycling` (:1139): fix_applied/new/investigating with recurrenceCount ≥ 2. */
40
+ export declare function detectCycling(cluster: {
41
+ status?: string;
42
+ recurrenceCount?: number;
43
+ }): boolean;
44
+ //# sourceMappingURL=transitions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transitions.d.ts","sourceRoot":"","sources":["../../../src/feedback-factory/processor/transitions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,wEAAwE;AACxE,eAAO,MAAM,SAAS,EAAE,WAAW,CAAC,MAAM,CAMxC,CAAC;AAEH,+EAA+E;AAC/E,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,CAexE,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE;IAAE,gBAAgB,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAKlG,CAAC;AAUF,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,EAC7B,OAAO,CAAC,EAAE,iBAAiB,GAAG,IAAI,GACjC,CAAC,OAAO,EAAE,MAAM,CAAC,CAsCnB;AAED,uGAAuG;AACvG,wBAAgB,aAAa,CAAC,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAM7F"}
@@ -0,0 +1,102 @@
1
+ /**
2
+ * transitions.ts — TS port of the feedback-factory lifecycle state machine.
3
+ *
4
+ * Scar (a) (the evidence gate, processor side) + part of scar (c)/(d). Byte-exact
5
+ * port of `V2_STATES` (:1000), `V2_TRANSITIONS` (:1009), `TRANSITION_GATES`
6
+ * (:1028), `can_transition` (:1045), and `detect_cycling` (:1139) from the
7
+ * reference `the-portal/.claude/scripts/feedback-processor.py`. These are the
8
+ * pure decision functions: which lifecycle transitions are legal, what evidence
9
+ * a terminal transition demands, the chronic circuit-breaker, and whether a
10
+ * cluster is cycling. The DB-coupled drivers (cmd_transition, the version-anchored
11
+ * half of can_transition_to_verified) are later increments — this module is the
12
+ * pure, parity-testable core.
13
+ *
14
+ * The reason strings interpolate Python's `sorted(set)` list-repr; pyListRepr()
15
+ * reproduces it (single-quoted, comma-space, alphabetical) so the parity harness
16
+ * matches BOTH the `allowed` decision and the `reason` text byte-for-byte.
17
+ */
18
+ /** Python V2_STATES (:1000) — set membership only; order irrelevant. */
19
+ export const V2_STATES = new Set([
20
+ 'new', 'investigating', 'research_complete', 'fix_applied',
21
+ 'dispatched', 'verified_tentative', 'verified', 'closed',
22
+ 'chronic', 'chronic_escalated', 'deferred',
23
+ 'wontfix', 'duplicate',
24
+ 'needs_human_verify', // legacy (accepted for backward compat)
25
+ ]);
26
+ /** Python V2_TRANSITIONS (:1009) — allowed target states per current state. */
27
+ export const V2_TRANSITIONS = {
28
+ new: new Set(['investigating', 'wontfix', 'duplicate', 'deferred']),
29
+ investigating: new Set(['research_complete', 'wontfix', 'duplicate', 'deferred']),
30
+ research_complete: new Set(['fix_applied', 'investigating']),
31
+ fix_applied: new Set(['dispatched', 'chronic']),
32
+ dispatched: new Set(['verified', 'verified_tentative', 'chronic']),
33
+ verified_tentative: new Set(['verified', 'closed', 'chronic']),
34
+ verified: new Set(['closed', 'chronic']),
35
+ chronic: new Set(['investigating', 'chronic_escalated', 'wontfix']),
36
+ deferred: new Set(['new']),
37
+ chronic_escalated: new Set(),
38
+ closed: new Set(),
39
+ wontfix: new Set(),
40
+ duplicate: new Set(),
41
+ needs_human_verify: new Set(['verified', 'wontfix', 'duplicate']),
42
+ };
43
+ /** Python TRANSITION_GATES (:1028) — extra hard gates beyond state legality. */
44
+ export const TRANSITION_GATES = {
45
+ dispatched: {
46
+ required_context: 'dispatch_id',
47
+ error: 'Cannot transition to dispatched without dispatch_id. Dispatch creation is ATOMIC with dispatch transition.',
48
+ },
49
+ };
50
+ const EVIDENCE_REQUIRED = new Set(['wontfix', 'closed', 'chronic_escalated']);
51
+ /** Reproduce Python's `repr(sorted(setOfStrings))` for simple identifier strings. */
52
+ function pyListRepr(values) {
53
+ const sorted = [...values].sort();
54
+ return '[' + sorted.map((v) => `'${v}'`).join(', ') + ']';
55
+ }
56
+ /**
57
+ * Port of Python `can_transition` (:1045). Returns `[allowed, reason]`.
58
+ * Pure: no I/O. Mirrors the reference's check order exactly (invalid target →
59
+ * unknown current → illegal transition → evidence gate → hard gate → chronic
60
+ * circuit-breaker), so the first-failing reason matches the reference.
61
+ */
62
+ export function canTransition(currentStatus, newStatus, justification, context) {
63
+ const ctx = context ?? {};
64
+ if (!V2_STATES.has(newStatus)) {
65
+ return [false, `Invalid state: ${newStatus}. Valid: ${pyListRepr(V2_STATES)}`];
66
+ }
67
+ if (!(currentStatus in V2_TRANSITIONS)) {
68
+ return [false, `Unknown current state: ${currentStatus}`];
69
+ }
70
+ const allowedTargets = V2_TRANSITIONS[currentStatus];
71
+ if (!allowedTargets.has(newStatus)) {
72
+ // Python: f"... Allowed: {sorted(...) or 'none (terminal)'}" — empty list is falsy.
73
+ const allowedStr = allowedTargets.size > 0 ? pyListRepr(allowedTargets) : 'none (terminal)';
74
+ return [false, `Cannot transition ${currentStatus} -> ${newStatus}. Allowed: ${allowedStr}`];
75
+ }
76
+ if (EVIDENCE_REQUIRED.has(newStatus)) {
77
+ if (!justification || justification.trim().length < 20) {
78
+ return [false, `Transitioning to '${newStatus}' requires justification (min 20 chars)`];
79
+ }
80
+ }
81
+ if (newStatus in TRANSITION_GATES) {
82
+ const gate = TRANSITION_GATES[newStatus];
83
+ const requiredKey = gate.required_context;
84
+ if (requiredKey && !ctx[requiredKey]) {
85
+ return [false, gate.error];
86
+ }
87
+ }
88
+ if (newStatus === 'chronic') {
89
+ const recurrence = ctx.recurrenceCount ?? 0;
90
+ if (recurrence >= 3) {
91
+ return [false, `chronicCount (${recurrence}) >= 3. Must transition to chronic_escalated instead (circuit breaker).`];
92
+ }
93
+ }
94
+ return [true, 'OK'];
95
+ }
96
+ /** Port of Python `detect_cycling` (:1139): fix_applied/new/investigating with recurrenceCount ≥ 2. */
97
+ export function detectCycling(cluster) {
98
+ const status = cluster.status;
99
+ return ((status === 'fix_applied' || status === 'new' || status === 'investigating') &&
100
+ (cluster.recurrenceCount ?? 0) >= 2);
101
+ }
102
+ //# sourceMappingURL=transitions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transitions.js","sourceRoot":"","sources":["../../../src/feedback-factory/processor/transitions.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.11",
3
+ "version": "1.3.12",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """Parity reference: lifecycle transitions from the REAL feedback-processor.py.
3
+
4
+ Imports the reference processor without running its CLI main and calls its actual
5
+ `can_transition` + `detect_cycling` over the shared corpus. Emits JSON so the Node
6
+ harness can diff the TS port (both the allowed decision AND the reason string).
7
+
8
+ Usage: python3 _py_transitions_ref.py <processor_path> <corpus_path>
9
+ """
10
+ import sys
11
+ import json
12
+ import importlib.util
13
+
14
+
15
+ def load_processor(path):
16
+ spec = importlib.util.spec_from_file_location("_feedback_processor_ref_tx", path)
17
+ mod = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(mod)
19
+ return mod
20
+
21
+
22
+ def main():
23
+ if len(sys.argv) != 3:
24
+ print("usage: _py_transitions_ref.py <processor_path> <corpus_path>", file=sys.stderr)
25
+ sys.exit(2)
26
+ processor_path, corpus_path = sys.argv[1], sys.argv[2]
27
+ proc = load_processor(processor_path)
28
+ with open(corpus_path, encoding="utf-8") as f:
29
+ corpus = json.load(f)
30
+
31
+ tx_out = []
32
+ for case in corpus["transitions"]:
33
+ allowed, reason = proc.can_transition(
34
+ case["current"],
35
+ case["new"],
36
+ case.get("justification"),
37
+ case.get("context"),
38
+ )
39
+ tx_out.append({"case": case, "allowed": allowed, "reason": reason})
40
+
41
+ cyc_out = []
42
+ for cluster in corpus["cycling"]:
43
+ cyc_out.append({"cluster": cluster, "cycling": proc.detect_cycling(cluster)})
44
+
45
+ sys.stdout.write(json.dumps({"transitions": tx_out, "cycling": cyc_out}, ensure_ascii=False))
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,40 @@
1
+ {
2
+ "_comment": "Parity corpus for the lifecycle state-machine port. `transitions`: cases for can_transition {current,new,justification?,context?} → [allowed, reason]. `cycling`: cases for detect_cycling {cluster} → bool. Covers legal/illegal transitions, terminal states, the evidence gate (both sides of 20 chars), the dispatch hard gate, and the chronic circuit-breaker (recurrence 2 vs 3).",
3
+ "transitions": [
4
+ { "current": "new", "new": "investigating" },
5
+ { "current": "new", "new": "verified" },
6
+ { "current": "investigating", "new": "research_complete" },
7
+ { "current": "research_complete", "new": "investigating" },
8
+ { "current": "fix_applied", "new": "dispatched", "context": { "dispatch_id": "dsp-123" } },
9
+ { "current": "fix_applied", "new": "dispatched" },
10
+ { "current": "fix_applied", "new": "verified" },
11
+ { "current": "closed", "new": "investigating" },
12
+ { "current": "chronic_escalated", "new": "investigating" },
13
+ { "current": "wontfix", "new": "new" },
14
+ { "current": "bogus_state", "new": "investigating" },
15
+ { "current": "new", "new": "not_a_real_state" },
16
+ { "current": "investigating", "new": "wontfix" },
17
+ { "current": "investigating", "new": "wontfix", "justification": "too short" },
18
+ { "current": "investigating", "new": "wontfix", "justification": "this is a sufficiently long justification over twenty chars" },
19
+ { "current": "verified", "new": "closed", "justification": "short" },
20
+ { "current": "verified", "new": "closed", "justification": "verified stable for 30 days, closing per policy" },
21
+ { "current": "chronic", "new": "chronic_escalated", "justification": "escalating after repeated regressions across three releases" },
22
+ { "current": "verified", "new": "chronic" },
23
+ { "current": "verified", "new": "chronic", "context": { "recurrenceCount": 2 } },
24
+ { "current": "verified", "new": "chronic", "context": { "recurrenceCount": 3 } },
25
+ { "current": "fix_applied", "new": "chronic", "context": { "recurrenceCount": 5 } },
26
+ { "current": "needs_human_verify", "new": "verified" },
27
+ { "current": "deferred", "new": "new" }
28
+ ],
29
+ "cycling": [
30
+ { "status": "fix_applied", "recurrenceCount": 2 },
31
+ { "status": "fix_applied", "recurrenceCount": 1 },
32
+ { "status": "new", "recurrenceCount": 3 },
33
+ { "status": "investigating", "recurrenceCount": 2 },
34
+ { "status": "investigating", "recurrenceCount": 0 },
35
+ { "status": "verified", "recurrenceCount": 5 },
36
+ { "status": "dispatched", "recurrenceCount": 9 },
37
+ { "status": "fix_applied" },
38
+ { "recurrenceCount": 4 }
39
+ ]
40
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * transitions-parity.mjs — LOCAL parity gate for the lifecycle state-machine port.
4
+ *
5
+ * Runs the REAL reference Python (`can_transition` + `detect_cycling` from
6
+ * the-portal/.claude/scripts/feedback-processor.py) and the TS port over the
7
+ * shared corpus and asserts BOTH the `allowed` decision AND the `reason` string
8
+ * match byte-for-byte (the reason interpolates Python's sorted-list repr, which
9
+ * the port reproduces). LOCAL evidence gate, not CI (CI lacks the reference).
10
+ *
11
+ * Exit 0 = parity; exit 1 = mismatch.
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = path.resolve(__dirname, '..', '..');
21
+
22
+ const PROCESSOR = process.env.PORTAL_PROCESSOR
23
+ || '/Users/justin/Documents/Projects/the-portal/.claude/scripts/feedback-processor.py';
24
+ const CORPUS = path.join(__dirname, 'transitions-corpus.json');
25
+ const PY_REF = path.join(__dirname, '_py_transitions_ref.py');
26
+
27
+ if (!fs.existsSync(PROCESSOR)) {
28
+ console.error(`[tx-parity] reference processor not found: ${PROCESSOR}`);
29
+ console.error('[tx-parity] set PORTAL_PROCESSOR to the reference feedback-processor.py');
30
+ process.exit(2);
31
+ }
32
+
33
+ const py = spawnSync('python3', [PY_REF, PROCESSOR, CORPUS], { encoding: 'utf8' });
34
+ if (py.status !== 0) {
35
+ console.error('[tx-parity] python reference failed:\n', py.stderr || py.stdout);
36
+ process.exit(2);
37
+ }
38
+ const reference = JSON.parse(py.stdout);
39
+
40
+ const distUrl = new URL('file://' + path.join(ROOT, 'dist', 'feedback-factory', 'processor', 'transitions.js'));
41
+ const { canTransition, detectCycling } = await import(distUrl.href);
42
+
43
+ let mismatches = 0;
44
+
45
+ for (const ref of reference.transitions) {
46
+ const c = ref.case;
47
+ const [allowed, reason] = canTransition(c.current, c.new, c.justification ?? null, c.context ?? null);
48
+ if (allowed !== ref.allowed || reason !== ref.reason) {
49
+ mismatches++;
50
+ console.error(`[MISMATCH tx] ${JSON.stringify(c)}`);
51
+ console.error(` python: allowed=${ref.allowed} reason=${JSON.stringify(ref.reason)}`);
52
+ console.error(` ts : allowed=${allowed} reason=${JSON.stringify(reason)}`);
53
+ }
54
+ }
55
+
56
+ for (const ref of reference.cycling) {
57
+ const got = detectCycling(ref.cluster);
58
+ if (got !== ref.cycling) {
59
+ mismatches++;
60
+ console.error(`[MISMATCH cycling] ${JSON.stringify(ref.cluster)} — python=${ref.cycling} ts=${got}`);
61
+ }
62
+ }
63
+
64
+ const total = reference.transitions.length + reference.cycling.length;
65
+ if (mismatches === 0) {
66
+ console.error(`[tx-parity] ✓ ${total}/${total} transition decisions + reasons + cycling results match the reference Python.`);
67
+ process.exit(0);
68
+ } else {
69
+ console.error(`[tx-parity] ✗ ${mismatches}/${total} mismatches — the TS port diverges from the reference.`);
70
+ process.exit(1);
71
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-05-27T05:23:34.820Z",
5
- "instarVersion": "1.3.11",
4
+ "generatedAt": "2026-05-27T05:27:08.240Z",
5
+ "instarVersion": "1.3.12",
6
6
  "entryCount": 192,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,26 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+
5
+ ## What Changed
6
+
7
+ Third increment of the **Feedback Factory Migration** (Dawn → Echo; spec `docs/specs/feedback-factory-migration.md`, approved). Ports the **lifecycle state machine** — `can_transition` + `detect_cycling` and their state/transition/gate constants — from the reference Python (`the-portal/.claude/scripts/feedback-processor.py`) to TypeScript at `src/feedback-factory/processor/transitions.ts`.
8
+
9
+ This is the heart of the evidence gate: the rules for how a bug cluster legally moves between lifecycle states (new → investigating → research_complete → fix_applied → dispatched → verified → closed, plus chronic/escalated/deferred/wontfix/duplicate), the requirement that any terminal transition (wontfix/closed/chronic_escalated) carry a ≥20-character justification, the atomic dispatch hard-gate, and the chronic circuit-breaker that blocks `chronic` once a bug has recurred 3+ times (forcing human escalation instead). Plus the cycling detector. Pure functions; **not wired into any route or job yet** — no behavioral change.
10
+
11
+ ## What to Tell Your User
12
+
13
+ - More of the feedback "brain" moved in-house — this is the part that enforces "you can't quietly mark a bug closed without writing down why," and the circuit-breaker that escalates a bug to a human once it keeps coming back.
14
+ - Same discipline: I proved my rewrite makes the exact same allow/deny decisions as Dawn's original — and even produces the exact same explanation text — across 33 cases.
15
+
16
+ ## Summary of New Capabilities
17
+
18
+ | Capability | How to Use |
19
+ |-----------|-----------|
20
+ | Lifecycle state machine (TS port) | Internal module `src/feedback-factory/processor/transitions.ts` — not yet wired |
21
+ | Transitions parity harness | `node scripts/feedback-factory/transitions-parity.mjs` (local; set `PORTAL_PROCESSOR`) |
22
+
23
+ ## Evidence
24
+
25
+ - **Parity vs the real reference Python:** ran `can_transition` + `detect_cycling` from the reference processor and the TS port over 33 cases (legal/illegal transitions, terminal states, the evidence gate on both sides of 20 chars, the dispatch hard-gate, the chronic circuit-breaker at recurrence 2 vs 3, cycling). Result: **33/33 match** — both the allow/deny decision AND the exact reason text (the reasons interpolate Python's sorted-list formatting, which the port reproduces byte-for-byte), plus all cycling results.
26
+ - **CI anchors:** unit tests assert the evidence gate, the circuit-breaker, terminal-state handling, and that every transition target is a known state — so a state-machine regression fails in CI even without the reference checkout.
@@ -0,0 +1,34 @@
1
+ # Side-Effects Review — feedback-factory lifecycle state machine (Phase 1, increment 3)
2
+
3
+ **Slug:** `feedback-factory-transitions`
4
+ **Date:** `2026-05-26`
5
+ **Author:** Echo
6
+ **Spec:** `docs/specs/feedback-factory-migration.md` (converged v2, approved by Justin 2026-05-26)
7
+ **Scope:** The pure lifecycle decision logic — `V2_STATES`/`V2_TRANSITIONS`/`TRANSITION_GATES` constants, `can_transition` (:1045, scar a's evidence gate + the state machine + chronic circuit-breaker), and `detect_cycling` (:1139). The DB-coupled drivers (`cmd_transition`, the version-anchored half of `can_transition_to_verified`) are later increments.
8
+
9
+ ## Summary of the change
10
+
11
+ Byte-exact port of the above from `the-portal/.claude/scripts/feedback-processor.py` to `src/feedback-factory/processor/transitions.ts`. Pure functions, no I/O. **Not wired into any route/job yet** — no behavioral change. Adds the transitions parity harness + Tier-1 unit tests.
12
+
13
+ This is the heart of scar (a): the evidence gate (terminal transitions to `wontfix`/`closed`/`chronic_escalated` require ≥20-char justification — unified with the `clusters.ts` API hard gate) and the chronic circuit-breaker (auto-block `chronic` at `recurrenceCount ≥ 3`, forcing `chronic_escalated`).
14
+
15
+ ## Equivalence verification
16
+
17
+ - **33/33 cases match the reference Python** — both the `allowed` decision AND the exact `reason` string (the reasons interpolate Python's `sorted(set)` list-repr; the port reproduces it via `pyListRepr` so even the diagnostic text is byte-identical), plus all `detect_cycling` results.
18
+ - The check ORDER is preserved exactly (invalid target → unknown current → illegal transition → evidence gate → hard gate → chronic breaker), so the first-failing reason matches the reference for every case.
19
+
20
+ ## Seven-dimension review
21
+
22
+ 1. **Over/under-reach** — Pure deterministic functions, no I/O, no global state, not imported by any runtime path. Cannot affect existing behavior. The constants are exported read-only.
23
+ 2. **Level-of-abstraction fit** — Processor-logic layer, alongside the fingerprint + similarity ports. Correct home. The state machine is data (constants) + a pure validator — no DB coupling, which is why it ports cleanly now (the DB-coupled verification driver waits for the data layer).
24
+ 3. **Signal vs Authority** — `can_transition` is the structural evidence gate (scar a): it *enforces* that terminal transitions carry evidence, mirroring the `clusters.ts` API hard gate. This is legitimate authority that already exists in the reference (not new authority introduced by the port). The processor still never force-closes; it validates.
25
+ 4. **Interactions** — None. New isolated module; nothing imports it yet. Parity scripts are LOCAL-only (external reference path via `PORTAL_PROCESSOR`).
26
+ 5. **Rollback cost** — Trivial: delete the module + tests + scripts.
27
+ 6. **Migration parity** — N/A. New internal library code; touches no agent-installed file.
28
+ 7. **Failure modes** — (a) Port diverges from reference → caught by the parity harness (33/33, decisions+reasons+cycling) + unit assertions in CI. (b) A new lifecycle state added to the reference but not the port → the "every transition target is a known state" unit test + future parity runs catch drift; Dawn's review is the human backstop. (c) Reason-string repr divergence → parity asserts reasons byte-for-byte.
29
+
30
+ ## Tests
31
+
32
+ - Tier-1 unit (CI): `tests/unit/feedback-factory/transitions.test.ts` — state legality, terminal states, evidence gate (both sides of 20 chars), dispatch hard gate, chronic circuit-breaker (recurrence 2 vs 3), cycling detection, constants integrity. 12 tests.
33
+ - Parity (local gate, evidence): `scripts/feedback-factory/transitions-parity.mjs` → **33/33** decisions + reasons + cycling identical to the reference Python.
34
+ - No integration/E2E this increment: not yet wired to a route/job; those tiers attach with the clustering/transition driver. Reasoned decision, documented.