tamperward 2.20.5 → 2.21.0

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/README.md CHANGED
@@ -368,13 +368,29 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
368
368
  ## Quick start
369
369
 
370
370
  ```bash
371
- npx tamperward init
371
+ npx tamperward onboard
372
372
  ```
373
373
 
374
374
  Requires Node.js 20.19 or later. JavaScript and TypeScript are the fully
375
375
  supported detector surface; the other documented ecosystems get file-level and
376
376
  pattern-based protection.
377
377
 
378
+ `onboard` (2.21.0) is the guided first run: it previews the installation with the
379
+ same planner as `init --dry-run`, explains each enforcement point, asks before writing
380
+ anything, runs the canonical `init`, offers the detected suite command for your
381
+ explicit acceptance (it is never written without one), runs and explains the first
382
+ `verify`, offers a safe demonstration of a weakening move on a disposable worktree that
383
+ leaves your tree byte-for-byte as it was, checks the GitHub controls with
384
+ `doctor --github` (or prints them), and ends with a `READY` / `READY WITH WARNINGS` /
385
+ `BROKEN` / `INCOMPLETE` posture taken from `doctor`. Non-interactive stdin refuses
386
+ rather than hangs; `--yes --verify-command "<cmd>"` is the scripted form.
387
+
388
+ The deterministic primitive underneath is unchanged:
389
+
390
+ ```bash
391
+ npx tamperward init
392
+ ```
393
+
378
394
  One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
379
395
  CI workflow that runs both the diff-time check and pristine verification, and a
380
396
  `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
@@ -566,6 +582,7 @@ option can never be reinterpreted as the agent command.
566
582
  | `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--json` (one versioned final envelope document) · `--observe-transients` (start a session-scoped transient observer) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
567
583
  | `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
568
584
  | `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
585
+ | `onboard` | `--cwd <dir>` · `--base <rev>` · `--repo <owner/repo>` · `--branch <name>` · `--skip-demo` / `--demo` (mutually exclusive) · `--no-github` · `--yes` (scripted: no prompts; the demo runs only with `--demo`) · `--verify-command "<suite command>"` (the only way a scripted run configures `verify.command`) |
569
586
  | `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
570
587
  | `hook claude` / `sweep claude` | none — the Claude Code payload arrives on stdin |
571
588
 
@@ -581,6 +598,7 @@ option can never be reinterpreted as the agent command.
581
598
  | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
582
599
  | `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
583
600
  | `init` | wired, or already wired | — | an item needs attention | — |
601
+ | `onboard` | posture `READY` or `READY WITH WARNINGS` (from `doctor`) | posture `BROKEN` or `INCOMPLETE`, including a declined write or an unconfigured verifier | refused (not a git repository, non-interactive stdin without `--yes`, a dirty tree the operator would not continue on) or aborted at a prompt | — |
584
602
  | no or unknown command | help printed (no command) | — | unknown command, help printed | — |
585
603
 
586
604
  ### Environment variables
@@ -709,6 +727,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
709
727
  npm install && npm run build # bundles the CLI to dist/cli/index.js
710
728
  npm test # 2,100+ tests — parser, detectors, engine, policy, renderers
711
729
  npm run typecheck
730
+ node harness/perf/bench.mjs # performance budgets against harness/perf/BASELINE.json (docs/PERF.md)
712
731
  ```
713
732
 
714
733
  Tamperward's own CI runs the engine it ships over every pull request — `check --diff`
package/dist/cli/index.js CHANGED
@@ -2202,8 +2202,8 @@ function numericOf(e) {
2202
2202
  }
2203
2203
  return void 0;
2204
2204
  }
