jwc-global
  • Status: Draft implementation plan. Requires Jon approval before hook runtime code is changed.
  • Sources checked: E:/hooks/depcruiser-migrations, E:/hooks/config.json, E:/hooks/_core/config-model.mjs, E:/hooks/quality-completion-gate/quality-verify-manifest.json, E:/hooks/.state, E:/hooks/_db, and the simplified V3 runtime page.

Files the Proposed Plan Would Create, Modify, Or Remove

#ActionFilePurpose
1Createdepcruiser-migrations/graph.contract.jsonStores the five-node authored graph and valid transitions.
2Createdepcruiser-migrations/lib/state-store.mjsOwns atomic .state reads, writes, validation, and event append behavior.
3Createdepcruiser-migrations/lib/path-scope.mjsNormalizes repo-relative paths, rejects traversal, extracts edit paths, and checks guarded scope.
4Createdepcruiser-migrations/depcruiser-migrations-cli.mjsProvides deterministic initialize, arm, validate-state, and disarm commands for the worker.
5Createdepcruiser-migrations/depcruiser-migrations.mjsRuns the PreToolUse gate for guarded migration edits.
6Createdepcruiser-migrations/verify-active-closure.mjsRuns target depcruiser, target typecheck, and strict reconcile from active state.
7Createdepcruiser-migrations/tests/depcruiser-migrations.test.mjsCovers state transitions, path extraction, guarded gate behavior, and strict reconcile.
8Modifydepcruiser-migrations/generate-closure-manifest.mjsAdds manifest schema fields needed by adjudication, approvals, target extras, and strict reconcile.
9Replacedepcruiser-migrations/validate-closure-manifest.mjsReplaces basename reconcile with repo-relative strict reconcile.
10Rewritedepcruiser-migrations/initialize-depcruiser-migrations/SKILL.mdMakes the skill worker-first: gather inputs, run CLI, collect two approvals, disarm through CLI.
11Rewritedepcruiser-migrations/README.mdDocuments the new runtime, command surface, state layout, and recovery behavior.
12Modifyconfig.jsonAdds disabled hook entry depcruiser-migrations pointing at depcruiser-migrations.mjs.
13Modifyquality-completion-gate/quality-verify-manifest.jsonAdds syntax and focused test checks for the new depcruiser runtime files.
14Removedepcruiser-migrations/closure-gate.mjsRemoves the old config-backed runtime after the new disabled hook validates.
15Removedepcruiser-migrations/closure-gate-core.mjsRemoves old filename-derived hook-id support after replacement.
16Removedepcruiser-migrations/closure-gate.register.jsonRemoves the old draft registration after the new config entry exists.

Generated runtime artifacts are not source files, but the implementation must create them during a migration run:

#ArtifactWritten ByPurpose
1E:/hooks/.state/depcruiser-migrations/active.jsondepcruiser-migrations-cli.mjs armRecords the active migration and guarded target scope.
2E:/hooks/.state/depcruiser-migrations/events.jsonlCLI and verifierRecords initialize, arm, validation, verification, approval, and disarm events.
3E:/hooks/depcruiser-migrations/manifests/<name>/inputs.jsonCLI initializeRecords normalized user and worker inputs.
4E:/hooks/depcruiser-migrations/manifests/<name>/setup-result.jsonCLI initializeRecords package manager, dependency-cruiser presence, install actions, and setup result.
5E:/hooks/depcruiser-migrations/manifests/<name>/before-edit.donor.jsonCLI initializeStores donor dependency-cruiser output.
6E:/hooks/depcruiser-migrations/manifests/<name>/<name>.source-manifest.jsonManifest generator and adjudicationStores donor closure, verdicts, target paths, target extras, and approvals.
7E:/hooks/depcruiser-migrations/manifests/<name>/adjudication-approval.jsonWorker after user approvalRecords approval number 1.
8E:/hooks/depcruiser-migrations/manifests/<name>/after-edit.target.jsonVerifierStores target dependency-cruiser output.
9E:/hooks/depcruiser-migrations/manifests/<name>/verification-report.jsonVerifierStores target trace, typecheck, reconcile, and pass/fail evidence.
10E:/hooks/depcruiser-migrations/manifests/<name>/closure-approval.jsonWorker after user approvalRecords approval number 2 before disarm.

Implementation Contract

Goal: Replace the old config-backed closure-gate draft with an implementable depcruiser migration runtime that starts from a skill, uses deterministic scripts for setup and state transitions, gates guarded edits through .state, verifies closure in the target repo, and requires exactly two user approvals before disarm.

Architecture: Integrated E:/hooks hook bundle with self-contained depcruiser runtime internals. The bundle uses the existing hook config model, existing quality gate manifest, existing .state substrate, and no hook database changes in P0.

Tech Stack: Node ESM scripts, dependency-cruiser, JSON manifests, atomic filesystem state, existing hooks config.json, existing hooks quality gate, and Node's built-in node:test.

Platform API

