Ctrl K
ComunidadGuías…

scripts/verification-record.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { outsideGit } from './lib/private-evidence.mjs';
import { sourceInputs } from './lib/verification-inputs.mjs';
import { templateSpec } from './lib/verification-spec.mjs';
import { appendEvent, createRun, currentSpec, preflight, readPrivateJson, readRun, renderReport, status, updateRun, validateRecordProfile, validateSpec } from './lib/verification-record.mjs';
import { buildCase, buildCleanup, buildOutcome, buildResource } from './lib/verification-record-builders.mjs';
import { familyStatus, readRunFamily, renderFamilyReport } from './lib/verification-record-family.mjs';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const HELP = `Usage: npm run verify:live:record -- COMMAND --run /private/path/run.json

template  --mode MODE [--purpose PURPOSE] [--area NAME] --output FILE
case-add  --spec FILE --output FILE --case ID --title TEXT --context ID --area AREA --require CAP --proof SURFACE
init      --spec FILE [--parent-run FILE] [--original-case CHILD=PARENT]
preflight                         Show runnable cases, blockers, registry warnings
refresh   --spec FILE --reason TEXT  Refresh observed capabilities and context
begin     --case ID [--reason TEXT]  Record an attempt before the action; prints its attemptId
blocked   --case ID --reason TEXT [--category CATEGORY] [--evidence FILE]
                                    Record why a case cannot run, without spending an attempt
verdict   --case ID --blocks-pr yes|no --reason TEXT
                                    Say whether a failed or blocked case blocks the PR
lesson    --text TEXT [--area AREA] [--case ID]
                                    Keep a gotcha for qa/live/features/; the report lists it
record    --event FILE               Record outcome, cleanup, repair, batch or candidate_transition
resource  --case ID --provider ID --kind ID --resource-id ID --ownership TYPE --cleanup-preset PRESET|--expected-file FILE --evidence FILE
finish|resolve --attempt ID --result RESULT --summary TEXT --evidence FILE --proof SURFACE=FILE [--completed-at ISO] [--observed-at ISO|now] [--timing-observation-ms MS]
                                    Proof surfaces: slack, admin, mcp, provider, model. A live case
                                    needs slack, admin or mcp proof. Omit a time you did not observe.
cleanup   --resource ID --outcome OUTCOME [--observed-file FILE] --evidence FILE
phase-start --phase NAME [--case ID] [--attempt ID]
phase-stop --phase-id ID
status                              Resume: open attempts, cleanup, repairs and next work
report    [--output FILE]            Generate Markdown from the record

All files must be outside Git. The record is private and never runs a browser,
claims/deploys a lane, retries an action, or cleans a resource. See
qa/live/operator/records.md for a spec and event examples. Use existing
verify:regression --record FILE for measured offline check receipts.
`;

/** The defaults are the standalone profile: v1 records and this checkout's
 * source. Another profile's coordinator passes { record, source }, where
 * record is its validated profile block and source() its current source. */
