repotector
Advanced tools
@@ -231,2 +231,8 @@ #!/usr/bin/env node | ||
| // blocks NEW regressions, never day-one debt). Never clobbers an existing hook. | ||
| // | ||
| // FAIL-OPEN by design: the shim blocks ONLY on the dedicated drift exit code | ||
| // (2). If the tool itself cannot run (missing node, moved path, offline), the | ||
| // commit goes through with a loud warning — a guard that locks every commit | ||
| // because IT is broken gets uninstalled, which is worse than one missed check. | ||
| // The shim pins the ABSOLUTE path of this CLI (no npx, no network at commit). | ||
| function cmdHooks () { | ||
@@ -236,6 +242,27 @@ const hooksDir = join(ROOT, '.git', 'hooks') | ||
| const hookPath = join(hooksDir, 'pre-commit') | ||
| const shim = '#!/bin/sh\n# repotector commit guard — blocks only NEW regressions (grandfathered baseline).\nexec npx --no-install repotector gates || exec npx -y repotector gates\n' | ||
| const cliAbs = join(PKG_ROOT, 'bin', 'psx-repotector.mjs').replace(/\\/g, '/') | ||
| const shim = [ | ||
| '#!/bin/sh', | ||
| '# repotector commit guard — blocks ONLY on gate regressions (exit 2).', | ||
| '# Tool failures fail OPEN with a warning (never a lockout).', | ||
| `node "${cliAbs}" gates`, | ||
| 'code=$?', | ||
| 'if [ "$code" -eq 2 ]; then', | ||
| ' echo "[repotector] new regression(s) - commit blocked. Run: repotector gates" >&2', | ||
| ' exit 1', | ||
| 'fi', | ||
| 'if [ "$code" -ne 0 ]; then', | ||
| ' echo "[repotector] guard could not run (exit $code) - failing OPEN, commit allowed." >&2', | ||
| 'fi', | ||
| 'exit 0', | ||
| '' | ||
| ].join('\n') | ||
| if (existsSync(hookPath)) { | ||
| const current = readFileSync(hookPath, 'utf8') | ||
| if (/repotector/.test(current)) { console.log('✓ Commit guard already installed.'); return } | ||
| if (/repotector/.test(current)) { | ||
| writeFileSync(hookPath, shim) | ||
| try { chmodSync(hookPath, 0o755) } catch { /* git runs hooks via sh */ } | ||
| console.log('✓ Commit guard updated (fail-open shim, pinned CLI path).') | ||
| return | ||
| } | ||
| return fail('a pre-commit hook already exists — add "npx repotector gates" to it manually (never clobbering yours).') | ||
@@ -245,3 +272,3 @@ } | ||
| try { chmodSync(hookPath, 0o755) } catch { /* windows: git runs hooks via sh regardless */ } | ||
| console.log('⬡ Commit guard installed (.git/hooks/pre-commit) — gates run on every commit; only regressions block.') | ||
| console.log('⬡ Commit guard installed (.git/hooks/pre-commit) — blocks only on regressions (exit 2); tool failures fail open.') | ||
| } | ||
@@ -276,4 +303,5 @@ | ||
| printProof(proof) | ||
| // Non-zero on regressions so hooks and CI can actually gate. | ||
| if (proof.verdict !== 'INTENT_HONORED') process.exit(1) | ||
| // Dedicated exit code 2 = drift (regressions), so the hook can block on | ||
| // exactly that and FAIL OPEN on any other failure (1 = tool error). | ||
| if (proof.verdict !== 'INTENT_HONORED') process.exit(2) | ||
| return | ||
@@ -280,0 +308,0 @@ } |
+5
-2
| { | ||
| "name": "repotector", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "description": "Repotector ⬡ — the repo guardian AI agents handshake with before touching code: REPOTECTOR/2 handshake + visitor register, city-map (built-vs-missing), grandfathered gates that never fail you on day one, resilient depart, and a DNA-aware MCP server.", | ||
@@ -42,3 +42,6 @@ "type": "module", | ||
| "homepage": "https://github.com/dir-ai/repotector#readme", | ||
| "license": "MIT" | ||
| "license": "MIT", | ||
| "scripts": { | ||
| "test": "node test/run.mjs" | ||
| } | ||
| } |
+1
-1
@@ -104,3 +104,3 @@ # Repotector ⬡ | ||
| in PSX Workbench repos, the Merge Machine's leases via the .psx mirror). | ||
| CLI: (exit 1 on conflicts, so hooks/CI | ||
| CLI: `repotector merge-check [target]` (exit 1 on conflicts, so hooks/CI | ||
| can gate on it). Zero network: it trial-merges against your LOCAL refs and | ||
@@ -107,0 +107,0 @@ says so — fetch first for the freshest truth. |
+2
-2
@@ -5,3 +5,3 @@ { | ||
| "description": "The repo guardian AI agents handshake with before they touch your code.", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "packages": [ | ||
@@ -11,3 +11,3 @@ { | ||
| "identifier": "repotector", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "runtimeHint": "npx", | ||
@@ -14,0 +14,0 @@ "transport": { |
+4
-1
@@ -78,5 +78,8 @@ // doctor.mjs — "how protected is this repo, really?" One command, semaphore | ||
| } | ||
| // Honest scope: doctor verifies INSTALLATION health, not live policy state — | ||
| // gates/merge-check answer "is the code OK right now"; doctor answers "is the | ||
| // guard wired". Saying "guarded" here would overclaim. | ||
| console.log(report.ok | ||
| ? `\n ${C.g}${C.b}All green — this repo is guarded.${C.x}\n` | ||
| ? `\n ${C.g}${C.b}All checks green — installation healthy.${C.x} ${C.dim}Live policy: run \`gates\` and \`merge-check\`.${C.x}\n` | ||
| : `\n ${C.r}${C.b}${report.reds} check(s) red.${C.x}\n`) | ||
| } |
@@ -25,2 +25,3 @@ // mcp-server.mjs — stdio MCP server. The repo's front door for any arriving AI. | ||
| recordClaim, recordRelease, activeClaims, claimConflicts, recordDecisions, listDecisions, | ||
| offClaimFiles, | ||
| } from './register.mjs' | ||
@@ -165,2 +166,5 @@ import { mergeCheck, allZones } from './merge.mjs' | ||
| const filesTouched = session ? filesChangedSince(ROOT, session.enterHead) : [] | ||
| // Out-of-claim reconciliation: if this session claimed a zone, files touched | ||
| // OUTSIDE it are flagged — the claim gets consequences, not just courtesy. | ||
| const offClaim = session ? offClaimFiles(ROOT, { sessionId: session.sessionId, files: filesTouched }) : [] | ||
| if (decisions?.length) { | ||
@@ -170,3 +174,3 @@ recordDecisions(ROOT, { sessionId: session?.sessionId, who: session?.who, decisions }) | ||
| } | ||
| recordDepart(ROOT, { sessionId: session?.sessionId, summary, filesTouched }) | ||
| recordDepart(ROOT, { sessionId: session?.sessionId, summary, filesTouched, offClaim: offClaim.length ? offClaim : undefined }) | ||
| try { writeJournalMd(ROOT) } catch { /* journal is a projection; best-effort */ } | ||
@@ -176,3 +180,4 @@ const who = session?.who | ||
| const delta = filesTouched.length ? ` You touched ${filesTouched.length} file(s): ${filesTouched.slice(0, 8).join(', ')}${filesTouched.length > 8 ? '…' : ''}.` : '' | ||
| return { content: [{ type: 'text', text: `← ${who ?? 'agent'} signed out.${delta} Safe travels.` }], structuredContent: { departed: true, filesTouched } } | ||
| const scopeNote = offClaim.length ? ` ⚠ ${offClaim.length} file(s) OUTSIDE your claimed zone: ${offClaim.slice(0, 6).join(', ')}${offClaim.length > 6 ? '…' : ''} — recorded in the register.` : '' | ||
| return { content: [{ type: 'text', text: `← ${who ?? 'agent'} signed out.${delta}${scopeNote} Safe travels.` }], structuredContent: { departed: true, filesTouched, offClaim } } | ||
| })) | ||
@@ -179,0 +184,0 @@ |
+23
-7
@@ -87,6 +87,18 @@ // merge.mjs — the portable front door of merge safety. When several agents | ||
| // Include the UNCOMMITTED work: `git stash create` mints a commit-ish of the | ||
| // working tree + index WITHOUT touching anything. An agent's not-yet-committed | ||
| // edits must count — otherwise merge_check says CLEAN while the worktree is | ||
| // already colliding. (Tracked changes only; brand-new untracked files are not | ||
| // captured — stated, not hidden.) | ||
| let ours = 'HEAD' | ||
| let includesWorktree = false | ||
| try { | ||
| const stashOid = git(root, ['stash', 'create']).trim() | ||
| if (stashOid) { ours = stashOid; includesWorktree = true } | ||
| } catch { /* clean tree or stash unavailable → HEAD is the honest side */ } | ||
| let conflictedFiles = [] | ||
| let clean = null | ||
| try { | ||
| git(root, ['merge-tree', '--write-tree', '--name-only', 'HEAD', resolved]) | ||
| git(root, ['merge-tree', '--write-tree', '--name-only', ours, resolved]) | ||
| clean = true | ||
@@ -98,6 +110,9 @@ } catch (error) { | ||
| clean = false | ||
| // Output: <tree-oid> then blank-separated conflicted file names. | ||
| conflictedFiles = out.split('\n').slice(1).map((s) => s.trim()).filter(Boolean) | ||
| // Output: <tree-oid>, conflicted file names, then a BLANK line followed by | ||
| // informational prose (Auto-merging…, CONFLICT…) — stop at the blank line. | ||
| const lines = out.split('\n').slice(1) | ||
| const blank = lines.findIndex((line) => line.trim() === '') | ||
| conflictedFiles = (blank === -1 ? lines : lines.slice(0, blank)).map((s) => s.trim()).filter(Boolean) | ||
| } else { | ||
| return { supported: false, target: resolved, ahead, behind, clean: null, conflicts: [], note: 'git merge-tree --write-tree unavailable (needs git >= 2.38) — cannot trial-merge.' } | ||
| return { supported: false, target: resolved, ahead, behind, clean: null, conflicts: [], includesWorktree, note: 'git merge-tree --write-tree unavailable (needs git >= 2.38) — cannot trial-merge.' } | ||
| } | ||
@@ -108,6 +123,7 @@ } | ||
| const conflicts = conflictedFiles.map((file) => ({ file, heldBy: zonesTouching(zones, file).map((z) => ({ who: z.who, source: z.source, reason: z.reason })) })) | ||
| const note = behind === 0 | ||
| const worktreeNote = includesWorktree ? 'Includes your uncommitted (tracked) changes. ' : '' | ||
| const note = worktreeNote + (behind === 0 | ||
| ? 'Up to date with the integration base.' | ||
| : `Base has moved (${behind} commit(s) not in HEAD) — trial merge is against the LOCAL ref; fetch first for the freshest truth.` | ||
| return { supported: true, target: resolved, ahead, behind, clean, conflicts, note } | ||
| : `Base has moved (${behind} commit(s) not in HEAD) — trial merge is against the LOCAL ref; fetch first for the freshest truth.`) | ||
| return { supported: true, target: resolved, ahead, behind, clean, conflicts, includesWorktree, note } | ||
| } | ||
@@ -114,0 +130,0 @@ |
+20
-1
@@ -58,3 +58,3 @@ // register.mjs — the visitor ledger. Every AI that faces the repo must handshake | ||
| // resilient part: a depart carries the real work even when `summary` is absent. | ||
| export function recordDepart (root, { sessionId, summary, filesTouched, synthetic, reason }) { | ||
| export function recordDepart (root, { sessionId, summary, filesTouched, synthetic, reason, offClaim }) { | ||
| appendLine(root, { | ||
@@ -66,2 +66,3 @@ event: 'depart', | ||
| filesTouched: filesTouched && filesTouched.length ? filesTouched : null, | ||
| offClaim: offClaim && offClaim.length ? offClaim : null, | ||
| synthetic: !!synthetic, | ||
@@ -141,2 +142,20 @@ reason: reason || null | ||
| // The active claims held by ONE session (for out-of-claim reconciliation). | ||
| export function sessionClaims (root, sessionId) { | ||
| if (!sessionId) return [] | ||
| return activeClaims(root).filter((c) => c.sessionId === sessionId) | ||
| } | ||
| // Files not covered by any of the session's claimed zones. Empty when the | ||
| // session holds no claims (claims are optional — no claim, no scope check). | ||
| export function offClaimFiles (root, { sessionId, files }) { | ||
| const claims = sessionClaims(root, sessionId) | ||
| if (claims.length === 0) return [] | ||
| const roots = claims.flatMap((c) => c.paths.map(claimRoot)) | ||
| return (files || []).filter((file) => { | ||
| const f = String(file).replace(/\\/g, '/') | ||
| return !roots.some((r) => r === '' || f === r || f.startsWith(r + '/')) | ||
| }) | ||
| } | ||
| // Claims from OTHER live sessions that overlap the given paths. | ||
@@ -143,0 +162,0 @@ export function claimConflicts (root, { paths, sessionId }) { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
147391
2.7%2645
2.64%0
-100%