instar 1.3.190 → 1.3.192

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.
@@ -28,13 +28,15 @@ import path from 'node:path';
28
28
  import crypto from 'node:crypto';
29
29
  import { execSync } from 'node:child_process';
30
30
  import { fileURLToPath } from 'node:url';
31
- import { checkEli16Overview } from './eli16-overview-check.mjs';
31
+ import { checkEli16Overview, MIN_ELI16_CHARS } from './eli16-overview-check.mjs';
32
32
  import { verifyProposalDerivedRunbooks } from '../skills/instar-dev/scripts/verify-proposal-derived-runbook.mjs';
33
+ import { classifyTier, decideRequirementSet } from './lib/classify-tier.mjs';
33
34
 
34
35
  const __filename = fileURLToPath(import.meta.url);
35
36
  const __dirname = path.dirname(__filename);
36
37
  const ROOT = path.resolve(__dirname, '..');
37
38
  const TRACES_DIR = path.join(ROOT, '.instar', 'instar-dev-traces');
39
+ const DECISIONS_LOG = path.join(ROOT, '.instar', 'instar-dev-decisions.jsonl');
38
40
  const WINDOW_MS = 60 * 60 * 1000; // 60 minutes
39
41
  const MIN_ARTIFACT_CHARS = 200;
40
42
 
@@ -135,6 +137,72 @@ if (bootstrapTrigger) {
135
137
  process.exit(0);
136
138
  }
137
139
 