export function runRecordCli(argv, root = ROOT, io = {}, profile) {
  const output = io.stdout ?? ((value) => process.stdout.write(value));
  const error = io.stderr ?? ((value) => process.stderr.write(value));
  try {
    if (profile !== undefined) {
      if (typeof profile?.source !== 'function') throw new Error('A record profile needs a source provider.');
      validateRecordProfile(profile.record);
    }
    const descriptor = profile?.record, scope = { profile: descriptor };
    const currentSource = () => profile ? profile.source() : sourceInputs(root);
    const { values: flags, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
      ...Object.fromEntries(['run', 'spec', 'reason', 'case', 'event', 'output', 'mode', 'purpose', 'title', 'text', 'context', 'max-attempts', 'max-wait-ms', 'min-observation-ms', 'original-request', 'expected-outcome', 'variant', 'cleanup-contract', 'provider', 'kind', 'resource-id', 'ownership', 'cleanup-preset', 'expected-file', 'attempt', 'result', 'summary', 'category', 'completed-at', 'observed-at', 'timing-observation-ms', 'cost-usd', 'resource', 'outcome', 'observed-file', 'phase', 'phase-id', 'parent-run', 'blocks-pr'].map((key) => [key, { type: 'string' }])),
      area: { type: 'string', multiple: true }, require: { type: 'string', multiple: true }, proof: { type: 'string', multiple: true }, evidence: { type: 'string', multiple: true }, 'original-case': { type: 'string', multiple: true },
      'stop-at': { type: 'string' }, 'max-occurrences': { type: 'string' }, family: { type: 'boolean' },
      help: { type: 'boolean' },
    } });
    if (flags.help) { output(HELP); return 0; }
    if (positionals.length !== 1) throw new Error('Choose one command. Use --help.');
    const command = positionals[0];
    const allowed = {
      template: ['mode', 'purpose', 'area', 'output'],
      'case-add': ['spec', 'output', 'case', 'title', 'context', 'area', 'require', 'proof', 'max-attempts', 'max-wait-ms', 'min-observation-ms', 'original-request', 'expected-outcome', 'variant', 'cleanup-contract'],
      init: ['run', 'spec', 'parent-run', 'original-case'], preflight: ['run'], refresh: ['run', 'spec', 'reason'],
      begin: ['run', 'case', 'reason'], blocked: ['run', 'case', 'reason', 'category', 'evidence'], lesson: ['run', 'text', 'area', 'case'], verdict: ['run', 'case', 'blocks-pr', 'reason'],
      record: ['run', 'event'], status: ['run'], report: ['run', 'output', 'family'],
      resource: ['run', 'case', 'provider', 'kind', 'resource-id', 'ownership', 'cleanup-preset', 'expected-file', 'evidence', 'stop-at', 'max-occurrences'],
      finish: ['run', 'attempt', 'result', 'summary', 'category', 'evidence', 'proof', 'completed-at', 'observed-at', 'timing-observation-ms', 'cost-usd'],
      resolve: ['run', 'attempt', 'result', 'summary', 'category', 'evidence', 'proof', 'completed-at', 'observed-at', 'timing-observation-ms', 'cost-usd'],
      cleanup: ['run', 'resource', 'outcome', 'observed-file', 'evidence'],
      'phase-start': ['run', 'phase', 'case', 'attempt'], 'phase-stop': ['run', 'phase-id'],
    }[command];
    if (!allowed || Object.keys(flags).some((key) => !allowed.includes(key))) throw new Error('Unknown command or inapplicable option.');
    if (command === 'template') {
      if (profile) throw new Error('This profile\'s spec templates come from its planner.');
      if (!flags.output) throw new Error('template needs --output.');
      const path = outsideGit(flags.output);
      mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
      writeFileSync(path, `${JSON.stringify(templateSpec(flags.mode, flags.area, Date.now(), flags.purpose), null, 2)}\n`, { flag: 'wx', mode: 0o600 });
      output(`${JSON.stringify({ output: path, next: 'Resolve observed capabilities, review selected scope, then init. Unavailable fixtures remain blockers.' })}\n`);
      return 0;
    }
    if (command === 'case-add') {
      if (!flags.spec || !flags.output) throw new Error('case-add needs --spec and a distinct --output.');
      const input = outsideGit(flags.spec), path = outsideGit(flags.output);
      if (input === path) throw new Error('case-add output must differ from its input spec.');
      const spec = structuredClone(readPrivateJson(input));
      const named = { case: flags.case, title: flags.title, context: flags.context, area: flags.area, require: flags.require, proof: flags.proof,
        maxAttempts: flags['max-attempts'], maxWaitMs: flags['max-wait-ms'], minObservationMs: flags['min-observation-ms'],
        originalRequest: flags['original-request'], expectedOutcome: flags['expected-outcome'], variant: flags.variant, cleanup: flags['cleanup-contract'] };
      spec.cases.push(buildCase(spec, named)); validateSpec(spec, descriptor?.areas, { releaseGate: descriptor?.releaseGate });
      mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, `${JSON.stringify(spec, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
      output(`${JSON.stringify({ output: path, caseId: flags.case })}\n`); return 0;
    }
    if (!flags.run) throw new Error('Pass --run.');
    let result, code = 0;
    if (command === 'init') {
      if (!flags.spec) throw new Error('init needs --spec.');
      let lineage;
      if (flags['parent-run']) {
        const parentPath = outsideGit(flags['parent-run']), parent = readRun(parentPath, scope);
        const family = readRunFamily(parentPath, currentSource(), 32, scope);
        if (family.missingAncestor) throw new Error('Parent run family has a missing ancestor.');
        const originalCases = {};
        for (const mapping of flags['original-case'] ?? []) {
          const [child, original, extra] = mapping.split('=');
          if (!child || !original || extra !== undefined) throw new Error('Original case must use CHILD=PARENT.');
          if (!currentSpec(parent).cases.some((entry) => entry.id === original)) throw new Error('Original parent case was not found in its current spec.');
          originalCases[child] = { runId: parent.id, caseId: original };
        }
        const spec = readPrivateJson(flags.spec);
        if (Object.keys(originalCases).some((child) => !spec.cases.some((entry) => entry.id === child))) throw new Error('Original-case child was not found in the new spec.');
        lineage = { parent: { path: parentPath, runId: parent.id }, originalCases };
      } else if (flags['original-case']?.length) throw new Error('--original-case needs --parent-run.');
      result = createRun(flags.run, readPrivateJson(flags.spec), currentSource(), Date.now(), lineage, descriptor);
      result = { runId: result.id, preflight: preflight(result) };
    } else if (['refresh', 'begin', 'blocked', 'lesson', 'verdict', 'record', 'resource', 'finish', 'resolve', 'cleanup', 'phase-start', 'phase-stop'].includes(command)) {
      let event;
      if (command === 'record') {
        if (!flags.event) throw new Error('record needs --event.');
        event = readPrivateJson(flags.event);
      } else if (command === 'refresh') {
        if (!flags.spec) throw new Error('refresh needs --spec.');
        event = { type: 'refresh', spec: readPrivateJson(flags.spec), reason: flags.reason };
      } else if (command === 'begin') event = { type: 'begin', caseId: flags.case, ...(flags.reason ? { reason: flags.reason } : {}) };
      else if (command === 'verdict') {
        if (!flags.case || !flags['blocks-pr'] || !flags.reason) throw new Error('verdict needs --case, --blocks-pr yes|no and --reason.');
        event = { type: 'verdict', caseId: flags.case, blocksPr: flags['blocks-pr'], reason: flags.reason };
      } else if (command === 'lesson') {
        if (!flags.text) throw new Error('lesson needs --text.');
        event = { type: 'lesson', text: flags.text, ...(flags.area?.length ? { areas: flags.area } : {}), ...(flags.case ? { caseId: flags.case } : {}) };
      } else if (command === 'blocked') {
        if (!flags.case || !flags.reason) throw new Error('blocked needs --case and --reason.');
        event = { type: 'case_blocked', caseId: flags.case, reason: flags.reason,
          ...(flags.category ? { category: flags.category } : {}), ...(flags.evidence ? { evidence: flags.evidence } : {}) };
      }
      else if (command !== 'resource') {
        const named = { ...flags, completedAt: flags['completed-at'], observedAt: flags['observed-at'], timingObservationMs: flags['timing-observation-ms'], costUsd: flags['cost-usd'], observedFile: flags['observed-file'], phaseId: flags['phase-id'] };
        if (command === 'finish' || command === 'resolve') event = buildOutcome(command, named);
        else if (command === 'cleanup') event = buildCleanup(named, readPrivateJson);
        else if (command === 'phase-start') event = { type: 'phase_start', phase: flags.phase, ...(flags.case ? { caseId: flags.case } : {}), ...(flags.attempt ? { attemptId: flags.attempt } : {}) };
        else event = { type: 'phase_finish', phaseId: flags['phase-id'] };
      }
      const source = currentSource();
      result = updateRun(flags.run, (run) => {
        const input = command === 'resource' ? buildResource(run, { ...flags, resourceId: flags['resource-id'], cleanupPreset: flags['cleanup-preset'], expectedFile: flags['expected-file'], stopAt: flags['stop-at'], maxOccurrences: flags['max-occurrences'] }, readPrivateJson) : event;
        return appendEvent(run, input, source);
      }, scope);
      // The full attempt carries every input digest; the operator needs its ID and deadline.
      if (command === 'begin') result = { attemptId: result.id, caseId: result.caseId, deadline: result.deadline, id: result.id };
    } else {
      const run = readRun(flags.run, scope);
      if (command === 'preflight') { result = preflight(run); code = result.ready ? 0 : 1; }
      else {
        const view = status(run, currentSource());
        if (command === 'status') result = view;
        else {
          const report = flags.family ? renderFamilyReport(familyStatus(readRunFamily(flags.run, currentSource(), 32, scope)), renderReport) : renderReport(view);
          if (!flags.output) { output(`${report}\n`); return 0; }
          const path = outsideGit(flags.output);
          mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
          // A report is disposable. Never replace the record or a retained evidence file.
          writeFileSync(path, report, { flag: 'wx', mode: 0o600 });
          result = { output: path, complete: view.complete };
        }
      }
    }
    output(`${JSON.stringify(result, null, 2)}\n`);
    return code;
  } catch (cause) {
    error(`Record command failed: ${cause instanceof Error && !cause.code?.startsWith('ERR_PARSE_ARGS') ? cause.message : 'Invalid arguments.'}\n`);
    return 2;
  }
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  process.exitCode = runRecordCli(process.argv.slice(2));
}