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
|
@@ -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-
|
|
5
|
-
"instarVersion": "1.3.
|
|
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.
|