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.
- package/dist/core/crossModelReviewer.d.ts +310 -0
- package/dist/core/crossModelReviewer.d.ts.map +1 -0
- package/dist/core/crossModelReviewer.js +489 -0
- package/dist/core/crossModelReviewer.js.map +1 -0
- package/package.json +1 -1
- package/scripts/instar-dev-precommit.js +279 -1
- package/scripts/lib/classify-tier.mjs +191 -0
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.191.md +37 -0
- package/upgrades/1.3.192.md +37 -0
- package/upgrades/side-effects/codex-crossreview-stepB.md +83 -0
- package/upgrades/side-effects/tier-classifier-and-tier1-path.md +81 -0
|
@@ -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-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.)
|