140
+ // ─── Step 3.5: compute + surface the tier signal ─────────────────────────
141
+ // Pure classifier (scripts/lib/classify-tier.mjs). SIGNAL ONLY — the gate
142
+ // SURFACES the suggestion; the agent DECLARES the real tier in its trace. The
143
+ // classifier never decides for the agent and never blocks. (The Body and the
144
+ // Mind: the body informs, the mind decides, the decision is audited.)
145
+
146
+ let tierSignal = { suggestedTier: 2, sizeTier: 2, riskFloor: 1, reasons: [] };
147
+ let totalChangedLoc = 0;
148
+ {
149
+ let addedLines = 0;
150
+ let deletedLines = 0;
151
+ let addedDiffText = '';
152
+ try {
153
+ const numstat = execSync(
154
+ `git diff --cached --numstat -- ${inScopeFiles.map((f) => JSON.stringify(f)).join(' ')}`,
155
+ { cwd: ROOT, encoding: 'utf8' },
156
+ );
157
+ for (const line of numstat.split('\n')) {
158
+ const trimmed = line.trim();
159
+ if (!trimmed) continue;
160
+ const [a, d] = trimmed.split('\t');
161
+ // Binary files show "-\t-"; treat as 0 LOC.
162
+ addedLines += a === '-' ? 0 : parseInt(a, 10) || 0;
163
+ deletedLines += d === '-' ? 0 : parseInt(d, 10) || 0;
164
+ }
165
+ } catch {
166
+ // If numstat fails, leave LOC at 0 — sizeTier becomes 1, riskFloor still
167
+ // governs. We never crash the gate over a diff-stat hiccup.
168
+ }
169
+ try {
170
+ // Added-line-only diff text feeds the new-capability heuristic. We pull the
171
+ // full staged diff and keep only the added (`+`) lines, stripping the `+`.
172
+ const fullDiff = execSync(
173
+ `git diff --cached -- ${inScopeFiles.map((f) => JSON.stringify(f)).join(' ')}`,
174
+ { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
175
+ );
176
+ addedDiffText = fullDiff
177
+ .split('\n')
178
+ .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
179
+ .map((l) => l.slice(1))
180
+ .join('\n');
181
+ } catch {
182
+ // No added-diff text → classifier SKIPS the new-capability check (per spec).
183
+ addedDiffText = '';
184
+ }
185
+
186
+ totalChangedLoc = addedLines + deletedLines;
187
+
188
+ tierSignal = classifyTier({
189
+ inScopeFiles,
190
+ addedLines,
191
+ deletedLines,
192
+ addedDiffText: addedDiffText || undefined,
193
+ });
194
+
195
+ console.error(
196
+ `[instar-dev-precommit] tier signal: suggestedTier=${tierSignal.suggestedTier} ` +
197
+ `(size=${tierSignal.sizeTier}, riskFloor=${tierSignal.riskFloor}, ` +
198
+ `${addedLines + deletedLines} LOC across ${inScopeFiles.length} file(s))`,
199
+ );
200
+ if (tierSignal.reasons.length > 0) {
201
+ console.error('[instar-dev-precommit] risk-floor reasons:');
202
+ for (const r of tierSignal.reasons) console.error(` • ${r}`);
203
+ }
204
+ }
205
+
138
206
  // ─── Step 4: find a fresh trace ──────────────────────────────────────────
139
207
 
140
208
  if (!fs.existsSync(TRACES_DIR)) {
@@ -158,6 +226,75 @@ if (traceEntries.length === 0) {
158
226
  );
159
227
  }
160
228
 
229
+ // ─── Step 4.5: read the agent's DECLARED tier + branch ───────────────────
230
+ // The agent records its tier in the trace JSON. We peek the freshest fresh
231
+ // trace to read `trace.tier` (1|2|3). decideRequirementSet() factors the pure
232
+ // enforcement decision:
233
+ // - tier 1 → 'tier1-lite' (ELI16 + side-effects staged; no spec)
234
+ // - tier 2 / 3 → 'tier2-full' (the EXISTING full validation, unchanged)
235
+ // - missing / no tier → 'tier2-full' (back-compat default — behaves as today)
236
+
237
+ let declaredTier = null;
238
+ let tierReasoning = '';
239
+ let freshestTrace = null;
240
+ try {
241
+ freshestTrace = JSON.parse(fs.readFileSync(traceEntries[0].file, 'utf8'));
242
+ if (freshestTrace && (freshestTrace.tier === 1 || freshestTrace.tier === 2 || freshestTrace.tier === 3)) {
243
+ declaredTier = freshestTrace.tier;
244
+ }
245
+ if (freshestTrace && typeof freshestTrace.tierReasoning === 'string') {
246
+ tierReasoning = freshestTrace.tierReasoning;
247
+ }
248
+ } catch {
249
+ // Malformed freshest trace → declaredTier stays null → Tier-2 back-compat
250
+ // path (the existing Step 5 loop will surface the malformed-JSON attempt).
251
+ }
252
+
253
+ const slug = (freshestTrace && (freshestTrace.slug || freshestTrace.name)) || 'unknown';
254
+ const decision = decideRequirementSet(declaredTier);
255
+
256
+ // AUDIT (all in-scope cases): one JSON line, written regardless of branch.
257
+ // belowFloor = the agent declared UNDER the risk-signaled floor. We never
258
+ // block on it (the mind holds authority) — the record is the backstop.
259
+ const belowFloor = declaredTier != null && declaredTier < tierSignal.riskFloor;
260
+ writeDecisionAudit({
261
+ slug,
262
+ suggestedTier: tierSignal.suggestedTier,
263
+ declaredTier,
264
+ riskFloor: tierSignal.riskFloor,
265
+ riskFloorReasons: tierSignal.reasons,
266
+ belowFloor,
267
+ files: inScopeFiles.length,
268
+ loc: totalChangedLoc,
269
+ });
270
+ if (belowFloor) {
271
+ console.error('');
272
+ console.error('┌──────────────────────────────────────────────────────────────────┐');
273
+ console.error('│ ⚠ BELOW RISK FLOOR — declared tier is under the risk-signaled │');
274
+ console.error('│ floor. NOT blocked (you hold authority), but recorded. │');
275
+ console.error('└──────────────────────────────────────────────────────────────────┘');
276
+ console.error(` declared Tier ${declaredTier} < risk floor ${tierSignal.riskFloor}. Risk signals:`);
277
+ for (const r of tierSignal.reasons) console.error(` • ${r}`);
278
+ if (tierReasoning) console.error(` Your tierReasoning: ${tierReasoning}`);
279
+ console.error(` Recorded to ${path.relative(ROOT, DECISIONS_LOG)} (belowFloor:true).`);
280
+ console.error('');
281
+ }
282
+
283
+ // ─── Step 4.6: Tier-1 lite path ──────────────────────────────────────────
284
+ // When the agent declared Tier 1, the requirement set is intentionally lighter:
285
+ // a staged ELI16 (the "request" ELI16) + a staged side-effects artifact
286
+ // (sha-matched if the trace records a sha) — and NO converged/approved spec.
287
+ // (The tests/lint requirement from the spec lives in the pre-PUSH gate, not
288
+ // here: this pre-COMMIT hook checks ARTIFACTS only.)
289
+ if (decision.requirementSet === 'tier1-lite') {
290
+ enforceTier1(freshestTrace, traceEntries[0].file);
291
+ // enforceTier1 either passes through (process.exit(0)) or blockCommit()s.
292
+ }
293
+
294
+ // ── Otherwise: Tier-2 / Tier-3 / no-tier → the EXISTING full validation ──
295
+ // Everything below (Steps 5–8) is unchanged. A Tier-3 project step is just a
296
+ // Tier-2 spec; nothing new is enforced for "Tier 3" at the gate.
297
+
161
298
  // ─── Step 5: validate most recent trace against staged files ─────────────
162
299
 
163
300
  let validTrace = null;
@@ -539,3 +676,144 @@ function blockCommit(files, reason) {
539
676
  console.error('');
540
677
  process.exit(1);
541
678
  }
679
+
680
+ // ─── Audit writer ─────────────────────────────────────────────────────────
681
+ // Append exactly one JSON line to .instar/instar-dev-decisions.jsonl for every
682
+ // in-scope commit (regardless of tier branch). Best-effort: a logging failure
683
+ // must never crash the gate.
684
+ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc }) {
685
+ try {
686
+ fs.mkdirSync(path.dirname(DECISIONS_LOG), { recursive: true });
687
+ fs.appendFileSync(
688
+ DECISIONS_LOG,
689
+ JSON.stringify({
690
+ ts: new Date().toISOString(),
691
+ slug,
692
+ suggestedTier,
693
+ declaredTier,
694
+ // riskFloor (the number) keeps the line self-contained for later review
695
+ // without re-running the classifier — not just the derived belowFloor.
696
+ riskFloor,
697
+ riskFloorReasons,
698
+ belowFloor,
699
+ files,
700
+ loc,
701
+ }) + '\n',
702
+ );
703
+ } catch {
704
+ // best-effort — never block on audit I/O
705
+ }
706
+ }
707
+
708
+ // ─── Tier-1 lite enforcement ──────────────────────────────────────────────
709
+ // Tier-1 requirement set: a staged ELI16 (trace.eli16Path) passing the length
710
+ // check + a staged side-effects artifact (trace.sideEffectsPath, sha-matched if
711
+ // trace.artifactSha256 is present). NO specPath / review-convergence / approved.
712
+ // On success this PASSES the whole gate (process.exit(0)); on any failure it
713
+ // blockCommit()s with a Tier-1-specific message.
714
+ function enforceTier1(trace, traceFile) {
715
+ const traceName = path.basename(traceFile);
716
+
717
+ // ── ELI16 (request overview) ──
718
+ const eli16Rel = trace.eli16Path;
719
+ if (!eli16Rel) {
720
+ blockCommit(
721
+ inScopeFiles,
722
+ [
723
+ `Tier-1 trace ${traceName} does not declare an ELI16 overview (trace.eli16Path is missing).`,
724
+ '',
725
+ 'A Tier-1 commit still requires a plain-English ELI16 overview of the',
726
+ 'change. Write it, stage it, and set "eli16Path": "<relative-path>" in the trace.',
727
+ ].join('\n'),
728
+ );
729
+ }
730
+ const eli16Abs = path.resolve(ROOT, eli16Rel);
731
+ if (!fs.existsSync(eli16Abs)) {
732
+ blockCommit(
733
+ inScopeFiles,
734
+ `Tier-1 ELI16 overview ${eli16Rel} (trace.eli16Path) does not exist.`,
735
+ );
736
+ }
737
+ if (!staged.includes(eli16Rel)) {
738
+ blockCommit(
739
+ inScopeFiles,
740
+ [
741
+ `Tier-1 ELI16 overview ${eli16Rel} is not staged for commit.`,
742
+ 'It must ship alongside the change.',
743
+ ].join('\n'),
744
+ );
745
+ }
746
+ const eli16Content = fs.readFileSync(eli16Abs, 'utf8');
747
+ if (eli16Content.trim().length < MIN_ELI16_CHARS) {
748
+ blockCommit(
749
+ inScopeFiles,
750
+ [
751
+ `Tier-1 ELI16 overview ${eli16Rel} is too short`,
752
+ `(${eli16Content.trim().length} chars, need ${MIN_ELI16_CHARS}).`,
753
+ '',
754
+ "A stub isn't an overview — write a real one.",
755
+ 'See skills/instar-dev/templates/eli16-overview.md for the expected shape.',
756
+ ].join('\n'),
757
+ );
758
+ }
759
+
760
+ // ── Side-effects artifact ──
761
+ const sideEffectsRel = trace.sideEffectsPath;
762
+ if (!sideEffectsRel) {
763
+ blockCommit(
764
+ inScopeFiles,
765
+ [
766
+ `Tier-1 trace ${traceName} does not declare a side-effects artifact (trace.sideEffectsPath is missing).`,
767
+ '',
768
+ 'A Tier-1 commit still requires a side-effects review artifact. Write it,',
769
+ 'stage it, and set "sideEffectsPath": "<relative-path>" in the trace.',
770
+ ].join('\n'),
771
+ );
772
+ }
773
+ const sideEffectsAbs = path.resolve(ROOT, sideEffectsRel);
774
+ if (!fs.existsSync(sideEffectsAbs)) {
775
+ blockCommit(
776
+ inScopeFiles,
777
+ `Tier-1 side-effects artifact ${sideEffectsRel} (trace.sideEffectsPath) does not exist.`,
778
+ );
779
+ }
780
+ if (!staged.includes(sideEffectsRel)) {
781
+ blockCommit(
782
+ inScopeFiles,
783
+ [
784
+ `Tier-1 side-effects artifact ${sideEffectsRel} is not staged for commit.`,
785
+ 'It must ship alongside the change.',
786
+ ].join('\n'),
787
+ );
788
+ }
789
+ const sideEffectsContent = fs.readFileSync(sideEffectsAbs, 'utf8');
790
+ if (sideEffectsContent.trim().length < MIN_ARTIFACT_CHARS) {
791
+ blockCommit(
792
+ inScopeFiles,
793
+ `Tier-1 side-effects artifact ${sideEffectsRel} is too short ` +
794
+ `(${sideEffectsContent.trim().length} chars, need ${MIN_ARTIFACT_CHARS}).`,
795
+ );
796
+ }
797
+ // sha-match exactly as the existing artifact logic, when a sha is recorded.
798
+ if (trace.artifactSha256) {
799
+ const sha = crypto.createHash('sha256').update(sideEffectsContent).digest('hex');
800
+ if (trace.artifactSha256 !== sha) {
801
+ blockCommit(
802
+ inScopeFiles,
803
+ [
804
+ `Tier-1 side-effects artifact ${sideEffectsRel} sha mismatch —`,
805
+ `trace records ${String(trace.artifactSha256).slice(0, 12)}… but the staged bytes hash to ${sha.slice(0, 12)}….`,
806
+ `If the current artifact is correct, set "artifactSha256": "${sha}" in the trace,`,
807
+ 're-stage BOTH the artifact and the trace, and commit fresh (do NOT amend).',
808
+ ].join('\n'),
809
+ );
810
+ }
811
+ }
812
+
813
+ console.error(
814
+ `[instar-dev-precommit] OK (Tier 1) — trace ${traceName} covers ${inScopeFiles.length} in-scope file(s), ` +
815
+ `ELI16 ${eli16Rel} (${eli16Content.trim().length} chars) + side-effects ${sideEffectsRel} staged & verified. ` +
816
+ `No converged spec required for Tier 1.`,
817
+ );
818
+ process.exit(0);
819
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * classify-tier.mjs — pure tier classifier for the instar-dev commit gate.
3
+ *
4
+ * Step A of the Tiered Development Process
5
+ * (docs/specs/tier-classifier-and-tier1-path-spec.md).
6
+ *
7
+ * Computes a TIER SIGNAL from a staged change. This is signal only — the gate
8
+ * SURFACES the suggestion, but the agent (the mind) DECLARES the actual tier in
9
+ * its trace. This module never decides the tier for the agent and never blocks.
10
+ *
11
+ * `classifyTier({ inScopeFiles, addedLines, deletedLines, addedDiffText })`
12
+ * → { suggestedTier, sizeTier, riskFloor, reasons }
13
+ *
14
+ * - sizeTier: 1 when (addedLines + deletedLines) <= SIZE_LOC AND
15
+ * inScopeFiles.length <= SIZE_FILES, else 2.
16
+ * - riskFloor: starts at 1, RAISED to >= 2 by any safety-invariant proximity,
17
+ * irreversibility, migration/fleet-rollout, or new-capability signal (each
18
+ * pushes a reason).
19
+ * - suggestedTier = max(sizeTier, riskFloor). NEVER 3 — Tier 3 is declared
20
+ * (an approved project step), not auto-suggested.
21
+ */
22
+
23
+ // ─── Tunable size thresholds ─────────────────────────────────────────────
24
+ export const SIZE_LOC = 40;
25
+ export const SIZE_FILES = 3;
26
+
27
+ // ─── Risk signal: safety-invariant proximity (path-based) ────────────────
28
+ // An in-scope path matching any of these raises the risk floor to >= 2.
29
+ const SAFETY_INVARIANT_PATTERNS = [
30
+ { regex: /secret/i, label: 'SecretDrop / never-on-disk invariant' },
31
+ { regex: /relay/i, label: 'relay / delivery path' },
32
+ { regex: /Telegram.*Adapter/, label: 'Telegram adapter' },
33
+ { regex: /\bauth\b/i, label: 'auth path' },
34
+ { regex: /token/i, label: 'token handling' },
35
+ { regex: /SafeFsExecutor/, label: 'SafeFsExecutor (destructive-fs funnel)' },
36
+ { regex: /SafeGitExecutor/, label: 'SafeGitExecutor (destructive-git funnel)' },
37
+ { regex: /SourceTreeGuard/, label: 'SourceTreeGuard (source-tree mutation guard)' },
38
+ { regex: /Reaper/, label: 'session reaper' },
39
+ { regex: /session.*lifecycle/i, label: 'session lifecycle' },
40
+ ];
41
+
42
+ // ─── Risk signal: irreversibility (path-based) ───────────────────────────
43
+ const IRREVERSIBILITY_PATTERNS = [
44
+ { regex: /migration/i, label: 'migration' },
45
+ { regex: /schema/i, label: 'data-format / schema' },
46
+ { regex: /PostUpdateMigrator/, label: 'PostUpdateMigrator' },
47
+ ];
48
+
49
+ // ─── Risk signal: migration / fleet-rollout surface (path-based) ─────────
50
+ // A change here touches the machinery that rolls out to EVERY deployed agent
51
+ // (the migration family) or the fleet release/publish path. This is where the
52
+ // zombie-cleanup, lifeline-skew, and "one malformed NEXT.md jams all releases"
53
+ // (#42) regressions lived — a one-line change here is never Tier-1. Distinct
54
+ // reason string from irreversibility even though `PostUpdateMigrator` /
55
+ // `migrate*` overlap, because the fleet-rollout blast radius is the concern.
56
+ const FLEET_ROLLOUT_PATTERNS = [
57
+ { regex: /PostUpdateMigrator/, label: 'PostUpdateMigrator (fleet migration machinery)' },
58
+ { regex: /migrate/i, label: 'migrate*() migration family' },
59
+ { regex: /http-hook-templates/, label: 'http-hook-templates (settings/hook migration source)' },
60
+ { regex: /NEXT\.md/, label: 'upgrades/NEXT.md (fleet release manifest)' },
61
+ // Release / publish scripts under scripts/ — the fleet publish path.
62
+ { regex: /^scripts\/.*release/i, label: 'release script (fleet publish path)' },
63
+ { regex: /^scripts\/.*publish/i, label: 'publish script (fleet publish path)' },
64
+ ];
65
+
66
+ // ─── Risk signal: new capability (diff-text-based, only when provided) ───
67
+ // Net-new added lines (the diff text passed in should be ADDED lines only;
68
+ // the caller is responsible for that). Each heuristic emits a reason.
69
+ const NEW_CAPABILITY_PATTERNS = [
70
+ {
71
+ // A new route registration: router.get( / router.post( / app.use( etc.
72
+ regex: /\brouter\.(get|post|put|patch|delete|use|all|options|head)\s*\(/,
73
+ label: 'new route (router.<verb>()',
74
+ },
75
+ {
76
+ regex: /\bexport\s+class\s+/,
77
+ label: 'new exported class / subsystem',
78
+ },
79
+ {
80
+ // A new config key — a quoted key followed by a colon, or an object-literal
81
+ // identifier key, that reads like a config surface addition. Conservative:
82
+ // require it to look like a JSON/TS config entry.
83
+ regex: /^[+]?\s*["']?[A-Za-z_][A-Za-z0-9_]*["']?\s*:\s*.+,?\s*$/m,
84
+ label: 'new config key',
85
+ // The generic key pattern is noisy on its own — gate it behind a hint that
86
+ // the diff is actually touching a config surface. See evaluation below.
87
+ requiresConfigHint: true,
88
+ },
89
+ ];
90
+
91
+ // Heuristic hint that the added diff is touching a config SURFACE (not just any
92
+ // object literal). Without this guard the bare `key: value` pattern fires on
93
+ // almost every TS change. We only treat a `key: value` addition as a new config
94
+ // key when the diff also mentions a recognizable config anchor.
95
+ const CONFIG_SURFACE_HINT = /(ConfigDefaults|config\.json|defaultConfig|InstarConfig|\.config\b|configSchema)/;
96
+
97
+ /**
98
+ * @param {object} input
99
+ * @param {string[]} input.inScopeFiles Staged in-scope file paths.
100
+ * @param {number} input.addedLines Total added lines across inScopeFiles.
101
+ * @param {number} input.deletedLines Total deleted lines across inScopeFiles.
102
+ * @param {string} [input.addedDiffText] Concatenated ADDED-line diff text. When
103
+ * absent, the new-capability check is SKIPPED
104
+ * (we never guess without the diff).
105
+ * @returns {{ suggestedTier: 1|2, sizeTier: 1|2, riskFloor: 1|2, reasons: string[] }}
106
+ */
107
+ export function classifyTier({ inScopeFiles, addedLines, deletedLines, addedDiffText } = {}) {
108
+ const files = Array.isArray(inScopeFiles) ? inScopeFiles : [];
109
+ const added = Number.isFinite(addedLines) ? addedLines : 0;
110
+ const deleted = Number.isFinite(deletedLines) ? deletedLines : 0;
111
+
112
+ const reasons = [];
113
+
114
+ // ── Size tier ──
115
+ const totalLoc = added + deleted;
116
+ const sizeTier = totalLoc <= SIZE_LOC && files.length <= SIZE_FILES ? 1 : 2;
117
+
118
+ // ── Risk floor ──
119
+ let riskFloor = 1;
120
+ const raise = (reason) => {
121
+ riskFloor = Math.max(riskFloor, 2);
122
+ reasons.push(reason);
123
+ };
124
+
125
+ // (a) safety-invariant proximity — path-based
126
+ for (const file of files) {
127
+ for (const { regex, label } of SAFETY_INVARIANT_PATTERNS) {
128
+ if (regex.test(file)) {
129
+ raise(`safety-invariant proximity: ${file} matches ${label}`);
130
+ }
131
+ }
132
+ }
133
+
134
+ // (b) irreversibility — path-based
135
+ for (const file of files) {
136
+ for (const { regex, label } of IRREVERSIBILITY_PATTERNS) {
137
+ if (regex.test(file)) {
138
+ raise(`irreversibility: ${file} touches ${label}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // (b2) migration / fleet-rollout surface — path-based
144
+ for (const file of files) {
145
+ for (const { regex, label } of FLEET_ROLLOUT_PATTERNS) {
146
+ if (regex.test(file)) {
147
+ raise(`migration / fleet-rollout surface: ${file} touches ${label}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // (c) new capability — diff-text-based, only when addedDiffText is provided.
153
+ // If absent, SKIP entirely (don't guess).
154
+ if (typeof addedDiffText === 'string' && addedDiffText.length > 0) {
155
+ for (const { regex, label, requiresConfigHint } of NEW_CAPABILITY_PATTERNS) {
156
+ if (requiresConfigHint && !CONFIG_SURFACE_HINT.test(addedDiffText)) {
157
+ continue;
158
+ }
159
+ if (regex.test(addedDiffText)) {
160
+ raise(`new capability: ${label} added`);
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── Suggested tier ── NEVER 3.
166
+ const suggestedTier = Math.max(sizeTier, riskFloor);
167
+
168
+ return { suggestedTier, sizeTier, riskFloor, reasons };
169
+ }
170
+
171
+ /**
172
+ * decideRequirementSet(declaredTier) — pure helper factoring the gate's
173
+ * tier-enforcement DECISION so it is unit-testable without git/fs mocking.
174
+ *
175
+ * Given the agent's DECLARED tier (from the trace; `null`/`undefined` when no
176
+ * trace or no `tier` field), return WHICH requirement set the gate enforces.
177
+ *
178
+ * - null/undefined (or any non-1/2/3 value) → 'tier2-full' (back-compat default)
179
+ * - 1 → 'tier1-lite'
180
+ * - 2 or 3 → 'tier2-full' (a Tier-3 project step is just a Tier-2 spec)
181
+ *
182
+ * @param {number|null|undefined} declaredTier
183
+ * @returns {{ requirementSet: 'tier1-lite'|'tier2-full', resolvedTier: 1|2 }}
184
+ */
185
+ export function decideRequirementSet(declaredTier) {
186
+ if (declaredTier === 1) {
187
+ return { requirementSet: 'tier1-lite', resolvedTier: 1 };
188
+ }
189
+ // 2, 3, missing, or anything else → the existing full Tier-2 requirement set.
190
+ return { requirementSet: 'tier2-full', resolvedTier: 2 };
191
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-01T20:17:17.015Z",
5
- "instarVersion": "1.3.190",
4
+ "generatedAt": "2026-06-02T00:19:44.300Z",
5
+ "instarVersion": "1.3.192",
6
6
  "entryCount": 193,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,37 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The instar-dev commit gate now computes a **tier signal** (size + risk) for each staged
9
+ change and prints it, the developing agent **declares** the tier in its trace, and the
10
+ gate enforces the **chosen** tier's requirement set — adding a lighter **Tier-1 path**
11
+ (ELI16 + side-effects, no pre-approved converged spec) for small, low-risk changes. Every
12
+ decision is recorded to `.instar/instar-dev-decisions.jsonl`. This is Step A of the
13
+ tiered-development project and the first executable instance of the constitution's
14
+ **The Body and the Mind**: the gate *informs*, the agent *decides*, the decision is
15
+ *audited*.
16
+
17
+ ## What to Tell Your User
18
+
19
+ Nothing to do — this only affects how the agent commits changes to instar itself. Small
20
+ changes now move through a lighter lane (overview + side-effects note), bigger changes
21
+ still get the full spec. The agent's tier choice and the gate's suggestion are logged.
22
+
23
+ ## Summary of New Capabilities
24
+
25
+ - `scripts/lib/classify-tier.mjs` — pure tier classifier (size + risk → suggested tier).
26
+ - `instar-dev-precommit.js` — prints the tier signal, routes by the declared tier, audits
27
+ the decision; Tier-1 path requires ELI16 + side-effects (no converged spec). No declared
28
+ tier → the existing Tier-2 requirement set, byte-for-byte (back-compat).
29
+ - `write-trace.mjs` — `--tier`, `--tier-reasoning`, `--eli16-path`, `--side-effects-path`
30
+ flags; `--spec` optional when `--tier 1`.
31
+
32
+ ## Evidence
33
+
34
+ - `npx tsc --noEmit` exit 0; Step-A unit tests green (classify-tier 47, write-trace-tier 5,
35
+ gate -deferrals/-sha-error 13/1), including a named no-tier back-compat regression and
36
+ the audit-line shape (with `riskFloor`).
37
+ - This very commit dogfoods the change: it is declared **Tier 2** in its own trace.
@@ -0,0 +1,37 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Spec-review-convergence's external **cross-model reviewer** is now real and framework-native:
9
+ instead of a placeholder API path, it detects the installed **codex CLI** and runs a
10
+ GPT-tier second opinion through the existing hardened codex provider — so any instar agent
11
+ with codex gets cross-model review for free, no API keys. When no supported reviewer is
12
+ installed (or a call fails, or convergence is abbreviated) the spec carries a loud,
13
+ honest `cross-model-review:` flag; the review is **signal, never a gate**. Step B of the
14
+ tiered-development project.
15
+
16
+ ## What to Tell Your User
17
+
18
+ Nothing to do — this only affects how the agent reviews its own instar specs. Specs now get
19
+ a real outside-model opinion when codex is present, and a clearly-marked note when they
20
+ don't, so a spec that never got an external review can't quietly look like it did.
21
+
22
+ ## Summary of New Capabilities
23
+
24
+ - `src/core/crossModelReviewer.ts` — codex-CLI reviewer detection + a supported-reviewer
25
+ registry + the reviewer driver (via the existing codex provider) + the honest flag
26
+ machinery (`codex-cli:<model>` / `degraded` / `degraded-all-rounds` / `unavailable` /
27
+ `skipped-abbreviated`), with deterministic, named context truncation.
28
+ - `skills/spec-converge/scripts/cross-model-review.mjs` — the CLI the convergence skill
29
+ calls; `write-convergence-tag.mjs` gains `--cross-model-review`; `SKILL.md` + the reviewer
30
+ prompt template now describe the real, inlined-context, single-codex-pass mechanism.
31
+
32
+ ## Evidence
33
+
34
+ - `npx tsc --noEmit` exit 0; Step-B tests 53 green (45 + 5 unit, 3 integration); `npm run
35
+ lint` green. Detection live-validated on this host: `{"available":true,
36
+ "framework":"codex-cli","model":"gpt-5.5"}`.
37
+ - Dev-only tooling: `/spec-converge` is not shipped to end-agent homes, so no migration.
@@ -0,0 +1,83 @@
1
+ # Side-Effects Review — Cross-model review on the codex CLI (Step B)
2
+
3
+ **Slug:** `codex-crossreview-stepB`
4
+ **Date:** 2026-06-01
5
+ **Author:** echo
6
+ **Spec:** `docs/specs/codex-crossreview-stepB-spec.md` (approved by Justin + abbreviated convergence, 5 findings folded)
7
+ **Project:** Step B of the Tiered Development Process (`docs/projects/tiered-dev-process/PROJECT.md`)
8
+
9
+ ## Summary of the change
10
+
11
+ Re-platforms the external **cross-model reviewer** in spec-review-convergence from the
12
+ (placeholder) `/crossreview` API path onto the **installed codex CLI**, so every instar
13
+ agent with codex gets a GPT-tier second opinion for free, no API keys. Honors the
14
+ constitution's **Signal vs. Authority** — the external review is *signal*, never a
15
+ blocking authority; an absent/failed reviewer is disclosed loudly, never faked.
16
+
17
+ **Files changed (in-scope):**
18
+ - `src/core/crossModelReviewer.ts` (NEW) — detection (`detectCodexReviewer` /
19
+ `detectCrossModelReviewer`: codex binary present AND OAuth `tokens.access_token` AND
20
+ Rule-1 clean), the `SUPPORTED_REVIEWER_FRAMEWORKS` registry (codex first; one entry per
21
+ future framework), the reviewer driver (routes the cross-model prompt through the
22
+ existing `buildIntelligenceProvider({framework:'codex-cli'})` → gpt-5.5, 120s, reusing
23
+ the provider's env allowlist + the factory circuit breaker), `assembleReviewerPrompt`
24
+ (inlines the spec in full + referenced context under a 60KB budget with **deterministic**
25
+ priority order — constitutional docs first — and a truncation note that **names** the
26
+ partial + omitted docs), `parseReviewerReply` (never throws/zeroes), and the flag
27
+ machinery: `buildCrossModelFlag` + `aggregateRoundOutcomes` producing one of
28
+ `codex-cli:<model>` (ran) / `degraded` / `degraded-all-rounds` / `unavailable` /
29
+ `skipped-abbreviated`. Never throws, never blocks.
30
+ - `skills/spec-converge/scripts/cross-model-review.mjs` (NEW) — the thin CLI the skill
31
+ shells out to (does only repo file-I/O — codex is sandboxed with no repo access —
32
+ delegating logic to compiled `dist/core/crossModelReviewer.js`; always exits 0).
33
+ - `skills/spec-converge/scripts/write-convergence-tag.mjs` — `--cross-model-review` /
34
+ `--cross-model-reason` args writing the `cross-model-review:` frontmatter field
35
+ (idempotent, YAML-safe quoting).
36
+ - `skills/spec-converge/SKILL.md` — replaced the `/crossreview` placeholder with the real
37
+ invocation; Phase 3 documents per-round aggregation (→ `degraded-all-rounds` when a
38
+ framework was present but **zero** rounds got a successful pass); Phase 4 renders a loud
39
+ `⚠` banner for **every** non-ran state (a real pass is the only unmarked one); Phase 5
40
+ the valid flag values. Internal reviewers + the Standards-Conformance Gate unchanged.
41
+
42
+ **Files changed (not in-scope):** `skills/spec-converge/templates/reviewer-cross-model.md`
43
+ (patched: spec + context are "inlined below" — no file-read instruction codex can't
44
+ follow — and dropped the three-phantom-model GPT/Gemini/Grok framing → one codex pass);
45
+ the spec + ELI16; tests (`tests/unit/crossModelReviewer.test.ts` 45,
46
+ `tests/unit/write-convergence-tag-crossmodel.test.ts` 5,
47
+ `tests/integration/cross-model-review-flow.test.ts` 3).
48
+
49
+ ## Blast radius
50
+
51
+ Additive. The new module is pure/injectable except the one provider call (reusing the
52
+ hardened codex provider). The convergence flow gains a real external reviewer where it had
53
+ a placeholder; the internal reviewers, the lessons-aware (non-skippable) reviewer, and the
54
+ Standards-Conformance gate are untouched. The reviewer never throws and never blocks — the
55
+ worst case is a loudly-disclosed `unavailable` / `degraded` / `degraded-all-rounds` flag.
56
+ The `cross-model-review:` flag is **disclosure, not a gate**: it does NOT touch the
57
+ `review-convergence` + `approved` enforcement in `instar-dev-precommit.js`.
58
+
59
+ ## Risks considered
60
+
61
+ - **Could a failed/absent review read as a clean pass?** No (convergence Finding 1/2): every
62
+ non-ran state carries a loud `⚠` banner, and a convergence where a framework was present
63
+ but *no* round ever succeeded collapses to a spec-level `degraded-all-rounds` (as loud as
64
+ `unavailable`) — "never actually got an external opinion" surfaces at spec level.
65
+ - **Silent partial context?** No (Finding 4): truncation is deterministic (constitutional
66
+ docs first) and the note names the partial + fully-omitted docs.
67
+ - **API-key leak through codex?** No — reuses `buildCodexChildEnv()`'s allowlist; Rule-1
68
+ (`validateRule1`) forbids an API-key-shaped credential; detection requires OAuth.
69
+ - **Cost?** Worst case ~10 capable-tier calls per spec (the 10-round convergence cap × 1
70
+ call/round), bounded by the account circuit breaker. Stated in the spec.
71
+
72
+ ## Migration parity
73
+
74
+ **None needed — verified.** `/spec-converge` is dev-only tooling: it is NOT in
75
+ `package.json` `files[]` and NOT installed by `installBuiltinSkills()`, so it never lands
76
+ in an end-agent home. No `PostUpdateMigrator` change. (The agents that *develop* instar get
77
+ it from the repo.)
78
+
79
+ ## Tests / lint
80
+
81
+ `npx tsc --noEmit` exit 0; Step-B tests 53 green (45+5 unit, 3 integration); `npm run lint`
82
+ (tsc + destructive/LLM-http/url-log/codex-drift) green. (The worktree's ~28 pre-existing
83
+ local failures — node v25.6.1 + real-config + tunnel — are independent and CI-green.)