No platform API endpoints are created, modified, or consumed. This implementation is a local hook runtime, not a service runtime.

Observability

No OpenTelemetry traces, metrics, or structured logs are created in P0. The runtime writes local audit events to E:/hooks/.state/depcruiser-migrations/events.jsonl.

Allowed event fields: timestamp, event, migrationId, currentNode, artifactPath, status, and reason.

Forbidden event fields: secrets, environment variable values, access tokens, and full file contents.

Database Migrations

No database migrations. Do not write depcruiser run state to E:/hooks/_db/hooks.db.

If JSON state becomes insufficient later, the next plan can introduce E:/hooks/.state/depcruiser-migrations/runs.sqlite after approval. That is not part of P0.

Edge Functions

No edge functions.

Frontend Surface Area

No app frontend files, pages, components, routes, hooks, or services are created or modified by this hook runtime implementation.

Hook Runtime Surface

The owned runtime seam is E:/hooks. The implementation must add one disabled PreToolUse hook entry in config.json, write active migration state under .state, and add quality checks under quality-completion-gate/quality-verify-manifest.json.

The active hook id must be depcruiser-migrations. Do not keep closure-gate as the live runtime id.

Proposed Runtime Decisions

  1. The skill is the user entry point.
  2. The worker gathers only variable inputs before setup.
  3. Setup is deterministic and owned by depcruiser-migrations-cli.mjs initialize.
  4. Active state is written by scripts, not by hand-editing config.json.
  5. The runtime graph is initialize -> adjudication -> implementation -> closure_verification -> disarm.
  6. User approval occurs exactly twice: once at the end of adjudication, once at the end of closure_verification.
  7. Worker autonomy is minimal outside implementation: gather inputs, run scripts, present evidence, and wait for approvals.
  8. Browser or Playwright verification is out of P0 scope.

Runtime Graph

NodeInitiated ByScript CommandFiles WrittenWorker RoleUser RoleCompleted When
initializeUser invokes skilldepcruiser-migrations-cli.mjs initializeinputs, setup result, donor trace, source manifestGather missing inputs and run commandSupply missing repo or page inputsScript exits 0 and graph contract loads
adjudicationInitialize successnone requiredupdated source manifest, approval 1Fill verdicts, target paths, target extrasApprove or correct decisionsUser approval 1 saved
implementationApproval 1arm, validate-stateactive state, target files, eventsPort code and adaptersAnswer intent-changing questionsTarget files exist and worker starts verification
closure_verificationImplementation completeverify-active-closure.mjstarget trace, report, approval 2Run verification and debug failuresApprove passing evidenceUser approval 2 saved
disarmApproval 2depcruiser-migrations-cli.mjs disarminactive state, eventsRun command and report resultNone unless disarm failsNo active migration remains

Code To Implement

The implementation must produce code equivalent to the shapes below. Helper names may change only if the tests preserve the same behavior and the file responsibilities stay the same.

graph.contract.json

{
  "version": 1,
  "nodes": ["initialize", "adjudication", "implementation", "closure_verification", "disarm"],
  "transitions": [
    ["initialize", "adjudication"],
    ["adjudication", "implementation"],
    ["implementation", "closure_verification"],
    ["closure_verification", "disarm"],
    ["closure_verification", "implementation"],
    ["closure_verification", "adjudication"]
  ],
  "approvals": [
    { "name": "adjudication", "node": "adjudication", "requiredBefore": "implementation" },
    { "name": "closure", "node": "closure_verification", "requiredBefore": "disarm" }
  ]
}

lib/state-store.mjs

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, appendFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

export const DEFAULT_STATE_DIR = 'E:/hooks/.state/depcruiser-migrations';

export function statePaths(stateDir = DEFAULT_STATE_DIR) {
  return { stateDir, activePath: join(stateDir, 'active.json'), eventsPath: join(stateDir, 'events.jsonl') };
}

export function readJson(path) {
  return JSON.parse(readFileSync(path, 'utf8'));
}

export function writeJsonAtomic(path, value) {
  mkdirSync(dirname(path), { recursive: true });
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
  writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  renameSync(tmp, path);
}

