instar 1.3.190 → 1.3.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.190",
3
+ "version": "1.3.191",
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",
@@ -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-01T22:39:21.302Z",
5
+ "instarVersion": "1.3.191",
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,81 @@
1
+ # Side-Effects Review — Tier classifier + Tier-1 PR path (Step A)
2
+
3
+ **Slug:** `tier-classifier-and-tier1-path`
4
+ **Date:** 2026-06-01
5
+ **Author:** echo
6
+ **Spec:** `docs/specs/tier-classifier-and-tier1-path-spec.md` (approved by Justin + abbreviated convergence)
7
+ **Project:** Step A of the Tiered Development Process (`docs/projects/tiered-dev-process/PROJECT.md`)
8
+
9
+ ## Summary of the change
10
+
11
+ Teaches the instar-dev commit gate to (1) compute + print a **tier signal** (size + risk),
12
+ (2) let the agent **declare** the tier in its trace, (3) enforce the **chosen** tier's
13
+ requirement set — adding a **Tier-1 path** (ELI16 + side-effects, no pre-approved
14
+ converged spec) — and (4) **audit** every decision. First executable instance of the
15
+ constitution's *The Body and the Mind*: the gate informs, the agent decides, the decision
16
+ is recorded. Strictly additive: a commit with no declared tier behaves exactly as before.
17
+
18
+ **Files changed (in-scope):**
19
+ - `scripts/lib/classify-tier.mjs` (NEW) — pure `classifyTier({inScopeFiles, addedLines,
20
+ deletedLines, addedDiffText})` → `{suggestedTier, sizeTier, riskFloor, reasons}` +
21
+ `decideRequirementSet(declaredTier)`. Size tier (≤40 LOC / ≤3 files = 1). Risk floor
22
+ raised by safety-invariant proximity, irreversibility, migration/fleet-rollout, and
23
+ (diff-text-gated) new-capability. `suggestedTier = max(size, risk)`, never 3.
24
+ - `scripts/instar-dev-precommit.js` — Step 3.5 computes + prints the signal (via
25
+ `git diff --cached --numstat`); Step 4.5 reads `trace.tier`/`trace.tierReasoning`,
26
+ routes via `decideRequirementSet`, writes one audit line to
27
+ `.instar/instar-dev-decisions.jsonl` (incl. `riskFloor` + `belowFloor`), and runs the
28
+ Tier-1 path (`enforceTier1`: staged ELI16 + side-effects, sha-matched) when `tier===1`.
29
+ **Tier-2/3/no-tier fall through to the existing Steps 5–8 unchanged.**
30
+ - `skills/instar-dev/scripts/write-trace.mjs` — new flags `--tier`, `--tier-reasoning`,
31
+ `--eli16-path`, `--side-effects-path`; `--spec` optional when `--tier 1`. Undeclared
32
+ traces round-trip byte-identically to the legacy shape.
33
+
34
+ **Files changed (tests, not in-scope):**
35
+ - `tests/unit/classify-tier.test.ts` (NEW, 47) — size boundaries, every risk signal incl.
36
+ fleet-rollout, `max(size,risk)`, never-3, config-hint-gated new-capability.
37
+ - `tests/unit/write-trace-tier.test.ts` (NEW, 5) — Tier-1/Tier-2/no-tier trace round-trips,
38
+ side-effects-path default, missing-ELI16 rejection.
39
+ - `tests/unit/instar-dev-precommit-deferrals.test.ts` — named back-compat regression
40
+ (no-tier trace + approved spec passes the full Tier-2 path) + audit-line shape (incl.
41
+ `riskFloor`). `-sha-error.test.ts` — sandbox updated for the new static import.
42
+
43
+ ## Blast radius
44
+
45
+ Additive and back-compatible. The classifier is pure (no I/O). The gate gains two new
46
+ steps (3.5 signal print, 4.5 tier routing + audit) **before** the existing Steps 5–8,
47
+ which are unchanged for Tier-2/no-tier. The default (no `tier` in trace) selects the exact
48
+ current requirement set — verified by the named back-compat regression test. The only new
49
+ relaxation is the Tier-1 path, still requiring ELI16 + side-effects. The risk floor is a
50
+ loud, audited signal (`belowFloor`), never a silent downgrade; the audit is a learning
51
+ signal, not a security boundary (PR review + the auto-merge spot-check are the human
52
+ gates). New runtime cost: one `git diff --cached --numstat` + one classify call + one
53
+ appended JSONL line per in-scope commit.
54
+
55
+ ## Risks considered
56
+
57
+ - **Tier-1 as a loophole?** The agent declares its own tier; a risk change that evades the
58
+ heuristic globs isn't flagged `belowFloor`. Mitigation (documented in the spec §4): the
59
+ PR is the review surface for every Tier-1, the auto-merge operator spot-check is the
60
+ human gate, and `belowFloor` rates are reviewed on a cadence (Close the Loop) so blind
61
+ spots surface and the risk list grows.
62
+ - **Breaking existing commits?** No — `no-tier → Tier-2` is byte-identical to today; named
63
+ regression test guards it.
64
+ - **Leaking sensitive data?** No — the audit records tiers, reasons, file counts, LOC; no
65
+ content.
66
+
67
+ ## Migration parity
68
+
69
+ `instar-dev-precommit.js` + `write-trace.mjs` ship in the instar repo for agents
70
+ *developing* instar; they are not installed into arbitrary agent homes by `init`. No
71
+ `PostUpdateMigrator` change for end agents. The instar-dev skill awareness update is a
72
+ later project step (Step C).
73
+
74
+ ## Tests / lint
75
+
76
+ `npx tsc --noEmit` exit 0; Step-A unit tests green (`classify-tier` 47, `write-trace-tier`
77
+ 5, gate `-deferrals`/`-sha-error` 13/1). Note: the worktree's full unit suite shows 28
78
+ **pre-existing** failures (TunnelManager, watchdog-bind-probe, serendipity-capture,
79
+ esm-compliance, no-silent-fallbacks) that fail **identically with and without** this change
80
+ (verified by stash-test) — local-environment/harness/lint artifacts (real config + tunnel
81
+ reachability + node v25.6.1), independent of Step A and green in clean CI.