2205
- function metricsOf(expr, resolve10) {
2206
- const obj = resolve10(expr);
2205
+ function metricsOf(expr, resolve11) {
2206
+ const obj = resolve11(expr);
2207
2207
  if (!ts4.isObjectLiteralExpression(obj)) return void 0;
2208
2208
  const out2 = { values: {}, opaque: false };
2209
2209
  let any = false;
@@ -2217,7 +2217,7 @@ function metricsOf(expr, resolve10) {
2217
2217
  const k = keyName(p.name);
2218
2218
  if (k === null || !isMetric(k)) continue;
2219
2219
  any = true;
2220
- const n = numericOf(resolve10(p.initializer));
2220
+ const n = numericOf(resolve11(p.initializer));
2221
2221
  if (n === void 0) out2.opaque = true;
2222
2222
  else out2.values[k] = n;
2223
2223
  }
@@ -2263,17 +2263,17 @@ function parseThresholds(src) {
2263
2263
  const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
2264
2264
  try {
2265
2265
  const sf = sourceOf(src);
2266
- const resolve10 = resolverFor(sf);
2266
+ const resolve11 = resolverFor(sf);
2267
2267
  const visit = (node) => {
2268
2268
  if (ts4.isPropertyAssignment(node)) {
2269
2269
  const key2 = keyName(node.name);
2270
2270
  if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
2271
2271
  res.present = true;
2272
- const init = resolve10(node.initializer);
2272
+ const init = resolve11(node.initializer);
2273
2273
  if (!ts4.isObjectLiteralExpression(init)) {
2274
2274
  res.opaque = true;
2275
2275
  } else {
2276
- const flat = metricsOf(init, resolve10);
2276
+ const flat = metricsOf(init, resolve11);
2277
2277
  if (flat) res.global = merge(res.global, flat);
2278
2278
  for (const p of init.properties) {
2279
2279
  if (ts4.isSpreadAssignment(p)) {
@@ -2287,7 +2287,7 @@ function parseThresholds(src) {
2287
2287
  res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
2288
2288
  continue;
2289
2289
  }
2290
- const m = metricsOf(p.initializer, resolve10);
2290
+ const m = metricsOf(p.initializer, resolve11);
2291
2291
  if (!m) continue;
2292
2292
  if (k === "global") res.global = merge(res.global, m);
2293
2293
  else res.paths.set(norm(k), m);
@@ -2320,18 +2320,18 @@ function parseLists(src) {
2320
2320
  const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
2321
2321
  try {
2322
2322
  const sf = sourceOf(src);
2323
- const resolve10 = resolverFor(sf);
2323
+ const resolve11 = resolverFor(sf);
2324
2324
  const visit = (node) => {
2325
2325
  if (ts4.isPropertyAssignment(node)) {
2326
2326
  const k = keyName(node.name);
2327
2327
  const vitestCoverage = ownerKey(node) === "coverage";
2328
2328
  if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
2329
- const { items, opaque } = literals(resolve10(node.initializer));
2329
+ const { items, opaque } = literals(resolve11(node.initializer));
2330
2330
  res.collectFrom = items;
2331
2331
  res.collectKey = k === "include" ? "coverage.include" : k;
2332
2332
  if (opaque) res.opaqueCollect = true;
2333
2333
  } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
2334
- const { items, opaque } = literals(resolve10(node.initializer));
2334
+ const { items, opaque } = literals(resolve11(node.initializer));
2335
2335
  res.ignore.push(...items);
2336
2336
  if (opaque) res.opaqueIgnore = true;
2337
2337
  }
@@ -7030,7 +7030,7 @@ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
7030
7030
  process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
7031
7031
  `);
7032
7032
  if (view === "staged" || view === "worktree" || view === "range") {
7033
- const failed = {
7033
+ const failed2 = {
7034
7034
  rule: "detector-error",
7035
7035
  severity: "block",
7036
7036
  message: `Detector "${d.id}" failed to run; the verdict is incomplete.`,
@@ -7038,7 +7038,7 @@ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
7038
7038
  remediation: "A rule that cannot run is not a rule that passed. Fix the input or the detector; do not read this as clean.",
7039
7039
  signoff: { required: true, command: `tamperward allow detector-error --reason "..."` }
7040
7040
  };
7041
- out2.push(failed);
7041
+ out2.push(failed2);
7042
7042
  }
7043
7043
  }
7044
7044
  }
@@ -11211,6 +11211,7 @@ function runVerify(opts) {
11211
11211
  const out2 = opts.silent ? (_s) => {
11212
11212
  } : (s) => void process.stdout.write(s + "\n");
11213
11213
  const cannotVerify = (reason, detail, extra = {}) => {
11214
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason, ...detail ? { detail } : {} });
11214
11215
  if (opts.json) {
11215
11216
  out2(JSON.stringify({
11216
11217
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11256,6 +11257,7 @@ function runVerify(opts) {
11256
11257
  const verifierBackend = opts.verifierBackend ?? prepareVerifierBackend(policy.verify);
11257
11258
  const backendReport = () => verifierBackendReport(verifierBackend);
11258
11259
  if (!verifierBackend.available) {
11260
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "VERIFIER_BACKEND_UNAVAILABLE", ...verifierBackend.reason ? { detail: verifierBackend.reason } : {} });
11259
11261
  if (opts.json) {
11260
11262
  out2(JSON.stringify({
11261
11263
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11272,6 +11274,7 @@ function runVerify(opts) {
11272
11274
  }
11273
11275
  const isolated = verifierBackend.kind === "container";
11274
11276
  if (!isolated && !localVerifierShell(process.platform, cmd)) {
11277
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "LOCAL_VERIFIER_UNSUPPORTED_PLATFORM", detail: `checkpointed-local verifier is unsupported on ${process.platform}` });
11275
11278
  if (opts.json) {
11276
11279
  out2(JSON.stringify({
11277
11280
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11295,6 +11298,7 @@ function runVerify(opts) {
11295
11298
  image: verifierBackend.image
11296
11299
  };
11297
11300
  if (dependencyEnvironment?.status === "unattestable" && !opts.allowDepDrift) {
11301
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "DEPENDENCY_ENVIRONMENT_UNATTESTABLE", ...dependencyEnvironment.reason ? { detail: dependencyEnvironment.reason } : {} });
11298
11302
  if (opts.json) {
11299
11303
  out2(JSON.stringify({
11300
11304
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11355,6 +11359,7 @@ function runVerify(opts) {
11355
11359
  if (visible.failure === "backend" || visible.failure === "resource") {
11356
11360
  cleanup([visRoot]);
11357
11361
  const exhausted = visible.failure === "resource";
11362
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE", ...visible.reason ? { detail: visible.reason } : {} });
11358
11363
  if (opts.json) {
11359
11364
  out2(JSON.stringify({
11360
11365
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11421,6 +11426,7 @@ function runVerify(opts) {
11421
11426
  if (pristine.failure === "backend" || pristine.failure === "resource") {
11422
11427
  cleanup([visRoot, priRoot]);
11423
11428
  const exhausted = pristine.failure === "resource";
11429
+ opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE", ...pristine.reason ? { detail: pristine.reason } : {} });
11424
11430
  if (opts.json) {
11425
11431
  out2(JSON.stringify({
11426
11432
  schema_version: MACHINE_SCHEMA_VERSION,
@@ -11476,6 +11482,7 @@ function runVerify(opts) {
11476
11482
  verdict2 = "SUITE_RED";
11477
11483
  code2 = 1;
11478
11484
  }
11485
+ opts.onVerdict?.({ verdict: verdict2 });
11479
11486
  const signedOff = verdict2 === "MASKED_FAILURE" ? oobToken("verify", oobFromEnv(), oobHeadFromEnv()) : null;
11480
11487
  if (signedOff) code2 = 0;
11481
11488
  if (opts.json) {
@@ -12482,22 +12489,17 @@ function lifecyclePlatformCheck(platform = process.platform, linuxPython = platf
12482
12489
  };
12483
12490
  }
12484
12491
  var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
12485
- function err2(message, opts, id = "doctor", checks = []) {
12486
- if (opts?.json) {
12487
- const report2 = machineOutput({
12488
- command: "doctor",
12489
- authoritative: false,
12490
- checks: [
12491
- ...checks.filter((check2) => check2.id !== id),
12492
- { id, state: "BROKEN", detail: message }
12493
- ]
12494
- });
12495
- process.stdout.write(JSON.stringify(report2) + "\n");
12496
- } else {
12497
- process.stderr.write(`tamperward doctor: ${message}
12498
- `);
12499
- }
12500
- return 2;
12492
+ function failed(message, id = "doctor", checks = []) {
12493
+ return {
12494
+ code: 2,
12495
+ authoritative: false,
12496
+ checks: [
12497
+ ...checks.filter((check2) => check2.id !== id),
12498
+ { id, state: "BROKEN", detail: message }
12499
+ ],
12500
+ failure: { id, message },
12501
+ summary: []
12502
+ };
12501
12503
  }
12502
12504
  function policyFor(opts, cwd) {
12503
12505
  if (!opts.base) return loadPolicy(cwd);
@@ -12701,8 +12703,7 @@ function observerCheck(cwd) {
12701
12703
  detail: `unavailable${observer.reason ? ": " + observer.reason : ""}; optional/advisory, and zero events are not evidence of no transient activity`
12702
12704
  };
12703
12705
  }
12704
- function emitReport(opts, checks) {
12705
- const authoritative = !checks.some((x) => x.state === "BROKEN");
12706
+ function emitReport(opts, checks, authoritative) {
12706
12707
  if (opts.json) {
12707
12708
  const report2 = machineOutput({ command: "doctor", authoritative, checks });
12708
12709
  process.stdout.write(JSON.stringify(report2) + "\n");
@@ -12894,22 +12895,20 @@ function verifyJobs(doc) {
12894
12895
  }
12895
12896
  return found;
12896
12897
  }
12897
- function runDoctor(opts = {}) {
12898
+ function diagnose(opts = {}) {
12898
12899
  const cwd = resolve8(opts.cwd ?? process.cwd());
12899
12900
  let policy;
12900
12901
  try {
12901
12902
  policy = policyFor(opts, cwd);
12902
12903
  } catch (e) {
12903
- return err2(
12904
+ return failed(
12904
12905
  e instanceof PolicyError || e instanceof Error ? e.message : String(e),
12905
- opts,
12906
12906
  "policy"
12907
12907
  );
12908
12908
  }
12909
12909
  let postureWorkflows = opts.workflow ? [opts.workflow] : void 0;
12910
- const fail = (id, message) => err2(
12910
+ const fail = (id, message) => failed(
12911
12911
  message,
12912
- opts,
12913
12912
  id,
12914
12913
  collectLocalPosture(cwd, policy, postureWorkflows, Boolean(opts.workflow))
12915
12914
  );
@@ -13037,19 +13036,38 @@ function runDoctor(opts = {}) {
13037
13036
  });
13038
13037
  }
13039
13038
  checks.push(observerCheck(cwd));
13040
- if (!opts.json) {
13041
- process.stdout.write(
13042
- `tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
13043
- `
13039
+ const summary = [
13040
+ `tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.`
13041
+ ];
13042
+ if (github) {
13043
+ summary.push(
13044
+ "tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes."
13044
13045
  );
13045
- if (github) {
13046
- process.stdout.write(
13047
- "tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes.\n"
13048
- );
13046
+ }
13047
+ return {
13048
+ code: 0,
13049
+ authoritative: !checks.some((x) => x.state === "BROKEN"),
13050
+ checks,
13051
+ summary,
13052
+ ...github ? { github: { repo: github.repo, branch: github.branch } } : {}
13053
+ };
13054
+ }
13055
+ function runDoctor(opts = {}) {
13056
+ const outcome = diagnose(opts);
13057
+ if (outcome.failure) {
13058
+ if (opts.json) {
13059
+ emitReport(opts, outcome.checks, false);
13060
+ } else {
13061
+ process.stderr.write(`tamperward doctor: ${outcome.failure.message}
13062
+ `);
13049
13063
  }
13064
+ return outcome.code;
13050
13065
  }
13051
- emitReport(opts, checks);
13052
- return 0;
13066
+ if (!opts.json) {
13067
+ for (const line of outcome.summary) process.stdout.write(line + "\n");
13068
+ }
13069
+ emitReport(opts, outcome.checks, outcome.authoritative);
13070
+ return outcome.code;
13053
13071
  }
13054
13072
 
13055
13073
  // src/cli/trace-verify.ts
@@ -13430,6 +13448,507 @@ function parseTraceVerify(args) {
13430
13448
  return out2;
13431
13449
  }
13432
13450
 
13451
+ // src/cli/onboard.ts
13452
+ import { execFileSync as execFileSync12 } from "node:child_process";
13453
+ import { randomBytes as randomBytes2 } from "node:crypto";
13454
+ import { lstatSync as lstatSync6, mkdtempSync as mkdtempSync6, readFileSync as readFileSync19, realpathSync as realpathSync8, renameSync as renameSync2, rmSync as rmSync7, writeFileSync as writeFileSync10 } from "node:fs";
13455
+ import { tmpdir as tmpdir6 } from "node:os";
13456
+ import { join as join20, resolve as resolve10, sep as sep4 } from "node:path";
13457
+ import { createInterface } from "node:readline";
13458
+ import { parseDocument } from "yaml";
13459
+ function readlineAsker(input, output) {
13460
+ const rl = createInterface({ input, output });
13461
+ let closed = false;
13462
+ let pending = null;
13463
+ rl.on("close", () => {
13464
+ closed = true;
13465
+ const resolve11 = pending;
13466
+ pending = null;
13467
+ resolve11?.(null);
13468
+ });
13469
+ rl.on("SIGINT", () => rl.close());
13470
+ const ask = (question) => new Promise((resolvePromise) => {
13471
+ if (closed) {
13472
+ resolvePromise(null);
13473
+ return;
13474
+ }
13475
+ pending = resolvePromise;
13476
+ rl.question(question, (answer) => {
13477
+ pending = null;
13478
+ resolvePromise(answer);
13479
+ });
13480
+ });
13481
+ ask.close = () => {
13482
+ if (!closed) rl.close();
13483
+ };
13484
+ return ask;
13485
+ }
13486
+ var Aborted = class extends Error {
13487
+ };
13488
+ var STEPS = [
13489
+ "Preflight",
13490
+ "Preview the installation",
13491
+ "Initialize",
13492
+ "Configure verification",
13493
+ "First verification",
13494
+ "Safe demonstration",
13495
+ "GitHub repository authority",
13496
+ "Posture",
13497
+ "Next steps"
13498
+ ];
13499
+ var MANUAL_CONTROLS = [
13500
+ "In the branch-protection or ruleset settings of the protected branch, configure ALL THREE:",
13501
+ " 1. require the `tamperward` status check;",
13502
+ ' 2. enable "Require review from Code Owners";',
13503
+ ' 3. enable "Dismiss stale pull request approvals when new commits are pushed".',
13504
+ "Until all three are enforced the generated CI gate is advisory: a pull request runs the",
13505
+ "workflow from its own head and a required check is matched by job name, so it could keep",
13506
+ "the job called `tamperward`, replace the gate with `true`, and report green."
13507
+ ];
13508
+ var JS_TEST_PATH = /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[cm]?[jt]sx?|__tests__\/[^/]+\.[cm]?[jt]sx?)$/;
13509
+ var JS_TEST_BLOCK = /^(\s*)(it|test|describe)(\s*\()/m;
13510
+ function git5(cwd, args) {
13511
+ try {
13512
+ return execFileSync12("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
13513
+ } catch {
13514
+ return null;
13515
+ }
13516
+ }
13517
+ function explainVerdict(v, code2) {
13518
+ const verdict2 = v?.verdict ?? (code2 === 0 ? "VERIFIED" : code2 === 1 ? "SUITE_RED" : "CANNOT_VERIFY");
13519
+ switch (verdict2) {
13520
+ case "VERIFIED":
13521
+ return [
13522
+ "VERIFIED: your suite passes, and still passes with every protected test, snapshot and",
13523
+ "config file restored from the base. Nothing was masked. Exit 0."
13524
+ ];
13525
+ case "SUITE_RED":
13526
+ return [
13527
+ "SUITE_RED: your suite fails as it is. That is not a TamperWard finding \u2014 fix the code",
13528
+ "(or the suite) first; verify exits 1 until the visible run is green."
13529
+ ];
13530
+ case "MASKED_FAILURE":
13531
+ return [
13532
+ "MASKED_FAILURE: your suite passes as it is, but FAILS once the protected files are restored",
13533
+ "from the base. Something weakened the checks; the code does not pass the original suite.",
13534
+ "Exit 1. In CI only a reviewer label (`tamperward:allow:verify@<head-sha>`) clears it."
13535
+ ];
13536
+ default:
13537
+ return [
13538
+ `CANNOT VERIFY${v?.reason ? ` (${v.reason})` : ""}: TamperWard could not verify this repository and fails closed`,
13539
+ "(exit 2) rather than reporting success. Nothing above is a verdict about your code.",
13540
+ ...v?.detail ? [` ${v.detail}`] : []
13541
+ ];
13542
+ }
13543
+ }
13544
+ function atomicReplaceFile(path, content, mode) {
13545
+ const tmp = `${path}.${process.pid}.${randomBytes2(8).toString("hex")}.tmp`;
13546
+ try {
13547
+ writeFileSync10(tmp, content, { encoding: "utf8", flag: "wx", mode });
13548
+ renameSync2(tmp, path);
13549
+ } finally {
13550
+ rmSync7(tmp, { force: true });
13551
+ }
13552
+ }
13553
+ function writeVerifyCommand(cwd, command) {
13554
+ const path = join20(cwd, POLICY_FILE);
13555
+ const st = lstatSync6(path, { throwIfNoEntry: false });
13556
+ if (st && (!st.isFile() || st.isSymbolicLink())) {
13557
+ return `${POLICY_FILE} is not a regular file; refusing to follow or replace a symlink or special file`;
13558
+ }
13559
+ const mode = st ? st.mode & 511 : 420;
13560
+ const original = st ? readFileSync19(path, "utf8") : null;
13561
+ let next;
13562
+ try {
13563
+ const doc = parseDocument(original && original.trim() ? original : "version: 1\n");
13564
+ if (doc.errors.length) return `${POLICY_FILE} is not valid YAML: ${doc.errors[0].message}`;
13565
+ doc.setIn(["verify", "command"], command);
13566
+ if (!doc.hasIn(["verify", "budget"])) doc.setIn(["verify", "budget"], 300);
13567
+ next = doc.toString();
13568
+ } catch (e) {
13569
+ return `${POLICY_FILE} could not be updated: ${errorMessage(e)}`;
13570
+ }
13571
+ atomicReplaceFile(path, next, mode);
13572
+ try {
13573
+ const loaded = loadPolicy(cwd);
13574
+ if (loaded.verify?.command !== command) throw new Error("verify.command did not round-trip");
13575
+ return null;
13576
+ } catch (e) {
13577
+ if (original === null) rmSync7(path, { force: true });
13578
+ else atomicReplaceFile(path, original, mode);
13579
+ return `the updated ${POLICY_FILE} does not load (${errorMessage(e)}); restored the previous file`;
13580
+ }
13581
+ }
13582
+ async function runOnboard(opts, io = {}) {
13583
+ const cwd = resolve10(opts.cwd ?? process.cwd());
13584
+ const out2 = io.out ?? ((line) => void process.stdout.write(line + "\n"));
13585
+ const err2 = (line) => void process.stderr.write(line + "\n");
13586
+ const platform = io.platform ?? process.platform;
13587
+ const runners = {
13588
+ init: runInit,
13589
+ verify: runVerify,
13590
+ doctor: diagnose,
13591
+ check: runCheck,
13592
+ ...io.runners
13593
+ };
13594
+ const interactive = io.interactive ?? (Boolean(process.stdin.isTTY) && !process.env.CI && !process.env.GITHUB_ACTIONS);
13595
+ const scripted = Boolean(opts.yes);
13596
+ if (!interactive && !scripted) {
13597
+ err2("tamperward onboard: stdin is not interactive (or a CI environment was detected), so the guided");
13598
+ err2("setup cannot ask questions. Re-run with --yes for the scripted mode (add --verify-command");
13599
+ err2('"<suite command>" to configure verification; a detected command is never written unscripted),');
13600
+ err2("or run the deterministic primitive directly: tamperward init.");
13601
+ return 2;
13602
+ }
13603
+ if (!isGitRepo(cwd)) {
13604
+ err2(`tamperward onboard: ${cwd} is not inside a git repository \u2014 run \`git init\` first; every enforcement point is git-anchored`);
13605
+ return 2;
13606
+ }
13607
+ const prompt = { asker: null };
13608
+ const rawAsk = io.ask ?? ((q) => (prompt.asker ??= readlineAsker(process.stdin, process.stdout))(q));
13609
+ const ask = async (question) => {
13610
+ const answer = await rawAsk(question);
13611
+ if (answer === null) throw new Aborted();
13612
+ return answer.trim();
13613
+ };
13614
+ const confirm = async (question, fallback, scriptedAnswer) => {
13615
+ if (scripted) return scriptedAnswer;
13616
+ const a = (await ask(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
13617
+ if (a === "") return fallback;
13618
+ return a === "y" || a === "yes";
13619
+ };
13620
+ let step = 0;
13621
+ const header = (n) => {
13622
+ step = n;
13623
+ out2("");
13624
+ out2(`== ${n}/${STEPS.length} ${STEPS[n - 1]} ==`);
13625
+ };
13626
+ const wrote = [];
13627
+ let verifyCommand;
13628
+ let verifyResult = null;
13629
+ let githubChecked = false;
13630
+ let repo = null;
13631
+ let declined = false;
13632
+ let doctorOutcome = null;
13633
+ try {
13634
+ header(1);
13635
+ out2(`TamperWard ${TW_VERSION} on Node ${process.versions.node} (${platform}/${process.arch}), repository ${cwd}`);
13636
+ const lifecycle = lifecyclePlatformCheck(platform);
13637
+ out2(`platform: [${lifecycle.state}] ${lifecycle.detail}`);
13638
+ const localVerifySupported = localVerifierShell(platform, "true") !== null;
13639
+ if (!localVerifySupported) {
13640
+ out2("platform: checkpointed-local verify is unsupported here; only a digest-pinned container verifier");
13641
+ out2(" (verify.backend: container) can verify on this platform, exactly as the support contract states.");
13642
+ }
13643
+ const head = git5(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])?.trim() ?? null;
13644
+ if (!head) {
13645
+ out2("history: no commit yet. Verification and the demonstration need a committed base; the wiring can still be written.");
13646
+ }
13647
+ const status = git5(cwd, ["status", "--porcelain", "--untracked-files=all"]) ?? "";
13648
+ const dirty = status.split("\n").filter(Boolean).map((line) => line.slice(3).split(" -> ").at(-1) ?? "");
13649
+ const owned = new Set(planInit(cwd).map((a) => a.path));
13650
+ const foreign = dirty.filter((p) => !owned.has(p));
13651
+ if (dirty.length && foreign.length === 0) {
13652
+ out2(`working tree: ${dirty.length} uncommitted path(s), all written by init \u2014 commit them so the gate travels with the repository.`);
13653
+ } else if (dirty.length) {
13654
+ out2(`working tree is not clean: ${dirty.length} changed or untracked path(s). Onboarding never stashes,`);
13655
+ out2("resets or edits your changes, but a clean tree makes the first verification easier to read:");
13656
+ out2("commit or stash your work first for the cleanest result.");
13657
+ if (!await confirm("Continue with a dirty working tree?", false, true)) {
13658
+ out2("Stopped before writing anything. Commit or stash your work, then re-run: tamperward onboard");
13659
+ return 2;
13660
+ }
13661
+ } else {
13662
+ out2("working tree: clean");
13663
+ }
13664
+ header(2);
13665
+ out2("This is the plan `tamperward init` would apply (the same planner as `init --dry-run`):");
13666
+ out2("");
13667
+ runners.init({ cwd, dryRun: true });
13668
+ out2("");
13669
+ out2("What each item is for:");
13670
+ out2(` policy \u2014 ${POLICY_FILE} holds your overrides; the baseline rules apply even without it.`);
13671
+ out2(" agent hooks \u2014 Claude Code PreToolUse deny + Stop sweep: the steering layer, judged in the loop.");
13672
+ out2(" pre-commit \u2014 the same engine over the staged diff, so a shortcut cannot be committed quietly.");
13673
+ out2(" CI \u2014 the authority for the protected branch: check --diff over the PR range plus");
13674
+ out2(" pristine verify; cleared only by a reviewer label bound to the head SHA.");
13675
+ out2(" CODEOWNERS \u2014 a human requirement on the paths that decide whether the gate runs at all.");
13676
+ const plan = planInit(cwd);
13677
+ const pending = plan.filter((a) => a.apply).length;
13678
+ const errors = plan.filter((a) => a.status === "error").length;
13679
+ if (pending === 0) {
13680
+ out2("");
13681
+ out2(errors ? "Nothing to write, but item(s) above need your attention." : "Everything is already wired \u2014 nothing to write.");
13682
+ } else {
13683
+ out2("");
13684
+ out2("init never overwrites a file you wrote: it creates what is absent and merges what is shared.");
13685
+ if (!await confirm(`Write these ${pending} change(s) now?`, false, true)) {
13686
+ declined = true;
13687
+ out2("Declined: nothing was written. Re-run `tamperward onboard` when ready, or apply the plan");
13688
+ out2("yourself with `tamperward init` (`--dry-run` prints it again).");
13689
+ }
13690
+ }
13691
+ if (!declined && pending > 0) {
13692
+ header(3);
13693
+ const code2 = runners.init({ cwd });
13694
+ wrote.push(...plan.filter((a) => a.apply).map((a) => a.path));
13695
+ if (code2 !== 0) out2("init reported item(s) that need your attention (above); onboarding continues so you can see the rest.");
13696
+ }
13697
+ if (!declined) {
13698
+ header(4);
13699
+ let configured;
13700
+ try {
13701
+ configured = loadPolicy(cwd).verify?.command?.trim() || void 0;
13702
+ } catch (e) {
13703
+ out2(`${POLICY_FILE} does not load (${errorMessage(e)}); fix it, then re-run. verify.command was not configured.`);
13704
+ }
13705
+ if (configured) {
13706
+ out2(`verification configured \u2014 ${configured}`);
13707
+ verifyCommand = configured;
13708
+ } else {
13709
+ out2("The verifier command is part of the trust anchor: `verify` re-runs it against a pristine copy of");
13710
+ out2("your suite, and CI fails closed without it. TamperWard never chooses it for you.");
13711
+ const candidates = verifierCandidates(cwd);
13712
+ let chosen;
13713
+ if (scripted) {
13714
+ if (opts.verifyCommand?.trim()) {
13715
+ chosen = opts.verifyCommand.trim();
13716
+ } else {
13717
+ out2(candidates.length ? `Detected candidate(s): ${candidates.join(", ")}.` : "No suite command was detected.");
13718
+ out2('Scripted mode: verify.command was not written. Pass --verify-command "<suite command>" to set it explicitly.');
13719
+ }
13720
+ } else if (candidates.length === 1) {
13721
+ out2(`Detected one high-confidence suite command: ${candidates[0]}`);
13722
+ if (await confirm(`Use \`${candidates[0]}\` as verify.command?`, false, false)) chosen = candidates[0];
13723
+ else chosen = await ask("Enter the suite command to trust (empty to skip): ") || void 0;
13724
+ } else if (candidates.length > 1) {
13725
+ out2("Several suite commands were detected; choose the one that defines merge authority:");
13726
+ candidates.forEach((c, i) => out2(` ${i + 1}. ${c}`));
13727
+ const a = await ask(`Choose 1-${candidates.length} (${candidates.join(" / ")}), type a command, or press Enter to skip: `);
13728
+ const n = Number(a);
13729
+ chosen = a === "" ? void 0 : Number.isInteger(n) && n >= 1 && n <= candidates.length ? candidates[n - 1] : a;
13730
+ } else {
13731
+ out2("No suite command was detected (no test script, pytest/tox config, Cargo or Go module root).");
13732
+ chosen = await ask("Enter the suite command to trust (empty to skip): ") || void 0;
13733
+ }
13734
+ if (chosen) {
13735
+ const problem = writeVerifyCommand(cwd, chosen);
13736
+ if (problem) {
13737
+ out2(`verify.command was not written: ${problem}`);
13738
+ } else {
13739
+ out2(`Wrote verify.command: ${chosen} (budget 300s) to ${POLICY_FILE}. Commit it: with a --base, verify reads`);
13740
+ out2("the policy from that commit, so an uncommitted command is invisible to CI.");
13741
+ verifyCommand = chosen;
13742
+ wrote.push(POLICY_FILE);
13743
+ }
13744
+ } else {
13745
+ out2("verify.command was not configured. CI will fail closed (exit 2) until it is; add it to");
13746
+ out2(`${POLICY_FILE} under verify: and re-run \`tamperward doctor\`.`);
13747
+ }
13748
+ }
13749
+ }
13750
+ if (!declined) {
13751
+ header(5);
13752
+ out2("`verify` runs your suite twice: as-is (visible), and with every protected test/snapshot/config");
13753
+ out2("file restored from the trusted base (pristine). The pair is what no diff rule can fake.");
13754
+ if (!verifyCommand) {
13755
+ out2("Skipped: no verify.command. Standalone `tamperward verify` would report CANNOT_VERIFY");
13756
+ out2("(exit 2) here \u2014 it fails closed rather than pretending to verify.");
13757
+ } else if (!localVerifySupported) {
13758
+ out2(`Skipped: checkpointed-local verify is unsupported on ${platform}; \`tamperward verify\` fails closed`);
13759
+ out2("(exit 2) before running anything. Configure a digest-pinned container verifier for this platform.");
13760
+ } else if (!head) {
13761
+ out2("Skipped: no commit to anchor the pristine copy to. Commit, then run: tamperward verify");
13762
+ } else if (await confirm(`Run the first verification now (${verifyCommand}, twice)?`, true, true)) {
13763
+ let summary = null;
13764
+ const verifyOpts = { cwd, onVerdict: (v) => {
13765
+ summary = v;
13766
+ } };
13767
+ if (opts.base) verifyOpts.base = opts.base;
13768
+ const code2 = runners.verify(verifyOpts);
13769
+ verifyResult = { code: code2, summary };
13770
+ out2("");
13771
+ for (const line of explainVerdict(summary, code2)) out2(line);
13772
+ out2(`(verify exited ${code2}; onboarding reports it and changes nothing about it.)`);
13773
+ } else {
13774
+ out2(`Skipped. Run it any time: tamperward verify${opts.base ? ` --base ${opts.base}` : ""}`);
13775
+ }
13776
+ }
13777
+ if (!declined && !opts.skipDemo) {
13778
+ header(6);
13779
+ out2("Optional: see a finding without risking anything. TamperWard creates a detached temporary");
13780
+ out2("worktree of HEAD, adds `.skip` to one test block THERE, runs `check --worktree` on it, then");
13781
+ out2("removes the worktree. Your working tree is not touched; its fingerprint is shown before and after.");
13782
+ const wanted = scripted ? Boolean(opts.demo) : opts.demo ? true : await confirm("Run the demonstration?", false, false);
13783
+ if (!wanted) {
13784
+ out2("Demo skipped.");
13785
+ } else if (!head) {
13786
+ out2("Demo skipped: no commit to build disposable state from.");
13787
+ } else {
13788
+ runDemo(cwd, head, runners, out2);
13789
+ }
13790
+ }
13791
+ header(7);
13792
+ out2("Local wiring steers the agent and gates commits; only the repository can make CI binding.");
13793
+ repo = opts.repo ?? inferGitHubRepo(cwd);
13794
+ const doctorCommand = `tamperward doctor --github --repo ${repo ?? "OWNER/REPO"} --branch ${opts.branch ?? "<default-branch>"}`;
13795
+ if (opts.noGithub) {
13796
+ out2("--no-github: the GitHub API is not consulted, so nothing below is reported as enforced.");
13797
+ for (const line of MANUAL_CONTROLS) out2(line);
13798
+ out2(`Then verify it with: ${doctorCommand}`);
13799
+ } else if (!repo) {
13800
+ out2("No GitHub repository could be determined (no github.com origin; pass --repo OWNER/REPO).");
13801
+ for (const line of MANUAL_CONTROLS) out2(line);
13802
+ out2(`Then verify it with: ${doctorCommand}`);
13803
+ } else {
13804
+ out2(`Repository: ${repo}${opts.branch ? `, branch ${opts.branch}` : ""}.`);
13805
+ githubChecked = await confirm(
13806
+ "Check the live GitHub controls now with `doctor --github`? (reads the GitHub API; set GH_TOKEN/GITHUB_TOKEN if needed)",
13807
+ true,
13808
+ true
13809
+ );
13810
+ if (!githubChecked) {
13811
+ for (const line of MANUAL_CONTROLS) out2(line);
13812
+ out2(`Then verify it with: ${doctorCommand}`);
13813
+ }
13814
+ }
13815
+ header(8);
13816
+ const doctorOpts = { cwd };
13817
+ if (githubChecked && repo) {
13818
+ doctorOpts.github = true;
13819
+ doctorOpts.repo = repo;
13820
+ if (opts.branch) doctorOpts.branch = opts.branch;
13821
+ }
13822
+ doctorOutcome = runners.doctor(doctorOpts);
13823
+ for (const line of doctorOutcome.summary) out2(line);
13824
+ if (doctorOutcome.failure) out2(`tamperward doctor: ${doctorOutcome.failure.message}`);
13825
+ for (const check2 of doctorOutcome.checks) renderCheck(check2, out2);
13826
+ const posture = postureOf(doctorOutcome);
13827
+ const warnings = doctorOutcome.checks.filter((c) => c.state === "WARN").length;
13828
+ const broken = doctorOutcome.checks.filter((c) => c.state === "BROKEN");
13829
+ out2("");
13830
+ out2(
13831
+ `POSTURE: ${posture}` + (posture === "READY WITH WARNINGS" ? ` \u2014 ${warnings} warning(s) above` : "") + (posture === "BROKEN" ? ` \u2014 ${broken.map((c) => c.id).join(", ")}` : "") + (posture === "INCOMPLETE" && doctorOutcome.failure ? ` \u2014 doctor could not certify: ${doctorOutcome.failure.id}` : "")
13832
+ );
13833
+ if (doctorOutcome.github) {
13834
+ out2(`GitHub repository authority: ENFORCED \u2014 doctor --github verified ${doctorOutcome.github.repo}#${doctorOutcome.github.branch}`);
13835
+ out2(" requires the tamperward check, Code Owner review, and stale-approval dismissal.");
13836
+ } else {
13837
+ out2("GitHub repository authority: NOT VERIFIED \u2014 locally configured only. Until doctor --github");
13838
+ out2(` confirms the three controls, CI is advisory. Run: ${doctorCommand}`);
13839
+ if (githubChecked && doctorOutcome.failure?.id === "github-authority") {
13840
+ for (const line of MANUAL_CONTROLS) out2(line);
13841
+ }
13842
+ }
13843
+ if (declined) out2("Nothing was written this run; the posture above describes the repository as it was.");
13844
+ if (verifyResult) out2(`First verification: ${verifyResult.summary?.verdict ?? `exit ${verifyResult.code}`}.`);
13845
+ header(9);
13846
+ out2("Day to day:");
13847
+ out2(" tamperward check --worktree the stop-sweep view of everything changed since HEAD");
13848
+ out2(` tamperward verify --base ${opts.base ?? "<base>"}${opts.base ? "" : " "}pristine re-execution against a base the agent cannot rewrite`);
13849
+ out2(` tamperward run --base ${opts.base ?? "<base>"} -- <agent...> the enforcement envelope (Linux; fails closed elsewhere)`);
13850
+ out2(` ${doctorCommand}`);
13851
+ out2("When the final verification matters most \u2014 a release, a merge into the protected branch \u2014 prefer");
13852
+ out2("the digest-pinned container verifier (verify.backend: container, image: <ref>@sha256:<digest>):");
13853
+ out2("it owns the runtime and dependencies, mounts the tree read-only and disables the network, so the");
13854
+ out2("candidate cannot reach the verifier from the same host. `run` deliberately does not offer it.");
13855
+ if (wrote.length) out2(`Commit what onboarding wrote (${[...new Set(wrote)].join(", ")}) so the gate travels with the repository.`);
13856
+ return posture === "READY" || posture === "READY WITH WARNINGS" ? 0 : 1;
13857
+ } catch (e) {
13858
+ if (e instanceof Aborted) {
13859
+ out2("");
13860
+ out2(`tamperward onboard: aborted during step ${step} (${STEPS[step - 1]}).`);
13861
+ out2(
13862
+ wrote.length ? `Written so far: ${[...new Set(wrote)].join(", ")} \u2014 all through init, all idempotent; nothing is half-applied.` : "Nothing was written."
13863
+ );
13864
+ out2("Re-run `tamperward onboard` to continue from the same place, or `tamperward doctor` to see the current posture.");
13865
+ return 2;
13866
+ }
13867
+ throw e;
13868
+ } finally {
13869
+ prompt.asker?.close();
13870
+ }
13871
+ }
13872
+ function renderCheck(check2, out2) {
13873
+ if (check2.id === "observer") out2(`tamperward doctor: transient observer: ${check2.detail}`);
13874
+ else out2(`tamperward doctor: [${check2.state}] ${check2.id} \u2014 ${check2.detail}`);
13875
+ }
13876
+ function postureOf(outcome) {
13877
+ if (outcome.failure) return "INCOMPLETE";
13878
+ if (outcome.checks.some((c) => c.state === "BROKEN")) return "BROKEN";
13879
+ if (outcome.checks.some((c) => c.state === "WARN")) return "READY WITH WARNINGS";
13880
+ return "READY";
13881
+ }
13882
+ function runDemo(cwd, head, runners, out2) {
13883
+ const files = (git5(cwd, ["ls-tree", "-r", "-z", head]) ?? "").split("\0").filter(Boolean).flatMap((row) => {
13884
+ const tab = row.indexOf(" ");
13885
+ if (tab < 0) return [];
13886
+ const mode = row.slice(0, tab).split(" ")[0];
13887
+ const path = row.slice(tab + 1);
13888
+ return (mode === "100644" || mode === "100755") && JS_TEST_PATH.test(path) ? [path] : [];
13889
+ });
13890
+ let target = null;
13891
+ for (const path of files) {
13892
+ const before2 = git5(cwd, ["show", `${head}:${path}`]);
13893
+ if (before2 === null || !JS_TEST_BLOCK.test(before2)) continue;
13894
+ target = { path, before: before2, after: before2.replace(JS_TEST_BLOCK, "$1$2.skip$3") };
13895
+ break;
13896
+ }
13897
+ if (!target) {
13898
+ out2("Demo skipped: no JavaScript test block (it/test/describe) at HEAD to demonstrate on.");
13899
+ return;
13900
+ }
13901
+ const before = treeFingerprint(cwd);
13902
+ out2(`working tree fingerprint before: ${before.slice(0, 16)}`);
13903
+ const wt = mkdtempSync6(join20(tmpdir6(), "tw-onboard-demo-"));
13904
+ try {
13905
+ if (git5(cwd, ["worktree", "add", "--detach", wt, head]) === null) {
13906
+ out2("Demo skipped: could not create a temporary worktree (git worktree add failed).");
13907
+ return;
13908
+ }
13909
+ const targetPath = join20(wt, target.path);
13910
+ const targetStat = lstatSync6(targetPath, { throwIfNoEntry: false });
13911
+ if (!targetStat || !targetStat.isFile() || targetStat.isSymbolicLink()) {
13912
+ out2("Demo skipped: the selected test is not a regular file in the disposable worktree.");
13913
+ return;
13914
+ }
13915
+ let worktreeRoot;
13916
+ let realTarget;
13917
+ try {
13918
+ worktreeRoot = realpathSync8(wt);
13919
+ realTarget = realpathSync8(targetPath);
13920
+ } catch {
13921
+ out2("Demo skipped: the selected test could not be resolved safely inside the disposable worktree.");
13922
+ return;
13923
+ }
13924
+ const prefix = worktreeRoot.endsWith(sep4) ? worktreeRoot : worktreeRoot + sep4;
13925
+ if (!realTarget.startsWith(prefix)) {
13926
+ out2("Demo skipped: the selected test resolves outside the disposable worktree.");
13927
+ return;
13928
+ }
13929
+ out2(`disposable worktree: ${wt}`);
13930
+ out2(`weakening move: ${target.path} \u2014 the first test block becomes .skip (only in the disposable copy)`);
13931
+ atomicReplaceFile(targetPath, target.after, targetStat.mode & 511);
13932
+ out2("");
13933
+ out2(`$ tamperward check --worktree (in ${wt})`);
13934
+ const code2 = runners.check({ cwd: wt, worktree: true });
13935
+ out2("");
13936
+ out2(`check exited ${code2}: ${code2 === 1 ? "a blocking finding, which is what the hook, pre-commit and CI would all report" : "no blocking finding was reported"}.`);
13937
+ } finally {
13938
+ git5(cwd, ["worktree", "remove", "--force", wt]);
13939
+ git5(cwd, ["worktree", "prune"]);
13940
+ rmSync7(wt, { recursive: true, force: true });
13941
+ }
13942
+ const after = treeFingerprint(cwd);
13943
+ out2(`working tree fingerprint after: ${after.slice(0, 16)}`);
13944
+ if (after === before) {
13945
+ out2("Identical: your working tree was restored byte-for-byte (it was never edited; the disposable worktree is gone).");
13946
+ } else {
13947
+ out2("WARNING: the fingerprint moved. The demo never wrote into your working tree, so something else");
13948
+ out2("changed it while the demo ran; inspect with `git status`.");
13949
+ }
13950
+ }
13951
+
13433
13952
  // src/cli/main.ts
13434
13953
  function parseAllow(args) {
13435
13954
  const o = {};
@@ -13461,6 +13980,22 @@ function parseInit(args) {
13461
13980
  }
13462
13981
  return o;
13463
13982
  }
13983
+ function parseOnboard(args) {
13984
+ const o = {};
13985
+ for (let i = 0; i < args.length; i++) {
13986
+ const a = args[i];
13987
+ if (a === "--cwd") o.cwd = args[++i];
13988
+ else if (a === "--base") o.base = args[++i];
13989
+ else if (a === "--repo") o.repo = args[++i];
13990
+ else if (a === "--branch") o.branch = args[++i];
13991
+ else if (a === "--verify-command") o.verifyCommand = args[++i];
13992
+ else if (a === "--skip-demo") o.skipDemo = true;
13993
+ else if (a === "--demo") o.demo = true;
13994
+ else if (a === "--no-github") o.noGithub = true;
13995
+ else if (a === "--yes") o.yes = true;
13996
+ }
13997
+ return o;
13998
+ }
13464
13999
  function parseDoctor(args) {
13465
14000
  const o = {};
13466
14001
  for (let i = 0; i < args.length; i++) {
@@ -13614,6 +14149,21 @@ function validateCliArgs(cmd, args) {
13614
14149
  }
13615
14150
  }).error;
13616
14151
  }
14152
+ if (cmd === "onboard") {
14153
+ const parsed = validateFlatArgs(args, {
14154
+ flags: ["--yes", "--skip-demo", "--demo", "--no-github"],
14155
+ values: {
14156
+ "--cwd": "string",
14157
+ "--base": "string",
14158
+ "--repo": "string",
14159
+ "--branch": "string",
14160
+ "--verify-command": "string"
14161
+ }
14162
+ });
14163
+ if (parsed.error) return parsed.error;
14164
+ if (parsed.seen.has("--demo") && parsed.seen.has("--skip-demo")) return "--demo cannot be combined with --skip-demo";
14165
+ return void 0;
14166
+ }
13617
14167
  if (cmd === "verify") {
13618
14168
  return validateFlatArgs(args, {
13619
14169
  flags: ["--json", "--keep", "--require-ancestor"],
@@ -13704,6 +14254,21 @@ Formats:
13704
14254
  2 when it cannot adjudicate (fails closed)
13705
14255
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
13706
14256
  [--file F] [--cwd D]
14257
+ tamperward onboard [--yes] [--cwd D] guided first-run setup: preflight, the
14258
+ [--base R] [--repo O/R] init plan with a confirmation before any
14259
+ [--branch B] [--verify-command C] write, canonical init, explicit verifier
14260
+ [--skip-demo | --demo] acceptance (a detected suite command is
14261
+ [--no-github] never written without it), the first
14262
+ verify explained, an optional demo on a
14263
+ disposable worktree, GitHub authority via
14264
+ doctor --github or the manual controls,
14265
+ and a READY / READY WITH WARNINGS /
14266
+ BROKEN / INCOMPLETE posture from doctor.
14267
+ Non-interactive stdin refuses unless
14268
+ --yes scripts it (then --verify-command
14269
+ is the only way to configure verify).
14270
+ Exit: 0 READY[ WITH WARNINGS], 1
14271
+ BROKEN/INCOMPLETE, 2 refused or aborted
13707
14272
  tamperward init [--dry-run] wire the policy file plus every
13708
14273
  [--force-workflow] [--cwd D] enforcement point: Claude Code hooks,
13709
14274
  pre-commit, CI. Idempotent; never
@@ -13757,6 +14322,8 @@ function main(argv2) {
13757
14322
  return runInit(parseInit(rest));
13758
14323
  case "doctor":
13759
14324
  return runDoctor(parseDoctor(rest));
14325
+ case "onboard":
14326
+ return runOnboard(parseOnboard(rest));
13760
14327
  case "watch":
13761
14328
  return runWatch(rest);
13762
14329
  case "verify":
@@ -13783,17 +14350,25 @@ function main(argv2) {
13783
14350
  }
13784
14351
  }
13785
14352
  function guardedMain(argv2, stderr = process.stderr) {
13786
- try {
13787
- return main(argv2);
13788
- } catch (e) {
14353
+ const failClosed2 = (e) => {
13789
14354
  const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
13790
14355
  stderr.write(`tamperward: ${first || "unexpected failure"}
13791
14356
  `);
13792
14357
  return 2;
14358
+ };
14359
+ try {
14360
+ const result = main(argv2);
14361
+ return typeof result === "number" ? result : result.catch(failClosed2);
14362
+ } catch (e) {
14363
+ return failClosed2(e);
13793
14364
  }
13794
14365
  }
13795
14366
 
13796
14367
  // src/cli/index.ts
13797
14368
  var argv = process.argv.slice(2);
14369
+ var exit = (code2) => {
14370
+ if (code2 >= 0) process.exit(code2);
14371
+ };
13798
14372
  var code = guardedMain(argv);
13799
- if (code >= 0) process.exit(code);
14373
+ if (typeof code === "number") exit(code);
14374
+ else void code.then(exit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.20.5",
3
+ "version": "2.21.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",