export function appendEvent(event, stateDir = DEFAULT_STATE_DIR) {
  const paths = statePaths(stateDir);
  mkdirSync(paths.stateDir, { recursive: true });
  appendFileSync(paths.eventsPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`, 'utf8');
}

export function loadActive(stateDir = DEFAULT_STATE_DIR) {
  const { activePath } = statePaths(stateDir);
  if (!existsSync(activePath)) return null;
  return readJson(activePath);
}

export function saveActive(active, stateDir = DEFAULT_STATE_DIR) {
  validateActive(active);
  writeJsonAtomic(statePaths(stateDir).activePath, active);
  appendEvent({ event: 'active_saved', migrationId: active.migrationId, currentNode: active.currentNode }, stateDir);
}

export function disarmActive(name, stateDir = DEFAULT_STATE_DIR) {
  const active = loadActive(stateDir);
  if (!active) {
    appendEvent({ event: 'disarm_noop', migrationId: name, status: 'no_active_state' }, stateDir);
    return { disarmed: false, reason: 'no_active_state' };
  }
  if (name && active.migrationId !== name) throw new Error(`active migration is ${active.migrationId}, not ${name}`);
  const next = { ...active, status: 'inactive', currentNode: 'disarm', disarmedAt: new Date().toISOString() };
  writeJsonAtomic(statePaths(stateDir).activePath, next);
  appendEvent({ event: 'disarmed', migrationId: active.migrationId, currentNode: 'disarm', status: 'inactive' }, stateDir);
  return { disarmed: true, active: next };
}

export function validateActive(active) {
  if (!active || typeof active !== 'object') throw new Error('active state must be an object');
  for (const key of ['version', 'migrationId', 'currentNode', 'targetRoot', 'targetEntry', 'manifestDir', 'sourceManifest']) {
    if (active[key] === undefined || active[key] === '') throw new Error(`active state missing ${key}`);
  }
  if (active.version !== 1) throw new Error('active state version must be 1');
  if (!['adjudication', 'implementation', 'closure_verification', 'disarm'].includes(active.currentNode)) throw new Error(`invalid currentNode: ${active.currentNode}`);
  if (!Array.isArray(active.guardedPrefixes)) throw new Error('guardedPrefixes must be an array');
  if (!Array.isArray(active.guardedFiles)) throw new Error('guardedFiles must be an array');
  return active;
}

lib/path-scope.mjs

import { isAbsolute, relative, resolve } from 'node:path';

export function slash(value) {
  return String(value || '').replaceAll('\\', '/');
}

export function normalizeRel(value) {
  const normalized = slash(value).replace(/^\.\/+/, '');
  if (!normalized || normalized === '.') return '';
  if (isAbsolute(normalized)) throw new Error(`expected repo-relative path, got absolute path: ${value}`);
  if (normalized.split('/').includes('..')) throw new Error(`path traversal is not allowed: ${value}`);
  return normalized;
}

export function toRepoRelative(root, candidate) {
  const value = String(candidate || '');
  if (!value) return '';
  if (!isAbsolute(value)) return normalizeRel(value);
  return normalizeRel(slash(relative(resolve(root), resolve(value))));
}

export function extractApplyPatchPaths(text) {
  const paths = [];
  for (const line of String(text || '').split(/\r?\n/)) {
    const match = line.match(/^\*\*\* (?:Add File|Update File|Delete File|Move to):\s+(.+)$/);
    if (match) paths.push(match[1].trim());
  }
  return paths;
}

export function extractToolPaths(input) {
  const payload = input?.tool_input || {};
  const raw = [
    payload.file_path,
    payload.path,
    payload.notebook_path,
    ...(Array.isArray(payload.edits) ? payload.edits.map((edit) => edit.file_path || edit.path) : []),
    ...extractApplyPatchPaths(payload.patch || payload.diff || payload.command || payload.input)
  ];
  return [...new Set(raw.filter(Boolean).map(slash))];
}

export function isGuardedPath(repoRelativePath, active) {
  const rel = normalizeRel(repoRelativePath);
  const files = (active.guardedFiles || []).map(normalizeRel);
  const prefixes = (active.guardedPrefixes || []).map(normalizeRel);
  if (files.includes(rel)) return true;
  return prefixes.some((prefix) => rel === prefix || rel.startsWith(`${prefix.replace(/\/$/, '')}/`));
}

depcruiser-migrations-cli.mjs

#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { appendEvent, saveActive, loadActive, disarmActive, validateActive } from './lib/state-store.mjs';
import { normalizeRel } from './lib/path-scope.mjs';

function args(argv = process.argv.slice(2)) {
  const out = { _: [] };
  for (let i = 0; i < argv.length; i += 1) {
    const token = argv[i];
    if (!token.startsWith('--')) out._.push(token);
    else {
      const key = token.slice(2);
      const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
      out[key] = out[key] === undefined ? value : Array.isArray(out[key]) ? [...out[key], value] : [out[key], value];
    }
  }
  return out;
}

function arrayArg(value) {
  if (value === undefined) return [];
  return Array.isArray(value) ? value : [value];
}

function detectPackageManager(root) {
  if (existsSync(join(root, 'pnpm-lock.yaml'))) return { name: 'pnpm', exec: ['pnpm', 'exec'], addDev: ['pnpm', 'add', '-D'] };
  if (existsSync(join(root, 'yarn.lock'))) return { name: 'yarn', exec: ['yarn'], addDev: ['yarn', 'add', '-D'] };
  return { name: 'npm', exec: ['npx'], addDev: ['npm', 'install', '-D'] };
}

function packageHasDepcruiser(root) {
  const pkgPath = join(root, 'package.json');
  if (!existsSync(pkgPath)) return false;
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
  return Boolean(pkg.dependencies?.['dependency-cruiser'] || pkg.devDependencies?.['dependency-cruiser']);
}

function run(root, command, commandArgs, options = {}) {
  const result = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8', shell: process.platform === 'win32' });
  if (options.stdoutPath) {
    mkdirSync(dirname(options.stdoutPath), { recursive: true });
    writeFileSync(options.stdoutPath, result.stdout || '', 'utf8');
  }
  if (result.status !== 0) throw new Error(`${command} ${commandArgs.join(' ')} failed: ${result.stderr || result.stdout}`);
  return result;
}

function runWithPackageManager(root, pm, bin, binArgs, options = {}) {
  if (pm.name === 'pnpm') return run(root, 'pnpm', ['exec', bin, ...binArgs], options);
  if (pm.name === 'yarn') return run(root, 'yarn', [bin, ...binArgs], options);
  return run(root, 'npx', [bin, ...binArgs], options);
}

function ensureDepcruiser(root) {
  const pm = detectPackageManager(root);
  const alreadyInstalled = packageHasDepcruiser(root);
  if (!alreadyInstalled) run(root, pm.addDev[0], [...pm.addDev.slice(1), 'dependency-cruiser']);
  return { packageManager: pm.name, alreadyInstalled, installed: !alreadyInstalled, pm };
}

function initialize(opts) {
  const name = opts.name;
  if (!name) throw new Error('--name is required');
  const manifestDir = opts['manifest-dir'] || `E:/hooks/depcruiser-migrations/manifests/${name}`;
  mkdirSync(manifestDir, { recursive: true });

  const inputs = {
    version: 1,
    migrationId: name,
    donorRoot: opts['donor-root'],
    donorEntry: normalizeRel(opts['donor-entry']),
    donorTsconfig: normalizeRel(opts['donor-tsconfig']),
    targetRoot: opts['target-root'],
    targetEntry: normalizeRel(opts['target-entry']),
    guardedPrefixes: arrayArg(opts['guarded-prefix']).map(normalizeRel),
    guardedFiles: arrayArg(opts['guarded-file']).map(normalizeRel),
    manifestDir
  };
  for (const key of ['donorRoot', 'donorEntry', 'donorTsconfig', 'targetRoot', 'targetEntry']) if (!inputs[key]) throw new Error(`${key} is required`);

  const donorSetup = ensureDepcruiser(inputs.donorRoot);
  const targetSetup = ensureDepcruiser(inputs.targetRoot);
  const donorTrace = join(manifestDir, 'before-edit.donor.json');
  const sourceManifest = join(manifestDir, `${name}.source-manifest.json`);

  runWithPackageManager(inputs.donorRoot, donorSetup.pm, 'depcruise', [
    '--no-config',
    '--output-type',
    'json',
    '--ts-config',
    inputs.donorTsconfig,
    '--ts-pre-compilation-deps',
    inputs.donorEntry
  ], { stdoutPath: donorTrace });

  run('E:/hooks', 'node', ['depcruiser-migrations/generate-closure-manifest.mjs', donorTrace, sourceManifest, '--entry', inputs.donorEntry]);

  writeFileSync(join(manifestDir, 'inputs.json'), `${JSON.stringify(inputs, null, 2)}\n`, 'utf8');
  writeFileSync(join(manifestDir, 'setup-result.json'), `${JSON.stringify({ donorSetup, targetSetup, donorTrace, sourceManifest }, null, 2)}\n`, 'utf8');
  appendEvent({ event: 'initialized', migrationId: name, currentNode: 'initialize', artifactPath: manifestDir });
  return { inputs, donorSetup, targetSetup, donorTrace, sourceManifest };
}

function arm(opts) {
  const name = opts.name;
  const manifestDir = opts['manifest-dir'] || `E:/hooks/depcruiser-migrations/manifests/${name}`;
  const active = {
    version: 1,
    migrationId: name,
    currentNode: 'implementation',
    status: 'active',
    targetRoot: opts['target-root'],
    targetEntry: normalizeRel(opts['target-entry']),
    manifestDir,
    sourceManifest: opts['source-manifest'] || join(manifestDir, `${name}.source-manifest.json`),
    donorTrace: opts['donor-trace'] || join(manifestDir, 'before-edit.donor.json'),
    targetTrace: opts['target-trace'] || join(manifestDir, 'after-edit.target.json'),
    verificationReport: join(manifestDir, 'verification-report.json'),
    guardedPrefixes: arrayArg(opts['guarded-prefix']).map(normalizeRel),
    guardedFiles: arrayArg(opts['guarded-file']).map(normalizeRel)
  };
  saveActive(active);
  return active;
}

function main() {
  const parsed = args();
  const command = parsed._[0];
  const result =
    command === 'initialize' ? initialize(parsed) :
    command === 'arm' ? arm(parsed) :
    command === 'validate-state' ? validateActive(loadActive()) :
    command === 'disarm' ? disarmActive(parsed.name) :
    null;
  if (!result) throw new Error('usage: initialize|arm|validate-state|disarm');
  process.stdout.write(`${JSON.stringify({ ok: true, command, result }, null, 2)}\n`);
}

try {
  main();
} catch (error) {
  process.stderr.write(`${error.message}\n`);
  process.exit(1);
}

depcruiser-migrations.mjs

#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { loadActive, validateActive } from './lib/state-store.mjs';
import { extractToolPaths, isGuardedPath, toRepoRelative } from './lib/path-scope.mjs';

const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch']);

function readJsonStdin() {
  try {
    const raw = readFileSync(0, 'utf8').replace(String.fromCharCode(65279), '');
    return raw.trim() ? JSON.parse(raw) : {};
  } catch {
    return {};
  }
}

function allow() {
  return { continue: true };
}

function deny(reason) {
  return {
    hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
    systemMessage: reason
  };
}

function manifestReady(manifestPath) {
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
  const modules = Array.isArray(manifest.modules) ? manifest.modules : [];
  const invalid = modules.filter((item) => !['port', 'adapter', 'cut'].includes(item.verdict));
  const missingTargets = modules.filter((item) => ['port', 'adapter'].includes(item.verdict) && !item.target_path);
  return { ok: invalid.length === 0 && missingTargets.length === 0, invalid, missingTargets };
}

export function evaluate(input) {
  if (!EDIT_TOOLS.has(input.tool_name || '')) return allow();
  const active = loadActive();
  if (!active || active.status !== 'active') return allow();

  try {
    validateActive(active);
  } catch (error) {
    return deny(`[depcruiser-migrations] active state is invalid: ${error.message}. Run depcruiser-migrations-cli.mjs validate-state.`);
  }

  const touched = extractToolPaths(input).map((file) => toRepoRelative(active.targetRoot, file)).filter(Boolean);
  if (!touched.some((file) => isGuardedPath(file, active))) return allow();

  if (!existsSync(active.sourceManifest)) return deny(`[depcruiser-migrations] source manifest is missing at ${active.sourceManifest}. Run initialize before guarded edits.`);

  const readiness = manifestReady(active.sourceManifest);
  if (!readiness.ok) return deny('[depcruiser-migrations] adjudication is incomplete. Set all verdicts and target_path values, then get user approval before implementation.');

  return allow();
}

if (process.argv.includes('--self-test')) {
  process.stdout.write(`${JSON.stringify(evaluate({ tool_name: 'Edit', tool_input: { file_path: 'x' } }))}\n`);
} else {
  process.stdout.write(JSON.stringify(evaluate(readJsonStdin())));
}

verify-active-closure.mjs

#!/usr/bin/env node
import { existsSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { appendEvent, loadActive, validateActive } from './lib/state-store.mjs';

function run(root, command, args, options = {}) {
  const result = spawnSync(command, args, { cwd: root, encoding: 'utf8', shell: process.platform === 'win32' });
  if (options.stdoutPath) writeFileSync(options.stdoutPath, result.stdout || '', 'utf8');
  return { command: `${command} ${args.join(' ')}`, cwd: root, status: result.status, stderr: result.stderr || '', stdout: result.stdout || '' };
}

function typecheck(root) {
  if (existsSync(join(root, 'pnpm-lock.yaml'))) return run(root, 'pnpm', ['exec', 'tsc', '--noEmit']);
  if (existsSync(join(root, 'yarn.lock'))) return run(root, 'yarn', ['tsc', '--noEmit']);
  return run(root, 'npx', ['tsc', '--noEmit']);
}

function main() {
  const active = validateActive(loadActive());
  const depcruise = run(active.targetRoot, 'npx', [
    'depcruise',
    '--config',
    'E:/hooks/depcruiser-migrations/dependency-cruiser.cjs',
    '--output-type',
    'json',
    '--ts-pre-compilation-deps',
    active.targetEntry
  ], { stdoutPath: active.targetTrace });

  const tsc = typecheck(active.targetRoot);
  const reconcile = run('E:/hooks', 'node', [
    'depcruiser-migrations/validate-closure-manifest.mjs',
    '--mode',
    'reconcile',
    '--manifest',
    active.sourceManifest,
    '--target',
    active.targetTrace,
    '--target-root',
    active.targetRoot
  ]);

  const report = {
    migrationId: active.migrationId,
    currentNode: 'closure_verification',
    targetTrace: active.targetTrace,
    sourceManifest: active.sourceManifest,
    depcruise: { status: depcruise.status, stderr: depcruise.stderr },
    typecheck: { status: tsc.status, command: tsc.command, stderr: tsc.stderr },
    reconcile: { status: reconcile.status, stdout: reconcile.stdout, stderr: reconcile.stderr },
    passed: depcruise.status === 0 && tsc.status === 0 && reconcile.status === 0
  };

  writeFileSync(active.verificationReport, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
  appendEvent({ event: 'closure_verified', migrationId: active.migrationId, currentNode: 'closure_verification', status: report.passed ? 'passed' : 'failed', artifactPath: active.verificationReport });
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  process.exit(report.passed ? 0 : 1);
}

main();

tests/depcruiser-migrations.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { extractApplyPatchPaths, isGuardedPath, normalizeRel } from '../lib/path-scope.mjs';
import { disarmActive, loadActive, saveActive } from '../lib/state-store.mjs';

test('normalizes repo-relative paths and rejects traversal', () => {
  assert.equal(normalizeRel('./src/page.tsx'), 'src/page.tsx');
  assert.throws(() => normalizeRel('../outside.ts'), /traversal/);
});

test('extracts apply_patch file paths', () => {
  const patch = ['*** Begin Patch', '*** Add File: src/new.ts', '*** Update File: src/existing.ts', '*** Delete File: src/remove.ts', '*** End Patch'].join('\n');
  assert.deepEqual(extractApplyPatchPaths(patch), ['src/new.ts', 'src/existing.ts', 'src/remove.ts']);
});

test('matches guarded prefixes and guarded files', () => {
  const active = { guardedPrefixes: ['src/routes'], guardedFiles: ['src/app.tsx'] };
  assert.equal(isGuardedPath('src/routes/page.tsx', active), true);
  assert.equal(isGuardedPath('src/app.tsx', active), true);
  assert.equal(isGuardedPath('src/other.tsx', active), false);
});

test('writes and disarms active state', () => {
  const stateDir = mkdtempSync(join(tmpdir(), 'depcruiser-state-'));
  saveActive({
    version: 1,
    migrationId: 'sample',
    currentNode: 'implementation',
    status: 'active',
    targetRoot: 'E:/target',
    targetEntry: 'src/page.tsx',
    manifestDir: 'E:/hooks/depcruiser-migrations/manifests/sample',
    sourceManifest: 'E:/hooks/depcruiser-migrations/manifests/sample/sample.source-manifest.json',
    guardedPrefixes: ['src'],
    guardedFiles: []
  }, stateDir);
  assert.equal(loadActive(stateDir).migrationId, 'sample');
  assert.equal(disarmActive('sample', stateDir).disarmed, true);
  assert.match(readFileSync(join(stateDir, 'events.jsonl'), 'utf8'), /disarmed/);
});

Existing File Changes

generate-closure-manifest.mjs

Modify the generated manifest shape so later scripts have one stable schema:

const manifest = {
  schema_version: 1,
  donor_entry: entry,
  generated_from: path.basename(inPath),
  generated_at: new Date().toISOString(),
  node_count: local.length,
  port_order: order,
  external_deps: [...external],
  unresolved,
  dynamic_edges: dynamicEdges,
  type_only_edges: typeOnlyEdges,
  target_extras: [],
  approvals: { adjudication: null, closure: null },
  modules: local.map((source) => ({
    source,
    imported_by: importedBy[source] || [],
    reachable_via: [...(edgeKind[source] || ['(entry)'])],
    depends_on: dependsOn[source],
    verdict: 'TBD',
    target_path: null,
    reason: null
  }))
};

Do not change the donor trace command in this file. The CLI owns running dependency-cruiser; this file only converts dependency-cruiser JSON into the source manifest.

validate-closure-manifest.mjs

Replace basename reconcile with strict repo-relative reconcile:

const allowedVerdicts = new Set(['port', 'adapter', 'cut']);

function assertRepoRelative(value, label) {
  if (!value || path.isAbsolute(value)) throw new Error(`${label} must be repo-relative`);
  const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '');
  if (normalized.split('/').includes('..')) throw new Error(`${label} cannot contain path traversal`);
  return normalized;
}

function targetLocalSources(depcruiseJson) {
  return new Set(
    depcruiseJson.modules
      .filter((item) => /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/.test(item.source))
      .filter((item) => !item.source.startsWith('node_modules/'))
      .map((item) => assertRepoRelative(item.source, 'target source'))
  );
}

function reconcile(manifest, targetJson) {
  const errors = [];
  const targetSet = targetLocalSources(targetJson);
  const modules = Array.isArray(manifest.modules) ? manifest.modules : [];
  const extras = new Map((manifest.target_extras || []).map((extra) => [assertRepoRelative(extra.path, 'target extra path'), extra.reason]));
  const seenTargets = new Set();

  for (const item of modules) {
    if (!allowedVerdicts.has(item.verdict)) errors.push(`invalid verdict for ${item.source}: ${item.verdict}`);
    if (item.verdict === 'cut' && targetSet.has(assertRepoRelative(item.source, 'cut source'))) errors.push(`leftover cut still present: ${item.source}`);
    if (item.verdict === 'port' || item.verdict === 'adapter') {
      const target = assertRepoRelative(item.target_path, `${item.source} target_path`);
      if (seenTargets.has(target)) errors.push(`duplicate target_path: ${target}`);
      seenTargets.add(target);
      if (!targetSet.has(target)) errors.push(`missing port or adapter: ${target}`);
    }
  }

  for (const target of targetSet) if (!seenTargets.has(target) && !extras.has(target)) errors.push(`unexpected target node: ${target}`);
  for (const [extra, reason] of extras) {
    if (!reason) errors.push(`target extra missing reason: ${extra}`);
    if (!targetSet.has(extra)) errors.push(`declared target extra not found: ${extra}`);
  }
  return errors;
}

The implemented file must keep both modes:

  • --mode verdicts
  • --mode reconcile --manifest <manifest> --target <target.depcruise.json> --target-root <targetRepo>

initialize-depcruiser-migrations/SKILL.md

Rewrite the skill around deterministic scripts:

# initialize-depcruiser-migrations

Use when the user wants to migrate a page or frontend entry from a donor repo to a target repo with dependency-cruiser closure control.

## Required sequence

1. Gather only variable inputs: migration name, donor root, donor entry, donor tsconfig, target root, target entry, guarded prefixes, and guarded files.
2. Run `node E:/hooks/depcruiser-migrations/depcruiser-migrations-cli.mjs initialize ...`.
3. Read the generated source manifest.
4. Adjudicate every module as `port`, `adapter`, or `cut`.
5. Ask for user approval 1.
6. Run `node E:/hooks/depcruiser-migrations/depcruiser-migrations-cli.mjs arm ...`.
7. Run `node E:/hooks/depcruiser-migrations/depcruiser-migrations-cli.mjs validate-state`.
8. Implement the approved migration.
9. Run `node E:/hooks/depcruiser-migrations/verify-active-closure.mjs`.
10. If verification fails, return to implementation or adjudication based on the failure.
11. When verification passes, ask for user approval 2.
12. Run `node E:/hooks/depcruiser-migrations/depcruiser-migrations-cli.mjs disarm --name <migration-name>`.

Do not manually edit `.state`.
Do not manually install dependency-cruiser except through the initialize command.
Do not edit guarded target files before adjudication approval.

README.md

Rewrite the README so it documents the new runtime instead of the old closure-gate draft:

# depcruiser-migrations

Runtime bundle for dependency-cruiser governed page migrations.

## Entry point

The user invokes `initialize-depcruiser-migrations`. The worker gathers migration inputs and runs `depcruiser-migrations-cli.mjs initialize`.

## Commands

- `initialize`
- `arm`
- `validate-state`
- `disarm`
- `verify-active-closure.mjs`

## State

Active runtime state lives in `E:/hooks/.state/depcruiser-migrations`.

## Approvals

The runtime requires exactly two user approvals:

1. Adjudication approval before implementation.
2. Closure approval after target verification and before disarm.

config.json

Add a disabled hook entry. Do not put active migration data in config.json.

{
  "id": "depcruiser-migrations",
  "name": "Depcruiser Migrations",
  "description": "PreToolUse gate and CLI for governed dependency-cruiser migrations.",
  "category": "gate",
  "event": "PreToolUse",
  "match": { "tools": ["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"] },
  "script": { "path": "depcruiser-migrations/depcruiser-migrations.mjs", "runtime": "node" },
  "scope": { "projects": ["*"], "paths": ["**"] },
  "enabled": false,
  "failPolicy": "open",
  "settings": {
    "stateDir": "E:/hooks/.state/depcruiser-migrations",
    "editTools": ["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]
  }
}

config.schema.json is generated output. This plan does not require a config model change, so it must not be hand-edited.

quality-completion-gate/quality-verify-manifest.json

Add focused depcruiser checks to the hooks runtime command list:

{
  "label": "depcruiser migrations runtime",
  "command": "node --check depcruiser-migrations/depcruiser-migrations.mjs && node --check depcruiser-migrations/depcruiser-migrations-cli.mjs && node --check depcruiser-migrations/verify-active-closure.mjs && node --check depcruiser-migrations/lib/state-store.mjs && node --check depcruiser-migrations/lib/path-scope.mjs && node depcruiser-migrations/tests/depcruiser-migrations.test.mjs",
  "timeoutMs": 30000
}

Implementation Tasks

Task 1: Add state and path libraries

Files: depcruiser-migrations/lib/state-store.mjs, depcruiser-migrations/lib/path-scope.mjs, depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Steps: Create both libraries, add tests for path normalization, traversal rejection, apply_patch extraction, guarded matching, active write, and disarm, then run the focused test.

Test command: node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: Node test exits 0.

Commit: feat: add depcruiser migration state primitives

Task 2: Add graph contract and CLI

Files: depcruiser-migrations/graph.contract.json, depcruiser-migrations/depcruiser-migrations-cli.mjs

Steps: Add the graph contract, implement initialize, arm, validate-state, and disarm, add CLI tests for argument validation and state transitions, then run focused tests.

Test command: node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: Node test exits 0.

Commit: feat: add depcruiser migrations cli

Task 3: Add PreToolUse gate

Files: depcruiser-migrations/depcruiser-migrations.mjs, depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Steps: Implement unarmed allow, malformed-state deny, guarded edit detection for direct paths and apply_patch, manifest-readiness checks, and tests for each branch.

Test command: node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: Node test exits 0.

Commit: feat: add depcruiser migrations edit gate

Task 4: Harden manifest generation and reconcile

Files: depcruiser-migrations/generate-closure-manifest.mjs, depcruiser-migrations/validate-closure-manifest.mjs, depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Steps: Add schema fields to generated manifests, replace basename reconcile with repo-relative reconcile, block missing ports, leftover cuts, unexpected target nodes, undeclared extras, duplicate target paths, and path traversal, then add tests for each blocking case.

Test command: node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: Node test exits 0.

Commit: fix: harden depcruiser closure reconcile

Task 5: Add active closure verifier

Files: depcruiser-migrations/verify-active-closure.mjs, depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Steps: Load active state, run target dependency-cruiser, detect target package manager, run tsc --noEmit, run strict reconcile, write verification-report.json, and return exit code 0 only when all checks pass.

Test command: node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: Node test exits 0.

Commit: feat: verify active depcruiser closure

Task 6: Rewrite the skill and README

Files: depcruiser-migrations/initialize-depcruiser-migrations/SKILL.md, depcruiser-migrations/README.md

Steps: Rewrite the skill around the CLI-first workflow, state the two approval points exactly, remove manual .state editing and config-backed arming language, and rewrite the README.

Test command: node --check depcruiser-migrations/depcruiser-migrations-cli.mjs

Expected output: Node exits 0.

Commit: docs: update depcruiser migrations workflow

Task 7: Register disabled hook and quality checks

Files: config.json, quality-completion-gate/quality-verify-manifest.json

Steps: Add the disabled depcruiser-migrations hook entry, keep active migration data out of config.json, add focused syntax and test commands to the quality manifest, and run config checks.

Test commands:

node _core/validate-runtime-hooks.mjs
node quality-completion-gate/test-config-model.mjs
node depcruiser-migrations/tests/depcruiser-migrations.test.mjs

Expected output: All commands exit 0.

Commit: chore: register depcruiser migrations hook

Task 8: Remove old closure-gate draft files

Files: depcruiser-migrations/closure-gate.mjs, depcruiser-migrations/closure-gate-core.mjs, depcruiser-migrations/closure-gate.register.json

Steps: Confirm config.json has no live closure-gate hook entry, confirm the new disabled hook validates, delete old closure-gate files, and run final checks.

Test commands:

node _core/validate-runtime-hooks.mjs
node depcruiser-migrations/tests/depcruiser-migrations.test.mjs
git diff --check

Expected output: Runtime validation passes, focused tests pass, and diff check has no whitespace errors.

Commit: chore: retire old closure gate draft

Verification Strategy

Run these checks before claiming implementation complete:

node --check depcruiser-migrations/depcruiser-migrations.mjs
node --check depcruiser-migrations/depcruiser-migrations-cli.mjs
node --check depcruiser-migrations/verify-active-closure.mjs
node --check depcruiser-migrations/lib/state-store.mjs
node --check depcruiser-migrations/lib/path-scope.mjs
node depcruiser-migrations/tests/depcruiser-migrations.test.mjs
node _core/validate-runtime-hooks.mjs
node quality-completion-gate/test-config-model.mjs
git diff --check

For a sample dry-run migration, the verifier must also produce:

  • before-edit.donor.json
  • <name>.source-manifest.json
  • adjudication-approval.json
  • active.json
  • after-edit.target.json
  • verification-report.json
  • closure-approval.json
  • a disarmed event in events.jsonl

Risks

  1. Dependency-cruiser install can mutate donor or target lockfiles. The initialize command must report exactly what it changed.
  2. apply_patch extraction can miss a future patch format. The tests must lock the current format and the hook must fail closed only for guarded paths it can identify.
  3. Strict reconcile can over-block target-native framework files unless target_extras[] is used correctly. The skill must teach the worker to declare target extras during adjudication.
  4. The hook is disabled in config.json after implementation. Enabling it is an operator step after validation.

Completion Criteria

The implementation is complete only when all of the following are true:

  1. The file inventory in this plan matches the repo.
  2. The active hook id is depcruiser-migrations, not closure-gate.
  3. Active migration state lives under E:/hooks/.state/depcruiser-migrations.
  4. initialize performs package-manager detection, dependency-cruiser install-if-missing, donor trace, manifest generation, and setup artifact writes.
  5. The worker does not manually edit .state.
  6. The runtime requires exactly two user approvals.
  7. Strict reconcile blocks missing ports, leftover cuts, unexpected target nodes, duplicate target paths, and path traversal.
  8. The target verifier runs dependency-cruiser, tsc --noEmit, and strict reconcile.
  9. Focused depcruiser tests pass.
  10. Hooks config validation passes.
  11. The old closure-gate draft files are removed after the new runtime validates.

Plan Validity Check

This plan declares zero platform API, zero database migration, zero edge function, zero frontend surface, and zero OpenTelemetry work because the owned runtime seam is the local hooks repo. The plan still declares the full hook runtime surface, state surface, generated artifact surface, quality gate surface, and completion checks needed to make the proposal implementable.