@dzhechkov/harness-cli 0.3.261 → 0.4.1
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/.dz-manifest.json +121 -17
- package/README.md +402 -11
- package/dist/bin.js +11 -1
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1087 -65
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +282 -22
- package/src/bin.ts +12 -1
- package/src/cli.ts +1063 -66
package/dist/cli.js
CHANGED
|
@@ -3,19 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
10
11
|
import { homedir, tmpdir } from 'node:os';
|
|
11
12
|
import { createRequire } from 'node:module';
|
|
12
|
-
import { createSkill, getSkillInfo,
|
|
13
|
+
import { createSkill, getSkillInfo, isTargetName, listSkills, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
13
14
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
14
15
|
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
|
|
15
16
|
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
|
|
16
17
|
replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
|
|
17
18
|
// Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
|
|
18
|
-
readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, BACKLOG_BACKENDS, applyDomainBoost, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
19
|
+
readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
20
|
+
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
21
|
+
parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, } from '@dzhechkov/harness-core';
|
|
19
22
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
20
23
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
21
24
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -30,7 +33,12 @@ Usage:
|
|
|
30
33
|
dz migrate [--project <dir>]
|
|
31
34
|
dz create-skill --name <id> [--description <text>] [--skills-dir <dir>] [--tier <1-3>] [--with-references] [--no-evals] [--bto]
|
|
32
35
|
dz scout [--topics <list>] [--since <date>] [--deep] [--output <file>] [--diff] [--report]
|
|
33
|
-
dz workflow <
|
|
36
|
+
dz workflow init --name <n> [--pattern pipeline|barrier|fanout|gate] [--o <plan.json>] (scaffold a loop-plan/1 plan)
|
|
37
|
+
dz workflow validate <plan.json> [--json] (schema + INV-1..8 checks; CI-runnable, non-zero on failure)
|
|
38
|
+
dz workflow render <plan.json> --o <script.js> [--check] [--force] (plan → region-delimited loop script; USER regions preserved)
|
|
39
|
+
dz workflow blobs [--check] (list/self-check the subsystem blob registry)
|
|
40
|
+
dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json] (layer-1 gate; exit 0/1/3 — inconclusive is never a pass)
|
|
41
|
+
dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json] (timeline + SEQ invariant runner)
|
|
34
42
|
dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
|
|
35
43
|
dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
|
|
36
44
|
dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
|
|
@@ -49,11 +57,15 @@ Usage:
|
|
|
49
57
|
dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
|
|
50
58
|
dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
|
|
51
59
|
dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
|
|
60
|
+
dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
|
|
52
61
|
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
|
|
53
62
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
54
63
|
dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
|
|
55
64
|
dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
|
|
56
|
-
dz backlog roulette [--pick <N>] [--seed <n>] [--commit] [--project <dir>] [--json] (WEIGHTED draw over eligible ideas: alignment^alpha * recencyDecay * 1/effort, seeded; --pick N = ranked shortlist; --commit flips the pick to in-progress
|
|
65
|
+
dz backlog roulette [--pick <N>] [--seed <n>] [--commit] [--project <dir>] [--json] (WEIGHTED draw over eligible ideas: alignment^alpha * recencyDecay * 1/effort, seeded; --pick N = ranked shortlist; --commit flips the pick to in-progress)
|
|
66
|
+
dz backlog ship <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (mark work DONE: new|enriched|in-progress → shipped, removing it from the roulette pool — run it after finishing a task; short id prefixes ok, ambiguous = loud error)
|
|
67
|
+
dz backlog drop <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (retire an idea: new|enriched|in-progress → dropped)
|
|
68
|
+
dz backlog reopen <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (back to the pool: shipped|dropped|in-progress → new)
|
|
57
69
|
dz backlog enrich <id> [--project <dir>] [--json] (stage the idea2prd input scaffold in features/<slug>/ and hand off to the idea2prd-manual skill — the CLI never fabricates a PRD)
|
|
58
70
|
dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
|
|
59
71
|
dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
|
|
@@ -103,7 +115,7 @@ Usage:
|
|
|
103
115
|
dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
|
|
104
116
|
dz help
|
|
105
117
|
|
|
106
|
-
Workflows:
|
|
118
|
+
Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate them with dz workflow-lint; read runs with dz workflow-trace (the ADR-005 templates are retired)
|
|
107
119
|
|
|
108
120
|
Targets: ${TARGET_NAMES.join(', ')}
|
|
109
121
|
Presets: ${PRESET_NAMES.join(', ')}`;
|
|
@@ -495,27 +507,322 @@ async function cmdScout(options, flags, cwd, write) {
|
|
|
495
507
|
return 1;
|
|
496
508
|
}
|
|
497
509
|
}
|
|
510
|
+
/** Scaffold plans for `dz workflow init` — one per pattern (each validates + renders lint-clean:
|
|
511
|
+
* the ADR-002 property "the generator cannot emit a script its own lint rejects" is enforced by
|
|
512
|
+
* workflow-init-lint-clean.test.ts, not convention). */
|
|
513
|
+
function workflowInitPlan(name, pattern) {
|
|
514
|
+
const base = {
|
|
515
|
+
schema: 'loop-plan/1',
|
|
516
|
+
name,
|
|
517
|
+
description: `TODO: describe the ${name} loop`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
518
|
+
whenToUse: `TODO: when to invoke ${name}`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
519
|
+
checkpointing: { enabled: false },
|
|
520
|
+
subsystems: { checkpoints: false, trainingPairs: false, usageAdaptive: false, challengePanel: false, codexDispatch: false },
|
|
521
|
+
trace: { emit: true },
|
|
522
|
+
};
|
|
523
|
+
if (pattern === 'pipeline') {
|
|
524
|
+
return {
|
|
525
|
+
...base,
|
|
526
|
+
steps: [
|
|
527
|
+
{ stepId: 'fan', kind: 'fanout', phase: 'Work', concurrency: 'pipeline', budget: { maxAgents: 8 } },
|
|
528
|
+
{ stepId: 'a', kind: 'agent', phase: 'Work', prompt: 'TODO: stage A per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
529
|
+
{ stepId: 'b', kind: 'agent', phase: 'Work', prompt: 'TODO: stage B per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
530
|
+
{ stepId: 'jn', kind: 'join', phase: 'Work', deps: ['fan'] },
|
|
531
|
+
],
|
|
532
|
+
fanouts: [{ stage: 'fan', registry: ['item1', 'item2', 'item3'], maxFanout: 3, chain: ['a', 'b'] }],
|
|
533
|
+
joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (pattern === 'fanout' || pattern === 'barrier') {
|
|
537
|
+
return {
|
|
538
|
+
...base,
|
|
539
|
+
steps: [
|
|
540
|
+
{ stepId: 'fan', kind: 'fanout', phase: 'Lanes', concurrency: 'barrier', budget: { maxAgents: 6 } },
|
|
541
|
+
{ stepId: 'lane', kind: 'agent', phase: 'Lanes', prompt: 'TODO: one lane', budget: { maxAgents: 6 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
542
|
+
{ stepId: 'jn', kind: 'join', phase: 'Lanes', deps: ['fan'] },
|
|
543
|
+
// the consumer hangs off the BARRIER (jn), never the fork — barrier-postdominates teaches this
|
|
544
|
+
{ stepId: 'synthesize', kind: 'agent', phase: 'Synthesize', deps: ['jn'], prompt: 'TODO: synthesize across lanes', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
545
|
+
],
|
|
546
|
+
fanouts: [{ stage: 'fan', registry: ['lane1', 'lane2', 'lane3'], maxFanout: 3, chain: ['lane'] }],
|
|
547
|
+
joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (pattern === 'gate') {
|
|
551
|
+
return {
|
|
552
|
+
...base,
|
|
553
|
+
steps: [
|
|
554
|
+
{ stepId: 'work', kind: 'agent', phase: 'Work', prompt: 'TODO: produce the artifact', budget: { maxAgents: 2 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
555
|
+
{ stepId: 'gate', kind: 'gate', phase: 'Gate', deps: ['work'], prompt: 'TODO: gate check (parse the verdict, never synthesize one)', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
556
|
+
],
|
|
557
|
+
gates: [{ stepId: 'gate', kind: 'parse-verdict', failRoute: 'work', maxRedos: 1 }],
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
// minimal default: one agent step
|
|
561
|
+
return {
|
|
562
|
+
...base,
|
|
563
|
+
steps: [{ stepId: 'main', kind: 'agent', phase: 'Work', prompt: 'TODO: the one step', budget: { maxAgents: 1 } }], // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* `dz workflow` — the loop-designer authoring verbs (ADR-002; the ADR-005 template emitter is
|
|
568
|
+
* RETIRED — AM-6). Subverbs: init | validate | render | blobs. Any other invocation (including
|
|
569
|
+
* every legacy `--task <name>` / `--name <name>` / bare positional template spelling) prints the
|
|
570
|
+
* pinned retirement message and exits 1 — no legacy format is silently reachable.
|
|
571
|
+
*/
|
|
498
572
|
function cmdWorkflow(options, flags, cwd, write) {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
573
|
+
const sub = options.get('_positional_0') ?? '';
|
|
574
|
+
const legacyTask = options.get('task') ?? options.get('name');
|
|
575
|
+
if (sub === 'init') {
|
|
576
|
+
const name = options.get('name') ?? options.get('_positional_1') ?? 'my-loop';
|
|
577
|
+
const pattern = options.get('pattern') ?? 'pipeline';
|
|
578
|
+
const outPath = resolve(cwd, options.get('o') ?? options.get('out') ?? `${name}.plan.json`);
|
|
579
|
+
const planObj = workflowInitPlan(name, pattern);
|
|
580
|
+
const parsed = parsePlan(planObj);
|
|
581
|
+
if (isParseErrors(parsed)) {
|
|
582
|
+
write(`dz workflow init: internal scaffold error — ${parsed.map((e) => e.message).join('; ')}`);
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
const diags = validatePlan(parsed);
|
|
586
|
+
if (diags.length > 0) {
|
|
587
|
+
write(`dz workflow init: internal scaffold failed validation — ${diags.map((d) => `${d.invariant} ${d.message}`).join('; ')}`);
|
|
588
|
+
return 1;
|
|
589
|
+
}
|
|
590
|
+
writeFileSync(outPath, JSON.stringify(normalizePlan(parsed), null, 2) + '\n');
|
|
591
|
+
write(`dz workflow init: wrote ${outPath} (pattern: ${pattern})`);
|
|
592
|
+
write('Next: edit the TODO prompts, then `dz workflow validate` + `dz workflow render`.'); // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
if (sub === 'validate') {
|
|
596
|
+
const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
|
|
597
|
+
if (planPath === '') {
|
|
598
|
+
write('dz workflow validate: usage — dz workflow validate <plan.json> [--json]');
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
const abs = resolve(cwd, planPath);
|
|
602
|
+
if (!existsSync(abs)) {
|
|
603
|
+
write(`dz workflow validate: no such plan file: ${abs}`);
|
|
604
|
+
return 1;
|
|
605
|
+
}
|
|
606
|
+
let raw;
|
|
607
|
+
try {
|
|
608
|
+
raw = JSON.parse(readFileSync(abs, 'utf8'));
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
write(`dz workflow validate: unparseable JSON — ${e instanceof Error ? e.message : String(e)}`);
|
|
612
|
+
return 1;
|
|
613
|
+
}
|
|
614
|
+
const parsed = parsePlan(raw);
|
|
615
|
+
if (isParseErrors(parsed)) {
|
|
616
|
+
if (flags.has('json'))
|
|
617
|
+
write(JSON.stringify({ ok: false, parseErrors: parsed }, null, 2));
|
|
618
|
+
else
|
|
619
|
+
for (const e of parsed)
|
|
620
|
+
write(`PARSE ${e.path}: ${e.message}`);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
const diags = validatePlan(parsed);
|
|
624
|
+
if (flags.has('json')) {
|
|
625
|
+
write(JSON.stringify({ ok: diags.length === 0, digest: planDigest(parsed), diagnostics: diags }, null, 2));
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
for (const d of diags)
|
|
629
|
+
write(`${d.invariant} ${d.path}: ${d.message}`);
|
|
630
|
+
write(diags.length === 0 ? `dz workflow validate: OK (digest sha256:${planDigest(parsed).slice(0, 16)}…)` : `dz workflow validate: ${diags.length} invariant violation(s)`);
|
|
631
|
+
}
|
|
632
|
+
return diags.length === 0 ? 0 : 1;
|
|
633
|
+
}
|
|
634
|
+
if (sub === 'render') {
|
|
635
|
+
const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
|
|
636
|
+
const outPath = options.get('o') ?? options.get('out') ?? '';
|
|
637
|
+
if (planPath === '' || outPath === '') {
|
|
638
|
+
write('dz workflow render: usage — dz workflow render <plan.json> -o <script.js> [--check] [--force]');
|
|
639
|
+
return 1;
|
|
640
|
+
}
|
|
641
|
+
const absPlan = resolve(cwd, planPath);
|
|
642
|
+
if (!existsSync(absPlan)) {
|
|
643
|
+
write(`dz workflow render: no such plan file: ${absPlan}`);
|
|
644
|
+
return 1;
|
|
645
|
+
}
|
|
646
|
+
const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
|
|
647
|
+
if (isParseErrors(parsed)) {
|
|
648
|
+
for (const e of parsed)
|
|
649
|
+
write(`PARSE ${e.path}: ${e.message}`);
|
|
650
|
+
return 1;
|
|
651
|
+
}
|
|
652
|
+
const diags = validatePlan(parsed);
|
|
653
|
+
if (diags.length > 0) {
|
|
654
|
+
for (const d of diags)
|
|
655
|
+
write(`${d.invariant} ${d.path}: ${d.message}`);
|
|
656
|
+
write('dz workflow render: refusing to render an invalid plan');
|
|
657
|
+
return 1;
|
|
658
|
+
}
|
|
659
|
+
const rendered = renderPlan(parsed);
|
|
660
|
+
const absOut = resolve(cwd, outPath);
|
|
661
|
+
const sidecar = absOut.replace(/\.js$/, '') + '.plan.json';
|
|
662
|
+
const prev = existsSync(absOut) ? readFileSync(absOut, 'utf8') : '';
|
|
663
|
+
const merged = prev === '' ? { text: rendered.text, conflicts: [], refused: false } : mergeRender(prev, rendered, { force: flags.has('force') });
|
|
664
|
+
if (flags.has('check')) {
|
|
665
|
+
const same = prev === merged.text && !merged.refused;
|
|
666
|
+
write(same ? 'dz workflow render --check: up to date' : 'dz workflow render --check: DRIFT — a fresh render differs from the file on disk');
|
|
667
|
+
return same ? 0 : 1;
|
|
668
|
+
}
|
|
669
|
+
if (merged.refused) {
|
|
670
|
+
const proposed = absOut + '.proposed.js';
|
|
671
|
+
writeFileSync(proposed, merged.proposedText ?? rendered.text);
|
|
672
|
+
write(`dz workflow render: ${absOut} carries NO region markers (hand-written loop) — REFUSING to overwrite.`);
|
|
673
|
+
write(`Proposed render written to ${proposed}; re-run with --force to replace the target.`);
|
|
674
|
+
return 1;
|
|
675
|
+
}
|
|
676
|
+
// sidecar plan FIRST, independently of the script (FR-4.1 — the oracle diffs against it)
|
|
677
|
+
writeFileSync(sidecar, rendered.planJson);
|
|
678
|
+
writeFileSync(absOut, merged.text);
|
|
679
|
+
for (const c of merged.conflicts)
|
|
680
|
+
write(`CONFLICT step ${c.stepId}: ${c.reason}`);
|
|
681
|
+
write(`dz workflow render: wrote ${sidecar} then ${absOut} (exec-fp sha256:${rendered.execFingerprint.slice(0, 16)}…, blobs: ${rendered.manifest.blobs.map((b) => b.name).join(', ') || 'none'})`);
|
|
682
|
+
write('Gate it: dz workflow-lint ' + outPath + ' --plan ' + sidecar + ' --require-plan');
|
|
683
|
+
return merged.conflicts.length > 0 ? 1 : 0;
|
|
684
|
+
}
|
|
685
|
+
if (sub === 'blobs') {
|
|
686
|
+
let bad = 0;
|
|
687
|
+
for (const b of Object.values(LOOP_BLOBS)) {
|
|
688
|
+
const actual = createHash('sha256').update(b.code, 'utf8').digest('hex');
|
|
689
|
+
const ok = actual === b.contentHash;
|
|
690
|
+
if (!ok)
|
|
691
|
+
bad++;
|
|
692
|
+
write(`${b.name}@${b.version} sha256:${b.contentHash.slice(0, 16)}… ${ok ? 'OK' : 'HASH MISMATCH (loop-blobs.generated.ts was hand-edited — regenerate: node scripts/gen-loop-blobs.mjs)'} (requires: [${b.requires.join(', ')}])`);
|
|
693
|
+
}
|
|
694
|
+
if (flags.has('check')) {
|
|
695
|
+
write(bad === 0 ? 'dz workflow blobs --check: registry self-consistent (authoritative canon-vs-committed diff runs in CI via loop-blobs-regen.test.ts / scripts/gen-loop-blobs.mjs --check)' : `dz workflow blobs --check: ${bad} blob(s) inconsistent`);
|
|
696
|
+
return bad === 0 ? 0 : 1;
|
|
697
|
+
}
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
// Everything else — the retired ADR-005 surface, BOTH spellings (bare positional task name and
|
|
701
|
+
// --task/--name) — prints the pinned shim message. AM-6: never silently reachable.
|
|
702
|
+
void legacyTask;
|
|
703
|
+
void flags;
|
|
704
|
+
write(WORKFLOW_TEMPLATES_RETIRED_MESSAGE);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
/** `dz workflow-lint` — layer-1 gate; exit 0/1/3 (pass/fail/inconclusive — INV-13: inconclusive is
|
|
708
|
+
* never a pass; the exit convention mirrors consult-gate). */
|
|
709
|
+
function cmdWorkflowLint(options, flags, cwd, write) {
|
|
710
|
+
const scriptPath = options.get('_positional_0') ?? '';
|
|
711
|
+
if (scriptPath === '') {
|
|
712
|
+
write('dz workflow-lint: usage — dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json]');
|
|
502
713
|
return 1;
|
|
503
714
|
}
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
506
|
-
write(`dz workflow:
|
|
715
|
+
const absScript = resolve(cwd, scriptPath);
|
|
716
|
+
if (!existsSync(absScript)) {
|
|
717
|
+
write(`dz workflow-lint: no such script: ${absScript}`);
|
|
507
718
|
return 1;
|
|
508
719
|
}
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
720
|
+
const scriptText = readFileSync(absScript, 'utf8');
|
|
721
|
+
const planPath = options.get('plan');
|
|
722
|
+
let plan = null;
|
|
723
|
+
let digestValue = null;
|
|
724
|
+
if (planPath !== undefined) {
|
|
725
|
+
const absPlan = resolve(cwd, planPath);
|
|
726
|
+
if (!existsSync(absPlan)) {
|
|
727
|
+
write(`dz workflow-lint: no such plan: ${absPlan}`);
|
|
728
|
+
return 1;
|
|
729
|
+
}
|
|
730
|
+
const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
|
|
731
|
+
if (isParseErrors(parsed)) {
|
|
732
|
+
for (const e of parsed)
|
|
733
|
+
write(`PARSE ${e.path}: ${e.message}`);
|
|
734
|
+
return 1;
|
|
735
|
+
}
|
|
736
|
+
plan = parsed;
|
|
737
|
+
digestValue = planDigest(parsed);
|
|
738
|
+
}
|
|
739
|
+
const mode = flags.has('require-plan') ? 'require-plan' : flags.has('legacy') ? 'legacy' : 'default';
|
|
740
|
+
const run = lint(scriptText, { plan, planDigestValue: digestValue, blobRegistry: LOOP_BLOBS, mode });
|
|
741
|
+
if (flags.has('json')) {
|
|
742
|
+
write(JSON.stringify(run, null, 2));
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
for (const f of run.findings) {
|
|
746
|
+
write(`${f.severity.toUpperCase().padEnd(12)} ${f.rule}: ${f.message}${f.anchor ? ` [anchor: ${f.anchor}]` : ''}`);
|
|
747
|
+
}
|
|
748
|
+
const counts = { fail: 0, warn: 0, inconclusive: 0 };
|
|
749
|
+
for (const f of run.findings)
|
|
750
|
+
if (f.severity in counts)
|
|
751
|
+
counts[f.severity]++;
|
|
752
|
+
write(`dz workflow-lint: ${run.verdict.toUpperCase()} (mode=${run.mode}; ${counts.fail} fail, ${counts.warn} warn, ${counts.inconclusive} inconclusive over ${Object.keys(run.rules).length} rules)`);
|
|
753
|
+
if (run.verdict === 'inconclusive')
|
|
754
|
+
write('inconclusive is NOT a pass (exit 3) — bind a plan (--plan … --require-plan) or acknowledge a legacy script with --legacy');
|
|
755
|
+
}
|
|
756
|
+
return lintExitCode(run);
|
|
757
|
+
}
|
|
758
|
+
/** `dz workflow-trace` — timeline + invariant runner over a run's trace.jsonl. Scope is CAPPED
|
|
759
|
+
* (AM-8): <runDir|--slug|--run>, --invariants, --html, --json. NO watch/filter/compare/search/
|
|
760
|
+
* retention/access-control — adding one needs an ADR amendment (the surface test pins this). */
|
|
761
|
+
function cmdWorkflowTrace(options, flags, cwd, write) {
|
|
762
|
+
const runDirArg = options.get('_positional_0');
|
|
763
|
+
const slug = options.get('slug');
|
|
764
|
+
const runId = options.get('run');
|
|
765
|
+
let runDir;
|
|
766
|
+
if (runDirArg !== undefined && runDirArg !== '')
|
|
767
|
+
runDir = resolve(cwd, runDirArg);
|
|
768
|
+
else if (slug !== undefined)
|
|
769
|
+
runDir = resolve(cwd, 'features', slug);
|
|
770
|
+
else if (runId !== undefined)
|
|
771
|
+
runDir = resolve(cwd, '.dz', 'loop-trace', runId);
|
|
772
|
+
else {
|
|
773
|
+
write('dz workflow-trace: usage — dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json]');
|
|
774
|
+
return 1;
|
|
775
|
+
}
|
|
776
|
+
const traceFile = join(runDir, 'trace.jsonl');
|
|
777
|
+
if (!existsSync(traceFile)) {
|
|
778
|
+
write(`dz workflow-trace: no trace.jsonl under ${runDir} (the loop writes its own trace only when the plan sets trace.emit: true)`);
|
|
779
|
+
return 1;
|
|
780
|
+
}
|
|
781
|
+
const traceText = readFileSync(traceFile, 'utf8');
|
|
782
|
+
const ckptFile = join(runDir, '.fa-state', 'checkpoints.jsonl');
|
|
783
|
+
const ledgerFile = resolve(cwd, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
|
|
784
|
+
const journalFile = join(runDir, 'journal.jsonl');
|
|
785
|
+
const timeline = assembleTimeline({
|
|
786
|
+
trace: traceText,
|
|
787
|
+
checkpoints: existsSync(ckptFile) ? readFileSync(ckptFile, 'utf8') : null,
|
|
788
|
+
ledger: existsSync(ledgerFile) ? readFileSync(ledgerFile, 'utf8') : null,
|
|
789
|
+
journal: existsSync(journalFile) ? readFileSync(journalFile, 'utf8') : null,
|
|
790
|
+
});
|
|
791
|
+
let verdicts = [];
|
|
792
|
+
let projection = null;
|
|
793
|
+
const invariantsPlan = options.get('invariants');
|
|
794
|
+
if (invariantsPlan !== undefined) {
|
|
795
|
+
const absPlan = resolve(cwd, invariantsPlan);
|
|
796
|
+
if (!existsSync(absPlan)) {
|
|
797
|
+
write(`dz workflow-trace: no such plan: ${absPlan}`);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
|
|
801
|
+
if (isParseErrors(parsed)) {
|
|
802
|
+
for (const e of parsed)
|
|
803
|
+
write(`PARSE ${e.path}: ${e.message}`);
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
806
|
+
projection = toTraceProjection(parsed);
|
|
807
|
+
verdicts = runInvariants(projection, parseTrace(traceText));
|
|
808
|
+
}
|
|
809
|
+
const htmlOut = options.get('html');
|
|
810
|
+
if (htmlOut !== undefined) {
|
|
811
|
+
const absHtml = resolve(cwd, htmlOut);
|
|
812
|
+
writeFileSync(absHtml, renderTimelineHtml(timeline, projection, verdicts));
|
|
813
|
+
write(`dz workflow-trace: wrote ${absHtml} (self-contained: mermaid topology + HTML waterfall)`);
|
|
814
|
+
}
|
|
815
|
+
if (flags.has('json')) {
|
|
816
|
+
write(JSON.stringify({ timeline, verdicts }, null, 2));
|
|
817
|
+
return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
|
|
818
|
+
}
|
|
819
|
+
write(`run ${timeline.runId ?? '(unknown)'}${timeline.incomplete ? ' — INCOMPLETE (no run.closed; unflushed tail may be lost)' : ''}; sources: ${timeline.sources.join(', ')}`);
|
|
820
|
+
for (const r of timeline.rows.filter((x) => x.kind === 'trace')) {
|
|
821
|
+
write(` ${String(r.seq).padStart(5)} ${r.label} ${r.detail}${r.wallTime ? ` [wall ${r.wallTime} — diagnostic only]` : ''}`);
|
|
822
|
+
}
|
|
823
|
+
for (const v of verdicts)
|
|
824
|
+
write(`INVARIANT ${v.status.toUpperCase().padEnd(12)} ${v.id}: ${v.message}`);
|
|
825
|
+
return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
|
|
519
826
|
}
|
|
520
827
|
function cmdMigrate(options, cwd, write) {
|
|
521
828
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
@@ -659,7 +966,7 @@ function cmdBundle(options, flags, cwd, write) {
|
|
|
659
966
|
}
|
|
660
967
|
return 0;
|
|
661
968
|
}
|
|
662
|
-
async function cmdInstall(options, flags, cwd, write) {
|
|
969
|
+
async function cmdInstall(options, flags, cwd, write, installRunner) {
|
|
663
970
|
const pkg = options.get('_positional_0');
|
|
664
971
|
if (!pkg) {
|
|
665
972
|
write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
|
|
@@ -671,10 +978,14 @@ async function cmdInstall(options, flags, cwd, write) {
|
|
|
671
978
|
return 1;
|
|
672
979
|
}
|
|
673
980
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
674
|
-
// Step 1: npm install the package
|
|
981
|
+
// Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
|
|
675
982
|
write(`Installing ${pkg}...`);
|
|
983
|
+
const installCmd = `npm install ${pkg} --save-dev --no-fund --no-audit`;
|
|
676
984
|
try {
|
|
677
|
-
|
|
985
|
+
if (installRunner)
|
|
986
|
+
installRunner(installCmd, projectRoot);
|
|
987
|
+
else
|
|
988
|
+
execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
678
989
|
}
|
|
679
990
|
catch (err) {
|
|
680
991
|
write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -686,26 +997,36 @@ async function cmdInstall(options, flags, cwd, write) {
|
|
|
686
997
|
write(`dz install: package not found at ${pkgDir}`);
|
|
687
998
|
return 1;
|
|
688
999
|
}
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1000
|
+
// Resolve the package's skills root across the known layouts (flat / npx-template /
|
|
1001
|
+
// skills-dir) — feature dz-install-npx-init. Total resolution failure is an ERROR
|
|
1002
|
+
// (exit 1), not the old advisory exit 0: a user asking to install skills that were
|
|
1003
|
+
// not installed must not see success.
|
|
1004
|
+
const roots = resolvePackageSkillRoots(pkgDir);
|
|
1005
|
+
if (roots.length === 0) {
|
|
1006
|
+
write(`dz install: no SKILL.md found in ${pkg}. Probed: ${PACKAGE_SKILL_LAYOUTS.map((l) => l.rel).join(', ')}.`);
|
|
1007
|
+
write(`If this package installs itself, try: npx -y ${pkg} init`);
|
|
1008
|
+
return 1;
|
|
695
1009
|
}
|
|
696
|
-
|
|
1010
|
+
const root = roots[0];
|
|
1011
|
+
// Step 3: Use dz init with the resolved skills root as source
|
|
697
1012
|
const report = await runInit({
|
|
698
1013
|
target: targetOpt,
|
|
699
|
-
skillsDir:
|
|
1014
|
+
skillsDir: root.dir,
|
|
700
1015
|
projectRoot,
|
|
701
1016
|
force: flags.has('force'),
|
|
702
1017
|
});
|
|
703
1018
|
const totalWritten = report.skills.reduce((sum, s) => sum + s.written.length, 0);
|
|
704
1019
|
const totalSkipped = report.skills.reduce((sum, s) => sum + s.skipped.length, 0);
|
|
705
|
-
|
|
1020
|
+
// Non-flat resolutions are tagged so a mis-resolution is legible in a user's paste;
|
|
1021
|
+
// flat output stays byte-identical to the pre-feature behavior (NFR-1 / test T5.6).
|
|
1022
|
+
const layoutTag = root.layout === 'flat' ? '' : ` [layout: ${root.layout}]`;
|
|
1023
|
+
write(`dz install ${pkg}: ${report.skills.length} skill(s), ${totalWritten} file(s) written, ${totalSkipped} skipped${layoutTag}`);
|
|
706
1024
|
for (const skill of report.skills) {
|
|
707
1025
|
write(` ${skill.id}: ${skill.written.length} written, ${skill.skipped.length} skipped`);
|
|
708
1026
|
}
|
|
1027
|
+
if (root.layout === 'npx-template' && root.hasCompanionAssets) {
|
|
1028
|
+
write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
|
|
1029
|
+
}
|
|
709
1030
|
return 0;
|
|
710
1031
|
}
|
|
711
1032
|
function cmdCompose(options, cwd, write) {
|
|
@@ -1367,6 +1688,23 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
1367
1688
|
}
|
|
1368
1689
|
write(`Imported ${imported} pattern(s) from ${fromJson}`);
|
|
1369
1690
|
write(` Skipped ${skipped} (duplicates already in the store, or invalid entries)`);
|
|
1691
|
+
// Bulk import preserves the DOMAIN of every record, so it can put medical lessons in
|
|
1692
|
+
// a shared store as silently as a hand-typed teach — and it returned before the
|
|
1693
|
+
// advice single-teach prints. The same advice, at the same point in the flow: after
|
|
1694
|
+
// the write, naming the choice, blocking nothing.
|
|
1695
|
+
let resolvedImportRoot = projectRoot;
|
|
1696
|
+
try {
|
|
1697
|
+
resolvedImportRoot = realpathSync(projectRoot);
|
|
1698
|
+
}
|
|
1699
|
+
catch { /* unresolvable is not the brain */ }
|
|
1700
|
+
const importedMedical = importedRecs.filter((r) => DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(canonicalDomainKey(r.domain)));
|
|
1701
|
+
if (importedMedical.length > 0) {
|
|
1702
|
+
const advice = renderSharedStoreAdvice(importedMedical[0]?.domain, resolvedImportRoot);
|
|
1703
|
+
if (advice !== '') {
|
|
1704
|
+
write(` ⚠ ${importedMedical.length} of the imported lesson(s) carry a medical domain.`);
|
|
1705
|
+
write(advice);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1370
1708
|
write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(projectRoot).length}`);
|
|
1371
1709
|
// ONE batched mirror call through the same seam as single-teach (QR-6 — no bespoke path).
|
|
1372
1710
|
await emitMirror(projectRoot, importedRecs, 'dz-teach-import');
|
|
@@ -1459,6 +1797,20 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
1459
1797
|
write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
|
|
1460
1798
|
write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
|
|
1461
1799
|
write(` Total patterns: ${count}`);
|
|
1800
|
+
// ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
|
|
1801
|
+
// directories and this binary; refusing would be defending a user against themselves,
|
|
1802
|
+
// which this design does not attempt. Making the choice INFORMED is the part that is
|
|
1803
|
+
// ours to do — the write has already happened when this prints.
|
|
1804
|
+
// RESOLVE before deciding. A symlink named `.health-brain` pointing at a shared project
|
|
1805
|
+
// silenced this advice in exactly the case it exists for.
|
|
1806
|
+
let resolvedRoot = projectRoot;
|
|
1807
|
+
try {
|
|
1808
|
+
resolvedRoot = realpathSync(projectRoot);
|
|
1809
|
+
}
|
|
1810
|
+
catch { /* a path we cannot resolve is not the brain */ }
|
|
1811
|
+
const sharedAdvice = renderSharedStoreAdvice(domain, resolvedRoot);
|
|
1812
|
+
if (sharedAdvice !== '')
|
|
1813
|
+
write(sharedAdvice);
|
|
1462
1814
|
if (quarantineOn) {
|
|
1463
1815
|
write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
|
|
1464
1816
|
}
|
|
@@ -1626,13 +1978,42 @@ function fmtUsageRow(row) {
|
|
|
1626
1978
|
return `${row.reads}×${last}${score} ${row.dzId}${domain} ${row.pattern.slice(0, 100)}`;
|
|
1627
1979
|
}
|
|
1628
1980
|
function cmdRecallUsage(options, flags, projectRoot, write) {
|
|
1629
|
-
const
|
|
1981
|
+
const raw = readRecallUsageReport(projectRoot);
|
|
1982
|
+
// The hold-out applies to EVERY surface that hands out lesson TEXT, not only to the
|
|
1983
|
+
// one labelled "export". `--usage --json` prints whole lessons in its `top` and
|
|
1984
|
+
// `neverRead` lists, so it is an export by behaviour whatever it is called — a
|
|
1985
|
+
// self-audit before review found health-research lesson text sitting in both. A
|
|
1986
|
+
// guarantee that covers only the surface you were thinking about is not a guarantee.
|
|
1987
|
+
const heldOut = heldOutAfterOptIn(options.get('include-domain'));
|
|
1988
|
+
// EVERY list of records, not the two that were obvious. A first pass held out `top`
|
|
1989
|
+
// and `neverRead` and a health lesson still came out — in `all`, a third list further
|
|
1990
|
+
// down the same object. Enumerating the lists you remembered is how the surface leaks.
|
|
1991
|
+
const top = applyExportHoldout(raw.top, heldOut);
|
|
1992
|
+
const neverRead = applyExportHoldout(raw.neverRead, heldOut);
|
|
1993
|
+
// `unknown` is deliberately NOT held out: RecallUsageStat carries counters and
|
|
1994
|
+
// timestamps only — no domain, no lesson text — so there is nothing in it to withhold.
|
|
1995
|
+
// Filtering it would be theatre, and theatre in a privacy control is worse than a gap
|
|
1996
|
+
// because it looks like coverage.
|
|
1997
|
+
const every = applyExportHoldout(raw.all ?? [], heldOut);
|
|
1998
|
+
const withheld = top.withheld.length + neverRead.withheld.length + every.withheld.length;
|
|
1999
|
+
const report = {
|
|
2000
|
+
...raw,
|
|
2001
|
+
top: top.exported,
|
|
2002
|
+
neverRead: neverRead.exported,
|
|
2003
|
+
...(raw.all === undefined ? {} : { all: every.exported }),
|
|
2004
|
+
};
|
|
2005
|
+
const holdoutNote = renderHoldoutNote([top, neverRead, every]
|
|
2006
|
+
.reduce((a, b) => (b.withheld.length > a.withheld.length ? b : a)));
|
|
1630
2007
|
if (flags.has('json')) {
|
|
1631
2008
|
write(JSON.stringify({
|
|
1632
2009
|
...report,
|
|
1633
2010
|
log: join(projectRoot, RECALL_USAGE_LOG_RELATIVE),
|
|
1634
2011
|
retention: { maxBytes: RECALL_USAGE_LOG_MAX_BYTES },
|
|
2012
|
+
withheld,
|
|
2013
|
+
withheldDomains: [...new Set([...top.domains, ...neverRead.domains, ...every.domains])].sort(),
|
|
1635
2014
|
}));
|
|
2015
|
+
if (withheld > 0 && holdoutNote !== '')
|
|
2016
|
+
process.stderr.write(`${holdoutNote}\n`);
|
|
1636
2017
|
return 0;
|
|
1637
2018
|
}
|
|
1638
2019
|
const displayLimit = Math.max(1, parseInt(options.get('limit') ?? '20', 10) || 20);
|
|
@@ -1662,6 +2043,9 @@ function cmdRecallUsage(options, flags, projectRoot, write) {
|
|
|
1662
2043
|
if (report.neverRead.length > displayLimit)
|
|
1663
2044
|
write(` ... ${report.neverRead.length - displayLimit} more (use --json for the full list)`);
|
|
1664
2045
|
}
|
|
2046
|
+
if (withheld > 0 && holdoutNote !== '') {
|
|
2047
|
+
write(holdoutNote);
|
|
2048
|
+
}
|
|
1665
2049
|
return 0;
|
|
1666
2050
|
}
|
|
1667
2051
|
/**
|
|
@@ -1768,15 +2152,55 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1768
2152
|
// --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
|
|
1769
2153
|
// With --json this is the portable export the agentdb-memory MCP bridge consumes.
|
|
1770
2154
|
if (all) {
|
|
1771
|
-
|
|
2155
|
+
// ISOLATION (health-advisor slice H, ADR-003). This command is THE portable sharing
|
|
2156
|
+
// form — it is documented as such and the agentdb bridge consumes it — so it is the
|
|
2157
|
+
// realistic path by which a learned store leaves this machine. `health-research`
|
|
2158
|
+
// carries lessons drawn from one person's investigations and is held back unless the
|
|
2159
|
+
// caller names it. This replaced a text-inspecting privacy guard that seven rounds of
|
|
2160
|
+
// review could not make correct: a tag set by the writer is decidable, prose is not.
|
|
2161
|
+
const allPatterns = loadStorePatternsSync(projectRoot);
|
|
2162
|
+
const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
|
|
2163
|
+
const patterns = holdout.exported;
|
|
2164
|
+
const holdoutNote = renderHoldoutNote(holdout);
|
|
2165
|
+
// The opt-in is honoured without argument — and named out loud. A flag that silently
|
|
2166
|
+
// includes medical lessons in a portable export is a flag whose consequence the user
|
|
2167
|
+
// has to remember; one that says what it just handed over is a flag they can act on.
|
|
2168
|
+
const optedIn = options.get('include-domain');
|
|
2169
|
+
if (optedIn !== undefined && optedIn.trim() !== '') {
|
|
2170
|
+
// Count against the SPLIT list, the same way heldOutAfterOptIn parses it. Comparing
|
|
2171
|
+
// each record to the whole unsplit string reported `0 of 2` for a comma-separated
|
|
2172
|
+
// opt-in that had just exported a medical lesson — a warning that undercounts the
|
|
2173
|
+
// thing it warns about is worse than none.
|
|
2174
|
+
const optedKeys = new Set(optedIn.split(',').map((d) => canonicalDomainKey(d)).filter((d) => d !== ''));
|
|
2175
|
+
const medicalKeys = new Set(DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey));
|
|
2176
|
+
const releasedKeys = [...optedKeys].filter((k) => medicalKeys.has(k));
|
|
2177
|
+
const releasedCount = allPatterns.filter((p) => releasedKeys.includes(canonicalDomainKey(p.domain))).length;
|
|
2178
|
+
if (releasedKeys.length > 0) {
|
|
2179
|
+
process.stderr.write(` ⚠ --include-domain ${releasedKeys.join(',')}: this export CONTAINS lessons from a medical `
|
|
2180
|
+
+ `domain (${releasedCount} of ${allPatterns.length}). They travel with the file wherever it `
|
|
2181
|
+
+ `goes. Nothing was blocked — this is your call.\n`);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
1772
2184
|
if (flags.has('stats')) {
|
|
1773
|
-
|
|
2185
|
+
// `stats` is computed from the UNFILTERED store, and `topUses` carries whole
|
|
2186
|
+
// lesson text. Filtering `patterns` two lines above and serialising this untouched
|
|
2187
|
+
// exported exactly what had just been withheld — found by review immediately after
|
|
2188
|
+
// I had written the lesson "cover every surface", in the same function. The
|
|
2189
|
+
// per-domain histogram goes too: it names the domain and its size.
|
|
2190
|
+
const rawStats = storeStats(projectRoot);
|
|
2191
|
+
const topHoldout = applyExportHoldout(rawStats.topUses, heldOutAfterOptIn(options.get('include-domain')));
|
|
2192
|
+
const perDomain = Object.fromEntries(Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))));
|
|
2193
|
+
const stats = { ...rawStats, topUses: topHoldout.exported, perDomain };
|
|
1774
2194
|
const backendStats = resolveLearningBackend(projectRoot).getStats();
|
|
1775
2195
|
if (asJson) {
|
|
1776
|
-
write(JSON.stringify({ patterns, stats, learning: backendStats }));
|
|
2196
|
+
write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
|
|
2197
|
+
if (holdoutNote !== '')
|
|
2198
|
+
process.stderr.write(`${holdoutNote}\n`);
|
|
1777
2199
|
return 0;
|
|
1778
2200
|
}
|
|
1779
2201
|
write(`dz recall --all --stats — ${patterns.length} learned pattern(s)`);
|
|
2202
|
+
if (holdoutNote !== '')
|
|
2203
|
+
write(holdoutNote);
|
|
1780
2204
|
write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
|
|
1781
2205
|
write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
|
|
1782
2206
|
write(` exact-dup groups: ${stats.exactDupGroups}`);
|
|
@@ -1788,12 +2212,19 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1788
2212
|
return 0;
|
|
1789
2213
|
}
|
|
1790
2214
|
if (asJson) {
|
|
2215
|
+
// The note goes to STDERR so the JSON on stdout stays machine-parsable — but it is
|
|
2216
|
+
// still SAID. A silent hold-out would let the reader believe they exported the whole
|
|
2217
|
+
// store, and would make a broken hold-out look exactly like an empty domain.
|
|
1791
2218
|
write(JSON.stringify(patterns));
|
|
2219
|
+
if (holdoutNote !== '')
|
|
2220
|
+
process.stderr.write(`${holdoutNote}\n`);
|
|
1792
2221
|
}
|
|
1793
2222
|
else {
|
|
1794
2223
|
write(`dz recall --all — ${patterns.length} learned pattern(s)`);
|
|
1795
2224
|
for (const p of patterns)
|
|
1796
2225
|
write(` [${p.reward.toFixed(2)}] (${p.domain}) ${p.pattern.slice(0, 80)}`);
|
|
2226
|
+
if (holdoutNote !== '')
|
|
2227
|
+
write(holdoutNote);
|
|
1797
2228
|
}
|
|
1798
2229
|
return 0;
|
|
1799
2230
|
}
|
|
@@ -1848,6 +2279,14 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1848
2279
|
// never receive its promised lift — the feature was weakest in exactly the case it
|
|
1849
2280
|
// exists for (foreign-domain dilution pushing a relevant lesson just past the cut).
|
|
1850
2281
|
// Fetch a bounded surplus, re-rank, then trim to the limit the caller asked for.
|
|
2282
|
+
// EVERY value that reaches an output line goes through this, not just stored fields.
|
|
2283
|
+
// Round 5: `pattern` and `domain` were sanitised but the QUERY and the requested
|
|
2284
|
+
// domain were not, so a caller-supplied newline could still forge a line that looks
|
|
2285
|
+
// like the tool's own — including the boost note a peer reads as a capability probe.
|
|
2286
|
+
// The rule is "one line out per line of output", and it has to hold for every field.
|
|
2287
|
+
const oneLine = (v) => String(v).replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
|
|
2288
|
+
const shownQuery = oneLine(query);
|
|
2289
|
+
const shownDomain = wantedDomain === undefined ? undefined : oneLine(wantedDomain);
|
|
1851
2290
|
const fetchLimit = wantedDomain !== undefined ? Math.min(limit * 3, limit + 20) : limit;
|
|
1852
2291
|
const result = await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
|
|
1853
2292
|
if (mode === 'semantic' && result.vectorEngine === 'none') {
|
|
@@ -1870,22 +2309,31 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1870
2309
|
// Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
|
|
1871
2310
|
// `dz teach --from-json` regardless of which backend ranked each hit.
|
|
1872
2311
|
write(JSON.stringify(hits.map((h) => h.pattern)));
|
|
2312
|
+
// The honesty notes go to STDERR here rather than being skipped: the JSON branch
|
|
2313
|
+
// used to return before them, so a scripted caller was told nothing about a boost
|
|
2314
|
+
// that had promoted a match and pushed a visible hit past the --limit cut.
|
|
2315
|
+
if (boost !== null && shownDomain !== undefined) {
|
|
2316
|
+
process.stderr.write(`${renderDomainBoostNote(boost, shownDomain)}\n`);
|
|
2317
|
+
const cutNoteJson = renderDomainCutNote(displaced, limit);
|
|
2318
|
+
if (cutNoteJson !== '')
|
|
2319
|
+
process.stderr.write(`${cutNoteJson}\n`);
|
|
2320
|
+
}
|
|
1873
2321
|
return 0;
|
|
1874
2322
|
}
|
|
1875
2323
|
if (hits.length === 0) {
|
|
1876
|
-
write(`dz recall "${
|
|
2324
|
+
write(`dz recall "${shownQuery}"`);
|
|
1877
2325
|
write(` No matching patterns (teach some with \`dz teach\`, or consolidate sessions).`);
|
|
1878
2326
|
// The domain note must print here too (Codex QE #10): a --domain run with zero hits
|
|
1879
2327
|
// silently said nothing about the domain, so the reader could not tell whether the
|
|
1880
2328
|
// boost had been applied and found nothing, or had not run at all.
|
|
1881
|
-
if (boost !== null &&
|
|
1882
|
-
write(renderDomainBoostNote(boost,
|
|
2329
|
+
if (boost !== null && shownDomain !== undefined)
|
|
2330
|
+
write(renderDomainBoostNote(boost, shownDomain));
|
|
1883
2331
|
return 0;
|
|
1884
2332
|
}
|
|
1885
2333
|
const vectorOn = result.vectorEngine !== 'none' && result.vectorError === undefined && mode !== 'lexical';
|
|
1886
2334
|
const lexLabel = result.lexicalBackend === 'sqlite' ? 'SQLite FTS5' : 'keyword (JSON)';
|
|
1887
2335
|
const ranking = vectorOn ? `${lexLabel} + vector (${result.vectorEngine}) ranking` : `${lexLabel} ranking (lexical)`;
|
|
1888
|
-
write(`dz recall "${
|
|
2336
|
+
write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}`);
|
|
1889
2337
|
let sawQuarantined = false;
|
|
1890
2338
|
for (const h of hits) {
|
|
1891
2339
|
const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
|
|
@@ -1901,7 +2349,6 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1901
2349
|
// might treat as a break — not just CR/LF. Round 4 forged the domain-boost note
|
|
1902
2350
|
// twice over: once through `domain`, which was rendered raw, and once through a
|
|
1903
2351
|
// U+2028 that Python's splitlines() honours and this replace did not.
|
|
1904
|
-
const oneLine = (v) => v.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
|
|
1905
2352
|
const shown = oneLine(h.pattern.pattern).slice(0, 80);
|
|
1906
2353
|
write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendTag}${qTag} ${shown}`);
|
|
1907
2354
|
}
|
|
@@ -1909,10 +2356,10 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
1909
2356
|
// The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
|
|
1910
2357
|
write(' ⚠q = quarantined (unproven hypothesis, rank damped) — confirm with dz teach --reinforce, or dz recall --promote <dzId> --apply');
|
|
1911
2358
|
}
|
|
1912
|
-
if (boost !== null &&
|
|
2359
|
+
if (boost !== null && shownDomain !== undefined) {
|
|
1913
2360
|
// Say what the boost did — INCLUDING when it did nothing. A silent reorder would
|
|
1914
2361
|
// let the reader believe the ranking was domain-aware when it had no match to work with.
|
|
1915
|
-
write(renderDomainBoostNote(boost,
|
|
2362
|
+
write(renderDomainBoostNote(boost, shownDomain));
|
|
1916
2363
|
const cutNote = renderDomainCutNote(displaced, limit);
|
|
1917
2364
|
if (cutNote !== '')
|
|
1918
2365
|
write(cutNote);
|
|
@@ -2054,6 +2501,29 @@ async function cmdVector(options, flags, cwd, write) {
|
|
|
2054
2501
|
write(' Patterns themselves stay portable via: dz recall --all --json (import with: dz teach --from-json)');
|
|
2055
2502
|
return 0;
|
|
2056
2503
|
}
|
|
2504
|
+
// The vector checkpoint carries EMBEDDINGS of the lesson text, and the RVF adapter
|
|
2505
|
+
// copies the whole store — there is no per-record filter to apply. So this path
|
|
2506
|
+
// fails CLOSED: if the store holds a held-out domain, the export is refused unless
|
|
2507
|
+
// the caller names that domain. An embedding is not plaintext, but ADR-003's
|
|
2508
|
+
// question is "does patient data leave this machine?", and a checkpoint that
|
|
2509
|
+
// silently carried it would answer yes while the documentation said no.
|
|
2510
|
+
// JUDGE THE FILE THIS COMMAND EXPORTS. The decision itself lives in harness-core as
|
|
2511
|
+
// a pure function WITH TESTS: the RVF engine is opt-in and absent on most machines,
|
|
2512
|
+
// so this branch could not be exercised in a normal checkout — reasoning about a
|
|
2513
|
+
// safety check I could not run is exactly what this project calls unverified.
|
|
2514
|
+
const optIn = options.get('include-domain');
|
|
2515
|
+
const vecHoldout = applyExportHoldout(loadStorePatternsSync(projectRoot).map((p) => ({ domain: p.domain })), heldOutAfterOptIn(optIn));
|
|
2516
|
+
const decision = decideVectorExport({
|
|
2517
|
+
rvfExists: existsSync(join(projectRoot, '.dz', 'memory', 'patterns.rvf')),
|
|
2518
|
+
heldOutLexicalCount: vecHoldout.withheld.length,
|
|
2519
|
+
heldOutDomains: vecHoldout.domains,
|
|
2520
|
+
optedIn: optIn,
|
|
2521
|
+
});
|
|
2522
|
+
if (!decision.allow) {
|
|
2523
|
+
write(`dz vector export: REFUSED — ${decision.reason}.`);
|
|
2524
|
+
write(` A forgotten lesson's embedding outlives its lexical record. To export anyway, naming what travels: dz vector export ${dest} --include-domain ${decision.optInHint}`);
|
|
2525
|
+
return 1;
|
|
2526
|
+
}
|
|
2057
2527
|
const r = await exporter(resolve(cwd, dest));
|
|
2058
2528
|
if (r.error !== undefined) {
|
|
2059
2529
|
write(`dz vector export: ${r.error}`);
|
|
@@ -3006,7 +3476,7 @@ async function cmdAutoCanonicalize(options, cwd, write) {
|
|
|
3006
3476
|
const skillName = parts[parts.length - 2] ?? parts[0] ?? 'unknown';
|
|
3007
3477
|
write(` ${skillName.padEnd(30)} ${path}`);
|
|
3008
3478
|
}
|
|
3009
|
-
write(`\nTo
|
|
3479
|
+
write(`\nTo bring each skill into the canonical tree, run:`);
|
|
3010
3480
|
const packDir = resolve(cwd, pack);
|
|
3011
3481
|
for (const path of skillMds) {
|
|
3012
3482
|
const parts = path.split('/');
|
|
@@ -3377,6 +3847,8 @@ function cmdPublish(options, flags, cwd, write) {
|
|
|
3377
3847
|
write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
|
|
3378
3848
|
else
|
|
3379
3849
|
write('dz publish: ✓ guard pre-flight passed');
|
|
3850
|
+
for (const n of guardResult.notes ?? [])
|
|
3851
|
+
write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
|
|
3380
3852
|
}
|
|
3381
3853
|
// ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
|
|
3382
3854
|
// refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
|
|
@@ -4577,7 +5049,13 @@ function cmdDriftCheck(options, flags, cwd, write) {
|
|
|
4577
5049
|
return driftExit || sigFatal;
|
|
4578
5050
|
}
|
|
4579
5051
|
const DEFAULT_STORE_CAP = 5000;
|
|
4580
|
-
/**
|
|
5052
|
+
/**
|
|
5053
|
+
* Ceiling on how many changed files the no-stubs scan reads per evaluation. Deterministic (the file
|
|
5054
|
+
* list is `git status` order) and fail-open: files beyond it simply have no gathered contents, so
|
|
5055
|
+
* the rule reports nothing for them — a pre-flight must never become a filesystem sweep.
|
|
5056
|
+
*/
|
|
5057
|
+
const MAX_STUB_SCAN_FILES = 400;
|
|
5058
|
+
/** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
|
|
4581
5059
|
function loadGuardConfig(root) {
|
|
4582
5060
|
const p = join(root, '.dz', 'guard.json');
|
|
4583
5061
|
if (!existsSync(p))
|
|
@@ -4660,6 +5138,34 @@ function gatherGuardFacts(op, root, text, storeCap) {
|
|
|
4660
5138
|
packages.push({ name: m.name ?? '(unnamed)', deps });
|
|
4661
5139
|
}
|
|
4662
5140
|
facts['packages'] = packages;
|
|
5141
|
+
// licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
|
|
5142
|
+
// `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
|
|
5143
|
+
// LICENSE reads as absent (null), which the checker treats as a violation for a publishable
|
|
5144
|
+
// pack — the fail direction that protects the hold, never the publish.
|
|
5145
|
+
try {
|
|
5146
|
+
const holds = [];
|
|
5147
|
+
for (const { dir, m } of located) {
|
|
5148
|
+
if (!m || m.licenseHold === undefined || m.licenseHold === null)
|
|
5149
|
+
continue;
|
|
5150
|
+
const read = (rel) => {
|
|
5151
|
+
try {
|
|
5152
|
+
return readFileSync(join(root, dir, rel), 'utf8');
|
|
5153
|
+
}
|
|
5154
|
+
catch {
|
|
5155
|
+
return null;
|
|
5156
|
+
}
|
|
5157
|
+
};
|
|
5158
|
+
holds.push({
|
|
5159
|
+
name: m.name ?? dir,
|
|
5160
|
+
privateFlag: m.private === true,
|
|
5161
|
+
licenseText: read('LICENSE'),
|
|
5162
|
+
noticesText: read('THIRD_PARTY_NOTICES.md') ?? read('THIRD_PARTY_NOTICES'),
|
|
5163
|
+
licenseField: typeof m.license === 'string' ? m.license : null,
|
|
5164
|
+
});
|
|
5165
|
+
}
|
|
5166
|
+
facts['licenceHold'] = holds;
|
|
5167
|
+
}
|
|
5168
|
+
catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
|
|
4663
5169
|
try {
|
|
4664
5170
|
facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
|
|
4665
5171
|
}
|
|
@@ -4726,11 +5232,26 @@ function gatherGuardFacts(op, root, text, storeCap) {
|
|
|
4726
5232
|
catch {
|
|
4727
5233
|
facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */
|
|
4728
5234
|
}
|
|
4729
|
-
// change: the working-tree diff
|
|
4730
|
-
//
|
|
4731
|
-
//
|
|
5235
|
+
// change: the working-tree diff — the notion of "changed" shared by PROMOTED (template) rules
|
|
5236
|
+
// and the no-stubs rule. (readme-first gathers its OWN pathspec-scoped porcelain call above and
|
|
5237
|
+
// does not read this fact — so the -uall widening below does not alter readme-first at all;
|
|
5238
|
+
// its collapsed-untracked-dir blind spot for a brand-new package dir is a separate, documented
|
|
5239
|
+
// limit of THAT gatherer.) Without this fact a rule written by
|
|
5240
|
+
// `dz guard promote --apply` would be INERT — present in the config and enforcing nothing.
|
|
5241
|
+
// Contents are read for the globs an active `format-match` rule asks about PLUS the changed
|
|
5242
|
+
// files the no-stubs scan reads (its explicit extension allowlist, capped: a pathological
|
|
5243
|
+
// change-set must not turn a pre-flight into a filesystem sweep — beyond the cap the rule
|
|
5244
|
+
// simply sees no contents for the excess files, the standing fail-open contract).
|
|
4732
5245
|
try {
|
|
4733
|
-
|
|
5246
|
+
// -uall (FN-1): without it, a brand-new DIRECTORY reports as one collapsed `?? newdir/` line
|
|
5247
|
+
// and every file INSIDE it is invisible to the change fact — and a fresh module directory is
|
|
5248
|
+
// the most stub-prone artifact there is (REPRODUCED: newdir/stub.ts with a live marker ⇒
|
|
5249
|
+
// PASS/0 findings). -uall lists the individual files; `.gitignore` semantics are unchanged
|
|
5250
|
+
// (git status never lists ignored paths, -uall or not — tested live). maxBuffer is raised
|
|
5251
|
+
// (default 1MB) because -uall can expand a huge untracked tree into a long listing; KNOWN
|
|
5252
|
+
// LIMIT: a listing beyond even this bound throws, the catch below drops the whole `change`
|
|
5253
|
+
// fact, and no-stubs + every template rule go silently fail-open together for that run.
|
|
5254
|
+
const status = execSync('git status --porcelain -uall', { cwd: root, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
|
|
4734
5255
|
const files = status
|
|
4735
5256
|
.split('\n')
|
|
4736
5257
|
.map((l) => l.slice(3).trim())
|
|
@@ -4739,34 +5260,58 @@ function gatherGuardFacts(op, root, text, storeCap) {
|
|
|
4739
5260
|
const formatGlobs = (Array.isArray(loadGuardConfig(root).rules) ? loadGuardConfig(root).rules : [])
|
|
4740
5261
|
.filter((r) => r?.template === 'format-match' && typeof r?.params?.file === 'string')
|
|
4741
5262
|
.map((r) => r.params.file);
|
|
5263
|
+
const stubScannable = files.filter((f) => scannableStubPath(f));
|
|
5264
|
+
const stubWanted = new Set(stubScannable.slice(0, MAX_STUB_SCAN_FILES));
|
|
5265
|
+
// FN-7: fail-open must not be fail-SILENT. Count every stub-scannable changed file whose
|
|
5266
|
+
// contents we do NOT gather (beyond the cap here; deleted/non-regular/oversize/read-error
|
|
5267
|
+
// below) — the no-stubs rule surfaces the count as ONE aggregate note, never a violation.
|
|
5268
|
+
let stubSkipped = stubScannable.length - stubWanted.size;
|
|
4742
5269
|
const contents = {};
|
|
4743
|
-
if (formatGlobs.length > 0) {
|
|
5270
|
+
if (formatGlobs.length > 0 || stubWanted.size > 0) {
|
|
4744
5271
|
for (const f of files) {
|
|
4745
|
-
|
|
5272
|
+
const wanted = stubWanted.has(f);
|
|
5273
|
+
if (!wanted && !formatGlobs.some((g) => globMatch(g, f)))
|
|
4746
5274
|
continue;
|
|
4747
5275
|
const abs = resolve(root, f);
|
|
4748
5276
|
// Containment: a `git status` path is repo-relative, but `..` in one must never let the
|
|
4749
5277
|
// LIVE reader step outside the repo the HISTORICAL reader is confined to.
|
|
4750
|
-
if (abs !== root && !abs.startsWith(root + sep))
|
|
5278
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
5279
|
+
if (wanted)
|
|
5280
|
+
stubSkipped++;
|
|
4751
5281
|
continue;
|
|
5282
|
+
}
|
|
4752
5283
|
try {
|
|
4753
5284
|
// lstat, NOT stat (Codex QE MED-3). `git show <sha>:<path>` yields the SYMLINK TARGET
|
|
4754
5285
|
// TEXT, never the file it points at, so a live reader that follows links answers a
|
|
4755
5286
|
// different question than the replay — and `/dev/zero` behind a symlink hangs the read.
|
|
4756
5287
|
// Skipping non-regular files restores replay/live equivalence and closes the DoS.
|
|
4757
5288
|
const st = lstatSync(abs);
|
|
4758
|
-
if (!st.isFile())
|
|
5289
|
+
if (!st.isFile()) {
|
|
5290
|
+
if (wanted)
|
|
5291
|
+
stubSkipped++;
|
|
4759
5292
|
continue;
|
|
4760
|
-
|
|
4761
|
-
|
|
5293
|
+
}
|
|
5294
|
+
if (st.size > MAX_CONTENT_BYTES) {
|
|
5295
|
+
if (wanted)
|
|
5296
|
+
stubSkipped++;
|
|
5297
|
+
continue;
|
|
5298
|
+
} // too large to be a spec file — undecidable, never guessed
|
|
4762
5299
|
contents[f] = readFileSync(abs, 'utf8');
|
|
4763
5300
|
}
|
|
4764
|
-
catch {
|
|
5301
|
+
catch {
|
|
5302
|
+
if (wanted)
|
|
5303
|
+
stubSkipped++; /* deleted — leave it undecidable, never guess */
|
|
5304
|
+
}
|
|
4765
5305
|
}
|
|
4766
5306
|
}
|
|
4767
|
-
facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}) };
|
|
5307
|
+
facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}), ...(stubSkipped > 0 ? { stubSkipped } : {}) };
|
|
4768
5308
|
}
|
|
4769
5309
|
catch { /* not a git repo — every template rule stays silent (fail-open) */ }
|
|
5310
|
+
// no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
|
|
5311
|
+
// MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
|
|
5312
|
+
const stubWaivers = loadGuardConfig(root).stubWaivers;
|
|
5313
|
+
if (Array.isArray(stubWaivers))
|
|
5314
|
+
facts['stubWaivers'] = stubWaivers;
|
|
4770
5315
|
}
|
|
4771
5316
|
if (op === 'consolidate') {
|
|
4772
5317
|
try {
|
|
@@ -5240,6 +5785,10 @@ function cmdGuard(options, flags, cwd, write) {
|
|
|
5240
5785
|
const scaffold = {
|
|
5241
5786
|
storeCap: DEFAULT_STORE_CAP,
|
|
5242
5787
|
rules: DEFAULT_RULES.map((r) => ({ id: r.id, severity: r.severity, enabled: true, description: r.description })),
|
|
5788
|
+
// The scaffold QUOTES the stub marker names in the no-stubs rule description, and this file is
|
|
5789
|
+
// itself a scannable changed file the moment it is written — so it carries its own reasoned
|
|
5790
|
+
// waiver (explicit and justified, never a silent path skip).
|
|
5791
|
+
stubWaivers: [{ path: '.dz/guard.json', reason: 'the guard config quotes the stub marker names in the no-stubs rule description' }],
|
|
5243
5792
|
};
|
|
5244
5793
|
mkdirSync(dirname(p), { recursive: true });
|
|
5245
5794
|
writeFileSync(p, JSON.stringify(scaffold, null, 2) + '\n');
|
|
@@ -5297,6 +5846,8 @@ function cmdGuard(options, flags, cwd, write) {
|
|
|
5297
5846
|
write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
|
|
5298
5847
|
for (const v of result.violations)
|
|
5299
5848
|
write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
|
|
5849
|
+
for (const n of result.notes ?? [])
|
|
5850
|
+
write(` [note] ${n}`); // information, never a verdict input (FN-7)
|
|
5300
5851
|
if (result.verdict === 'block' && forced)
|
|
5301
5852
|
write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
|
|
5302
5853
|
else if (result.verdict === 'block')
|
|
@@ -5973,6 +6524,379 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
|
|
|
5973
6524
|
function nameFor(t, outcome) {
|
|
5974
6525
|
return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
|
|
5975
6526
|
}
|
|
6527
|
+
/**
|
|
6528
|
+
* `dz mutation-gate` — the mutation gate (feature ha-mutation-gate, SPEC at
|
|
6529
|
+
* features/ha-mutation-gate/SPEC.md). A green test proves the code works; it does NOT prove the
|
|
6530
|
+
* test would notice the protection being DELETED. For each entry in a declarative registry this
|
|
6531
|
+
* gate applies the entry's exact {find, replace} mutation to a SCRATCH COPY of the package, runs
|
|
6532
|
+
* the package's test command there, and REQUIRES a non-zero exit (red). All logic is in
|
|
6533
|
+
* harness-core's pure engine (mutation-gate.ts); this is the I/O executor.
|
|
6534
|
+
*
|
|
6535
|
+
* --package <dir> the package to gate (default: cwd; must contain package.json)
|
|
6536
|
+
* --registry <file> registry path (default: <pkg>/test/mutation-registry.json, then
|
|
6537
|
+
* <pkg>/mutation-registry.json)
|
|
6538
|
+
* --test-cmd '<cmd>' suite command run in the scratch copy (default: registry.testCommand,
|
|
6539
|
+
* then `npm test`)
|
|
6540
|
+
* --only <id[,id]> run a subset of entries (unknown id = usage error, never a silent skip)
|
|
6541
|
+
* --timeout <ms> per-suite-run timeout (default 300000). A timed-out run is INCONCLUSIVE —
|
|
6542
|
+
* a FAILURE, never a pass.
|
|
6543
|
+
* --rebaseline <m> route-b guard mode: 'per-entry' (default — every red entry re-runs the
|
|
6544
|
+
* suite on the restored tree; not green ⇒ that entry is INCONCLUSIVE) or
|
|
6545
|
+
* 'final' (one re-run at the end; not green ⇒ every red-based verdict is
|
|
6546
|
+
* downgraded). The gate's PROVEN now means the redness was ATTRIBUTABLE.
|
|
6547
|
+
* --keep-scratch keep the scratch copy for inspection (default: removed in a finally)
|
|
6548
|
+
* --json machine contract {packageDir, registryPath, testCommand, rebaselineMode,
|
|
6549
|
+
* baseline, results, summary, warnings, exitCode}
|
|
6550
|
+
*
|
|
6551
|
+
* The four rules (SPEC §"Four rules") and where each is enforced:
|
|
6552
|
+
* 1. does-not-apply = FAILURE → core classifyMutationOutcome (occurrences !== 1 ⇒ NOT_APPLIED);
|
|
6553
|
+
* 2. green suite = FAILURE → core (exit 0 ⇒ UNDEFENDED, names the property);
|
|
6554
|
+
* 3. never mutate the working tree → HERE: every write targets the scratch copy under tmpdir();
|
|
6555
|
+
* the repo tree is opened read-only, and a crashed run leaves at worst a stale tmp dir;
|
|
6556
|
+
* 4. the gate's own discrimination proof → harness-cli/test/fixtures/mutation-gate-undefended
|
|
6557
|
+
* (the gate MUST fail on it; asserted by test/mutation-gate-cli.test.ts).
|
|
6558
|
+
*
|
|
6559
|
+
* Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
|
|
6560
|
+
* below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
|
|
6561
|
+
* error (missing registry, red BASELINE — a red unmutated copy proves nothing and must not be
|
|
6562
|
+
* read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
|
|
6563
|
+
* symlink escape is refused before anything is written, SPEC rule 3).
|
|
6564
|
+
*/
|
|
6565
|
+
/**
|
|
6566
|
+
* Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
|
|
6567
|
+
* suite runs. A registry mutation must delete the protection while keeping the file loadable — a
|
|
6568
|
+
* file that no longer parses kills the whole suite (or its import chain), and that STRUCTURAL
|
|
6569
|
+
* redness says nothing about the named protection. Returns `{error}` when a parser ran and the
|
|
6570
|
+
* text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
|
|
6571
|
+
*/
|
|
6572
|
+
function parseCheckMutatedFile(absFile, text) {
|
|
6573
|
+
const ext = extname(absFile).toLowerCase();
|
|
6574
|
+
try {
|
|
6575
|
+
if (ext === '.ts' || ext === '.tsx' || ext === '.mts' || ext === '.cts') {
|
|
6576
|
+
let ts = null;
|
|
6577
|
+
for (const from of [absFile, import.meta.url]) {
|
|
6578
|
+
try {
|
|
6579
|
+
ts = createRequire(from)('typescript');
|
|
6580
|
+
break;
|
|
6581
|
+
}
|
|
6582
|
+
catch { /* try the next resolution root */ }
|
|
6583
|
+
}
|
|
6584
|
+
if (ts === null)
|
|
6585
|
+
return { skipped: 'no TypeScript parser resolvable (typescript installed neither near the package nor near the CLI)' };
|
|
6586
|
+
const out = ts.transpileModule(text, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.Latest } });
|
|
6587
|
+
const first = (out.diagnostics ?? []).find((d) => d.category === ts.DiagnosticCategory.Error);
|
|
6588
|
+
if (first === undefined)
|
|
6589
|
+
return {};
|
|
6590
|
+
return { error: `TS${first.code}: ${ts.flattenDiagnosticMessageText(first.messageText, ' ')}` };
|
|
6591
|
+
}
|
|
6592
|
+
if (ext === '.json') {
|
|
6593
|
+
try {
|
|
6594
|
+
JSON.parse(text);
|
|
6595
|
+
return {};
|
|
6596
|
+
}
|
|
6597
|
+
catch (e) {
|
|
6598
|
+
return { error: String(e.message).slice(0, 200) };
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
|
|
6602
|
+
try {
|
|
6603
|
+
// `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
|
|
6604
|
+
execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
|
|
6605
|
+
return {};
|
|
6606
|
+
}
|
|
6607
|
+
catch (e) {
|
|
6608
|
+
const err = e;
|
|
6609
|
+
const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
6610
|
+
// prefer the actual `SyntaxError: …` line over node's trailing version footer.
|
|
6611
|
+
const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
|
|
6612
|
+
return { error: msg.slice(0, 200) };
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
|
|
6616
|
+
}
|
|
6617
|
+
catch (e) {
|
|
6618
|
+
return { skipped: `parse-check errored: ${String(e.message).slice(0, 120)}` };
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6621
|
+
function cmdMutationGate(options, flags, cwd, write) {
|
|
6622
|
+
const json = flags.has('json');
|
|
6623
|
+
const fail = (what) => {
|
|
6624
|
+
write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
|
|
6625
|
+
return 2;
|
|
6626
|
+
};
|
|
6627
|
+
const pkgDir = resolve(cwd, options.get('package') ?? '.');
|
|
6628
|
+
if (!existsSync(join(pkgDir, 'package.json'))) {
|
|
6629
|
+
return fail(`no package.json at ${pkgDir} — pass --package <dir>`);
|
|
6630
|
+
}
|
|
6631
|
+
const registryOpt = options.get('registry');
|
|
6632
|
+
const registryPath = registryOpt !== undefined
|
|
6633
|
+
? resolve(cwd, registryOpt)
|
|
6634
|
+
: [join(pkgDir, 'test', 'mutation-registry.json'), join(pkgDir, 'mutation-registry.json')].find((p) => existsSync(p));
|
|
6635
|
+
if (registryPath === undefined || !existsSync(registryPath)) {
|
|
6636
|
+
return fail(`no mutation registry found (looked for test/mutation-registry.json and mutation-registry.json under ${pkgDir}) — pass --registry <file>`);
|
|
6637
|
+
}
|
|
6638
|
+
const parsed = parseMutationRegistry(readFileSync(registryPath, 'utf-8'));
|
|
6639
|
+
if (parsed.registry === null) {
|
|
6640
|
+
return fail(`registry ${registryPath} is invalid:\n - ${parsed.errors.join('\n - ')}`);
|
|
6641
|
+
}
|
|
6642
|
+
let entries = parsed.registry.entries;
|
|
6643
|
+
const only = options.get('only');
|
|
6644
|
+
if (only !== undefined) {
|
|
6645
|
+
const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
|
|
6646
|
+
const known = new Set(entries.map((e) => e.id));
|
|
6647
|
+
const unknown = ids.filter((id) => !known.has(id));
|
|
6648
|
+
if (unknown.length > 0)
|
|
6649
|
+
return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
|
|
6650
|
+
entries = entries.filter((e) => ids.includes(e.id));
|
|
6651
|
+
}
|
|
6652
|
+
const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
|
|
6653
|
+
if (/[\0\n\r]/.test(testCmdRaw))
|
|
6654
|
+
return fail('--test-cmd may not contain NUL or newline characters');
|
|
6655
|
+
const testCmd = testCmdRaw;
|
|
6656
|
+
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
6657
|
+
const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
6658
|
+
// Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
|
|
6659
|
+
// tree, so a flaky neighbour flips THAT entry to INCONCLUSIVE) or `final` (cheap — one re-run at
|
|
6660
|
+
// the end; if it is not green, every red-based verdict of the run is downgraded, because any of
|
|
6661
|
+
// them may have been the flake). MEASURED on the 18-entry health-advisor registry (~15s/suite
|
|
6662
|
+
// run): per-entry ≈ 37 runs, final ≈ 20 runs vs 19 pre-fix. An unknown mode is a usage error.
|
|
6663
|
+
const rebaselineMode = options.get('rebaseline') ?? 'per-entry';
|
|
6664
|
+
if (rebaselineMode !== 'per-entry' && rebaselineMode !== 'final') {
|
|
6665
|
+
return fail(`--rebaseline must be 'per-entry' or 'final', got '${rebaselineMode}'`);
|
|
6666
|
+
}
|
|
6667
|
+
// Rule 3 — NEVER mutate the working tree: the package is copied into a scratch dir we own and
|
|
6668
|
+
// mutated THERE. The copy must actually be RUNNABLE (SPEC rule 3's note), which took three
|
|
6669
|
+
// measured layers on the seed package:
|
|
6670
|
+
// • the package's own node_modules is symlinked back (absolute), so deps + .bin resolve;
|
|
6671
|
+
// • the copy lives inside a SHADOW of the package's repo — every ancestor level mirrors the
|
|
6672
|
+
// real one with SYMLINKED siblings (root node_modules for hoisted deps, sibling packages
|
|
6673
|
+
// for repo-relative test paths like `../../harness-core/dist`); only the package under test
|
|
6674
|
+
// is a real, mutable copy (MEASURED: without this, 30 health-advisor tests failed at
|
|
6675
|
+
// baseline on ERR_MODULE_NOT_FOUND / a missing sibling dist);
|
|
6676
|
+
// • the copy is `git init`-ed and committed, because hygiene tests take `git status` before
|
|
6677
|
+
// and after the run — they compare before WITH after, so a pre-mutation commit keeps them
|
|
6678
|
+
// discriminating (MEASURED: without it, 2 tests failed at baseline on "not a git repository").
|
|
6679
|
+
const scratchParent = mkdtempSync(join(tmpdir(), 'dz-mutgate-'));
|
|
6680
|
+
let gitTop = null;
|
|
6681
|
+
try {
|
|
6682
|
+
gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
|
|
6683
|
+
}
|
|
6684
|
+
catch { /* not in a git repo */ }
|
|
6685
|
+
let copyDir = join(scratchParent, 'pkg');
|
|
6686
|
+
const results = [];
|
|
6687
|
+
const observations = [];
|
|
6688
|
+
const warnings = [];
|
|
6689
|
+
let baseline;
|
|
6690
|
+
try {
|
|
6691
|
+
if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
|
|
6692
|
+
// shadow tree: mirror <gitTop>/…/<pkg> under scratch, symlinking every sibling entry.
|
|
6693
|
+
let realCursor = gitTop;
|
|
6694
|
+
let shadowCursor = join(scratchParent, 'root');
|
|
6695
|
+
mkdirSync(shadowCursor, { recursive: true });
|
|
6696
|
+
const segs = relative(gitTop, pkgDir).split(sep);
|
|
6697
|
+
segs.forEach((seg, i) => {
|
|
6698
|
+
for (const entry of readdirSync(realCursor)) {
|
|
6699
|
+
if (entry === seg || entry === '.git')
|
|
6700
|
+
continue;
|
|
6701
|
+
try {
|
|
6702
|
+
symlinkSync(join(realCursor, entry), join(shadowCursor, entry));
|
|
6703
|
+
}
|
|
6704
|
+
catch { /* best effort */ }
|
|
6705
|
+
}
|
|
6706
|
+
realCursor = join(realCursor, seg);
|
|
6707
|
+
shadowCursor = join(shadowCursor, seg);
|
|
6708
|
+
if (i < segs.length - 1)
|
|
6709
|
+
mkdirSync(shadowCursor, { recursive: true });
|
|
6710
|
+
});
|
|
6711
|
+
copyDir = shadowCursor;
|
|
6712
|
+
}
|
|
6713
|
+
cpSync(pkgDir, copyDir, {
|
|
6714
|
+
recursive: true,
|
|
6715
|
+
filter: (src) => {
|
|
6716
|
+
const rel = relative(pkgDir, src);
|
|
6717
|
+
return rel === '' || !rel.split(sep).some((seg) => seg === 'node_modules' || seg === '.git');
|
|
6718
|
+
},
|
|
6719
|
+
});
|
|
6720
|
+
const srcNm = join(pkgDir, 'node_modules');
|
|
6721
|
+
if (existsSync(srcNm) && !existsSync(join(copyDir, 'node_modules'))) {
|
|
6722
|
+
symlinkSync(srcNm, join(copyDir, 'node_modules'), 'dir');
|
|
6723
|
+
}
|
|
6724
|
+
try {
|
|
6725
|
+
execSync('git init -q && git add -A -f . && git -c user.email=mutation-gate@dz -c user.name=mutation-gate -c commit.gpgsign=false commit -qm scratch-baseline', { cwd: copyDir, stdio: 'pipe' });
|
|
6726
|
+
}
|
|
6727
|
+
catch { /* no git available → a suite that needs it fails the BASELINE loudly, never silently */ }
|
|
6728
|
+
// F-2 — rule-3 containment root: the scratch copy AS THE FILESYSTEM sees it. Every mutation
|
|
6729
|
+
// write below is asserted to RESOLVE inside this root before it happens.
|
|
6730
|
+
const realScratchRoot = realpathSync(copyDir);
|
|
6731
|
+
const runSuite = () => {
|
|
6732
|
+
try {
|
|
6733
|
+
const out = execSync(testCmd, { cwd: copyDir, stdio: 'pipe', encoding: 'utf-8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, FORCE_COLOR: '0' } });
|
|
6734
|
+
return { exitCode: 0, output: out };
|
|
6735
|
+
}
|
|
6736
|
+
catch (e) {
|
|
6737
|
+
const err = e;
|
|
6738
|
+
return {
|
|
6739
|
+
exitCode: typeof err.status === 'number' ? err.status : null,
|
|
6740
|
+
output: `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`,
|
|
6741
|
+
};
|
|
6742
|
+
}
|
|
6743
|
+
};
|
|
6744
|
+
// Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
|
|
6745
|
+
// result would be this gate shipping the defect class it exists to catch.
|
|
6746
|
+
if (!json)
|
|
6747
|
+
write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
6748
|
+
const base = runSuite();
|
|
6749
|
+
baseline = classifyBaseline(base.exitCode);
|
|
6750
|
+
if (!baseline.ok) {
|
|
6751
|
+
if (json) {
|
|
6752
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2));
|
|
6753
|
+
return 2;
|
|
6754
|
+
}
|
|
6755
|
+
write(renderMutationReport([], baseline, pkgDir));
|
|
6756
|
+
return 2;
|
|
6757
|
+
}
|
|
6758
|
+
for (const entry of entries) {
|
|
6759
|
+
const filePath = join(copyDir, entry.file);
|
|
6760
|
+
let sourceText = null;
|
|
6761
|
+
try {
|
|
6762
|
+
sourceText = readFileSync(filePath, 'utf-8');
|
|
6763
|
+
}
|
|
6764
|
+
catch { /* missing file ⇒ occurrences 0 ⇒ NOT_APPLIED */ }
|
|
6765
|
+
if (sourceText === null) {
|
|
6766
|
+
const obs = { entry, occurrences: 0, exitCode: null, failingCount: null };
|
|
6767
|
+
observations.push(obs);
|
|
6768
|
+
results.push(classifyMutationOutcome(obs));
|
|
6769
|
+
continue;
|
|
6770
|
+
}
|
|
6771
|
+
const applied = applyMutationToText(sourceText, entry.mutation.find, entry.mutation.replace);
|
|
6772
|
+
if (!applied.ok || applied.text === undefined) {
|
|
6773
|
+
const obs = { entry, occurrences: applied.occurrences, exitCode: null, failingCount: null };
|
|
6774
|
+
observations.push(obs);
|
|
6775
|
+
results.push(classifyMutationOutcome(obs));
|
|
6776
|
+
continue;
|
|
6777
|
+
}
|
|
6778
|
+
// F-2 — rule-3 containment (SPEC "Never mutate the working tree"): `join(copyDir, file)` is
|
|
6779
|
+
// LEXICAL; a symlink cpSync preserved inside the package (or the intentionally symlinked
|
|
6780
|
+
// node_modules) makes it RESOLVE outside the scratch tree, and the "scratch" write would
|
|
6781
|
+
// follow the link and mutate the REAL working tree for the whole suite run — restored only
|
|
6782
|
+
// by the finally, so a SIGKILL mid-run leaves the real tree permanently mutated (MEASURED
|
|
6783
|
+
// pre-fix: a registry file behind a package-local symlink; the suite-run witness read the
|
|
6784
|
+
// mutated text from the REAL file). Same primitive as health-advisor lock.js's
|
|
6785
|
+
// realCaseDir/assertLockRootIsItself: decide on realpaths, refuse an escape — exit 2, a
|
|
6786
|
+
// registry/setup error, never a mutation.
|
|
6787
|
+
let realTarget = null;
|
|
6788
|
+
try {
|
|
6789
|
+
realTarget = realpathSync(filePath);
|
|
6790
|
+
}
|
|
6791
|
+
catch { /* vanished between read and here → refuse below */ }
|
|
6792
|
+
if (realTarget === null || (realTarget !== realScratchRoot && !realTarget.startsWith(realScratchRoot + sep))) {
|
|
6793
|
+
return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
|
|
6794
|
+
}
|
|
6795
|
+
if (!json)
|
|
6796
|
+
write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
|
|
6797
|
+
let run = null;
|
|
6798
|
+
let parseError;
|
|
6799
|
+
try {
|
|
6800
|
+
writeFileSync(filePath, applied.text);
|
|
6801
|
+
// Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
|
|
6802
|
+
// suite for structural, not behavioural, reasons, and must never read as discrimination.
|
|
6803
|
+
const check = parseCheckMutatedFile(filePath, applied.text);
|
|
6804
|
+
if (check.skipped !== undefined) {
|
|
6805
|
+
warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
|
|
6806
|
+
if (!json)
|
|
6807
|
+
write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
|
|
6808
|
+
}
|
|
6809
|
+
if (check.error !== undefined) {
|
|
6810
|
+
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
6811
|
+
}
|
|
6812
|
+
else {
|
|
6813
|
+
run = runSuite();
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
finally {
|
|
6817
|
+
writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
|
|
6818
|
+
}
|
|
6819
|
+
// Route-a′ guard (round-6 rework): the file-load-vs-assertion signal is derived from THE
|
|
6820
|
+
// SAME RUN that produced the failing count — no isolated child, no environment mismatch,
|
|
6821
|
+
// nothing to disagree with itself (the round-5 isolated `import()` had three measured
|
|
6822
|
+
// false-PASS routes, all artifacts of the isolation environment differing from the runner).
|
|
6823
|
+
// 'file-load' ⇒ MUTATION_LOAD_FATAL (structural); 'unrecognised' ⇒ INCONCLUSIVE (a
|
|
6824
|
+
// runner-coverage gap of this tool, loud, never PROVEN); 'assertions' ⇒ behavioural, the
|
|
6825
|
+
// count-based verdicts apply.
|
|
6826
|
+
let fileLoadFailure;
|
|
6827
|
+
let outputUnrecognised;
|
|
6828
|
+
if (run !== null && run.exitCode !== null && run.exitCode !== 0) {
|
|
6829
|
+
const cls = classifyRunFailure(run.output);
|
|
6830
|
+
if (cls.kind === 'file-load') {
|
|
6831
|
+
fileLoadFailure = cls.evidence ?? 'test file failed to load (no evidence line)';
|
|
6832
|
+
}
|
|
6833
|
+
else if (cls.kind === 'unrecognised') {
|
|
6834
|
+
outputUnrecognised = cls.evidence ?? `no classifier for runner '${cls.runner}'`;
|
|
6835
|
+
}
|
|
6836
|
+
}
|
|
6837
|
+
// Route-b guard (per-entry mode): a red mutated run is attributable only if the RESTORED
|
|
6838
|
+
// tree reproduces green — otherwise a flaky neighbour may be what went red. Skipped when the
|
|
6839
|
+
// classification already failed the entry structurally (file-load / unrecognised): those
|
|
6840
|
+
// verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
6841
|
+
let rebaselineExitCode;
|
|
6842
|
+
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
6843
|
+
&& fileLoadFailure === undefined && outputUnrecognised === undefined) {
|
|
6844
|
+
if (!json)
|
|
6845
|
+
write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
|
|
6846
|
+
rebaselineExitCode = runSuite().exitCode;
|
|
6847
|
+
}
|
|
6848
|
+
const obs = {
|
|
6849
|
+
entry,
|
|
6850
|
+
occurrences: 1,
|
|
6851
|
+
exitCode: run === null ? null : run.exitCode,
|
|
6852
|
+
failingCount: run === null ? null : countFailingTests(run.output),
|
|
6853
|
+
...(parseError !== undefined ? { parseError } : {}),
|
|
6854
|
+
...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
|
|
6855
|
+
...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
|
|
6856
|
+
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
6857
|
+
};
|
|
6858
|
+
observations.push(obs);
|
|
6859
|
+
results.push(classifyMutationOutcome(obs));
|
|
6860
|
+
}
|
|
6861
|
+
// Route-b guard (final mode): one re-run after all entries. Not green ⇒ EVERY red-based
|
|
6862
|
+
// verdict of this run is downgraded (any of them may have been the flake, and there is no
|
|
6863
|
+
// per-entry evidence to say which) — re-classifying with the final exit turns them
|
|
6864
|
+
// INCONCLUSIVE while leaving NOT_APPLIED / UNDEFENDED / MUTATION_UNPARSEABLE /
|
|
6865
|
+
// MUTATION_LOAD_FATAL untouched.
|
|
6866
|
+
if (rebaselineMode === 'final') {
|
|
6867
|
+
if (!json)
|
|
6868
|
+
write('mutation-gate: final re-baseline of the restored tree …');
|
|
6869
|
+
const finalExit = runSuite().exitCode;
|
|
6870
|
+
if (finalExit !== 0) {
|
|
6871
|
+
const what = finalExit === null ? 'no exit code' : `exit ${finalExit}`;
|
|
6872
|
+
warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
|
|
6873
|
+
if (!json)
|
|
6874
|
+
write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
|
|
6875
|
+
const reclassified = observations.map((obs) => classifyMutationOutcome({ ...obs, rebaselineExitCode: finalExit }));
|
|
6876
|
+
results.length = 0;
|
|
6877
|
+
results.push(...reclassified);
|
|
6878
|
+
}
|
|
6879
|
+
}
|
|
6880
|
+
}
|
|
6881
|
+
finally {
|
|
6882
|
+
if (flags.has('keep-scratch')) {
|
|
6883
|
+
write(`mutation-gate: scratch copy kept at ${copyDir}`);
|
|
6884
|
+
}
|
|
6885
|
+
else {
|
|
6886
|
+
try {
|
|
6887
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
6888
|
+
}
|
|
6889
|
+
catch { /* best effort */ }
|
|
6890
|
+
}
|
|
6891
|
+
}
|
|
6892
|
+
const exitCode = mutationGateExitCode(results, baseline.ok);
|
|
6893
|
+
if (json) {
|
|
6894
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
|
|
6895
|
+
return exitCode;
|
|
6896
|
+
}
|
|
6897
|
+
write(renderMutationReport(results, baseline, pkgDir));
|
|
6898
|
+
return exitCode;
|
|
6899
|
+
}
|
|
5976
6900
|
/**
|
|
5977
6901
|
* `dz delivery-check` — the portable Step-10 Delivery Gate (feature portable-gates). The `manual` form that
|
|
5978
6902
|
* travels to every `shell` target: the deterministic parts (artifact probes, hand-off arithmetic,
|
|
@@ -7090,6 +8014,23 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7090
8014
|
if (eff.adjusted && !json && eff.note !== undefined)
|
|
7091
8015
|
write(`dz backlog: ${eff.note}`);
|
|
7092
8016
|
const dryRun = flags.has('dry-run');
|
|
8017
|
+
// Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
|
|
8018
|
+
// bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
|
|
8019
|
+
// (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
|
|
8020
|
+
if (dryRun) {
|
|
8021
|
+
if (readBacklogEmbedFormVersion(projectRoot) < DEDUP_EMBED_FORM_VERSION && readIdeas(projectRoot).length > 0 && !json) {
|
|
8022
|
+
write(`dz backlog: ⚠ idea vectors are in the old (full-text) embed form — dedup may be unreliable until a non-dry add or \`dz backlog harmonize\` migrates them`);
|
|
8023
|
+
}
|
|
8024
|
+
}
|
|
8025
|
+
else {
|
|
8026
|
+
const form = await ensureBacklogEmbedForm(projectRoot);
|
|
8027
|
+
if (form.action === 'migrated' && !json) {
|
|
8028
|
+
write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
|
|
8029
|
+
}
|
|
8030
|
+
else if (form.action === 'deferred' && !json) {
|
|
8031
|
+
write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'}) — semantic dedup may compare against stale full-text vectors`);
|
|
8032
|
+
}
|
|
8033
|
+
}
|
|
7093
8034
|
const verdict = await dedupIdea(projectRoot, text, cfg);
|
|
7094
8035
|
// The TOP-MATCH pair (id @ cosine) is the ADR-002 calibration surface (idea ce914ac2) — observational
|
|
7095
8036
|
// only: the band itself is unchanged, but a RELATED verdict now shows WHICH idea produced the cosine.
|
|
@@ -7102,19 +8043,38 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7102
8043
|
// DUPLICATE ⇒ snapshot + reinforce the existing root; NO new record (ADR-002 T-002b).
|
|
7103
8044
|
const ideas = readIdeas(projectRoot);
|
|
7104
8045
|
const match = ideas.find((i) => i.id === verdict.matchedId);
|
|
8046
|
+
let absorbErr;
|
|
7105
8047
|
if (!dryRun && match !== undefined) {
|
|
7106
8048
|
const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
|
|
7107
8049
|
if (snap.error !== undefined)
|
|
7108
8050
|
return emitErr(snap.error);
|
|
8051
|
+
// The absorbed TEXT is preserved (absorbed.jsonl) — a duplicate verdict must never destroy
|
|
8052
|
+
// user text: two documented false absorptions (2026-08-05, 2026-08-11) were unrecoverable.
|
|
8053
|
+
absorbErr = recordAbsorption(projectRoot, {
|
|
8054
|
+
ts: new Date().toISOString(),
|
|
8055
|
+
matchedId: match.id,
|
|
8056
|
+
cosine: verdict.cosine,
|
|
8057
|
+
...(verdict.containment !== undefined ? { containment: verdict.containment } : {}),
|
|
8058
|
+
...(verdict.subsetMatch === true ? { subsetMatch: true } : {}),
|
|
8059
|
+
text,
|
|
8060
|
+
}).error;
|
|
7109
8061
|
match.uses += 1;
|
|
7110
8062
|
writeIdeas(projectRoot, ideas);
|
|
7111
8063
|
}
|
|
7112
8064
|
if (json)
|
|
7113
|
-
write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
|
|
8065
|
+
write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}), ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), ...(dryRun ? {} : { absorbedLogged: absorbErr === undefined, ...(absorbErr !== undefined ? { absorbedLogError: absorbErr } : {}) }), exitCode: 0 }, null, 2));
|
|
7114
8066
|
else {
|
|
7115
|
-
|
|
8067
|
+
const via = verdict.subsetMatch === true
|
|
8068
|
+
? `subset match: containment ${(verdict.containment ?? 0).toFixed(3)} ≥ ${cfg.dedup.subsetContainment}, cosine ${verdict.cosine.toFixed(3)}`
|
|
8069
|
+
: `cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}`;
|
|
8070
|
+
write(`dz backlog: DUPLICATE of ${verdict.matchedId} (${via}) — reinforced, no new record`);
|
|
7116
8071
|
if (topMatch !== undefined)
|
|
7117
8072
|
write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
|
|
8073
|
+
if (!dryRun) {
|
|
8074
|
+
write(absorbErr === undefined
|
|
8075
|
+
? ' absorbed text kept in .dz/backlog/absorbed.jsonl (re-add it from there if this verdict was wrong)'
|
|
8076
|
+
: ` ⚠ could NOT log the absorbed text (${absorbErr}) — if this verdict is wrong, the wording above is the only copy`);
|
|
8077
|
+
}
|
|
7118
8078
|
}
|
|
7119
8079
|
return 0;
|
|
7120
8080
|
}
|
|
@@ -7136,15 +8096,22 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7136
8096
|
const proposal = options.get('proposal');
|
|
7137
8097
|
if (proposal !== undefined)
|
|
7138
8098
|
rec.proposal = proposal; // agent prose ONLY — the CLI never fabricates it
|
|
8099
|
+
// A demotion (≥-threshold cosine that failed lexical corroboration) is NEVER silent — it is the
|
|
8100
|
+
// register-only false-duplicate surface (the 2026-08-05 zombie x publish-gate absorption).
|
|
8101
|
+
const demotedLine = verdict.demoted !== undefined
|
|
8102
|
+
? ` near-duplicate demoted: ${verdict.demoted.id} @ cosine ${verdict.demoted.cosine.toFixed(3)} cleared the band but shares no subject vocabulary (containment ${verdict.demoted.containment.toFixed(3)} < ${cfg.dedup.corroborationFloor}) — kept as related`
|
|
8103
|
+
: undefined;
|
|
7139
8104
|
if (dryRun) {
|
|
7140
8105
|
if (json)
|
|
7141
|
-
write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
|
|
8106
|
+
write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
|
|
7142
8107
|
else {
|
|
7143
8108
|
write(`dz backlog (dry-run): ${verdict.action.toUpperCase()} — would create ${rec.id}; align ${rec.goalAlignment.toFixed(3)}${rec.goalId !== null ? ` → ${rec.goalId}` : ''}`);
|
|
7144
8109
|
// The calibration surface belongs on the dry-run too (QE LOW-8) — a dry-run is exactly where a
|
|
7145
8110
|
// user checks whether a near-duplicate should have crossed the band.
|
|
7146
8111
|
if (topMatch !== undefined)
|
|
7147
8112
|
write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
|
|
8113
|
+
if (demotedLine !== undefined)
|
|
8114
|
+
write(demotedLine);
|
|
7148
8115
|
}
|
|
7149
8116
|
return 0;
|
|
7150
8117
|
}
|
|
@@ -7156,12 +8123,14 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7156
8123
|
writeIdeas(projectRoot, ideas);
|
|
7157
8124
|
const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
|
|
7158
8125
|
if (json)
|
|
7159
|
-
write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
|
|
8126
|
+
write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
|
|
7160
8127
|
else {
|
|
7161
8128
|
write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
|
|
7162
8129
|
if (verdict.action === 'related' && topMatch !== undefined) {
|
|
7163
8130
|
write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
|
|
7164
8131
|
}
|
|
8132
|
+
if (demotedLine !== undefined)
|
|
8133
|
+
write(demotedLine);
|
|
7165
8134
|
if (rec.goalId !== null)
|
|
7166
8135
|
write(` top goal: ${rec.goalId} (alignment ${rec.goalAlignment.toFixed(3)})`);
|
|
7167
8136
|
if (verdict.relatedIds.length > 0)
|
|
@@ -7365,6 +8334,43 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7365
8334
|
}
|
|
7366
8335
|
return 0;
|
|
7367
8336
|
}
|
|
8337
|
+
// ── ship | drop | reopen — the status-transition surface (the missing verb that let the roulette
|
|
8338
|
+
// keep re-drawing already-shipped work: without it, work finished WITHOUT `roulette --commit` —
|
|
8339
|
+
// the normal flow — stayed `new` forever). ALL logic lives in transitionIdeas (harness-core):
|
|
8340
|
+
// short-prefix resolution (unique or a loud error), the IDEA_TRANSITIONS legality table,
|
|
8341
|
+
// idempotent no-ops, all-or-nothing fail-closed batches, line-preserving atomic writes.
|
|
8342
|
+
if (sub === 'ship' || sub === 'drop' || sub === 'reopen') {
|
|
8343
|
+
const prefixes = [];
|
|
8344
|
+
for (let i = 1;; i += 1) {
|
|
8345
|
+
const p = options.get(`_positional_${i}`);
|
|
8346
|
+
if (p === undefined)
|
|
8347
|
+
break;
|
|
8348
|
+
prefixes.push(p);
|
|
8349
|
+
}
|
|
8350
|
+
if (prefixes.length === 0)
|
|
8351
|
+
return emitErr(`an idea id is required: dz backlog ${sub} <id> [<id>…]`);
|
|
8352
|
+
const report = transitionIdeas(projectRoot, sub, prefixes, {
|
|
8353
|
+
...(options.get('reason') !== undefined ? { reason: options.get('reason') } : {}),
|
|
8354
|
+
dryRun: flags.has('dry-run'),
|
|
8355
|
+
});
|
|
8356
|
+
if (json) {
|
|
8357
|
+
write(JSON.stringify({ verb: sub, ...report, exitCode: report.ok ? 0 : 1 }, null, 2));
|
|
8358
|
+
return report.ok ? 0 : 1;
|
|
8359
|
+
}
|
|
8360
|
+
for (const e of report.errors)
|
|
8361
|
+
write(`dz backlog ${sub}: ${e}`);
|
|
8362
|
+
for (const c of report.changes) {
|
|
8363
|
+
if (c.action === 'noop')
|
|
8364
|
+
write(`dz backlog ${sub}: ${c.id} is already ${c.to} — no-op`);
|
|
8365
|
+
else
|
|
8366
|
+
write(`dz backlog ${sub}${report.dryRun ? ' (dry-run)' : ''}: ${c.id} ${c.from} → ${c.to} ${c.text}`);
|
|
8367
|
+
}
|
|
8368
|
+
if (!report.ok)
|
|
8369
|
+
write(` nothing was written (all-or-nothing: fix the batch and re-run)`);
|
|
8370
|
+
else if (report.dryRun && report.changes.some((c) => c.action === 'transitioned'))
|
|
8371
|
+
write(' (dry-run — nothing written; re-run without --dry-run to apply)');
|
|
8372
|
+
return report.ok ? 0 : 1;
|
|
8373
|
+
}
|
|
7368
8374
|
if (sub === 'enrich') {
|
|
7369
8375
|
const id = options.get('_positional_1');
|
|
7370
8376
|
if (id === undefined)
|
|
@@ -7415,6 +8421,13 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7415
8421
|
if (sub === 'harmonize') {
|
|
7416
8422
|
const apply = flags.has('apply');
|
|
7417
8423
|
const thr = options.get('threshold');
|
|
8424
|
+
// Harmonize is the batch maintenance surface — migrate the mirrored vectors to the current
|
|
8425
|
+
// bounded embed form here too (idempotent; a deferral is warned, never fatal).
|
|
8426
|
+
const form = await ensureBacklogEmbedForm(projectRoot);
|
|
8427
|
+
if (form.action === 'migrated' && !json)
|
|
8428
|
+
write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
|
|
8429
|
+
else if (form.action === 'deferred' && !json)
|
|
8430
|
+
write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
|
|
7418
8431
|
const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
|
|
7419
8432
|
if (json) {
|
|
7420
8433
|
write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
|
|
@@ -7438,6 +8451,9 @@ async function cmdBacklog(options, flags, cwd, write) {
|
|
|
7438
8451
|
write(' show <id> full record');
|
|
7439
8452
|
write(' goals [--validate] the compass (.dz/backlog/goals.json)');
|
|
7440
8453
|
write(' roulette [--pick N][--seed n][--commit <id>] weighted draw; --commit takes the id you saw');
|
|
8454
|
+
write(' ship <id> [<id>…] [--reason <t>][--dry-run] mark done (new|enriched|in-progress → shipped) — run it after finishing a task, or the roulette re-draws it forever');
|
|
8455
|
+
write(' drop <id> [<id>…] [--reason <t>][--dry-run] retire an idea (→ dropped)');
|
|
8456
|
+
write(' reopen <id> [<id>…] [--reason <t>][--dry-run] back to the pool (shipped|dropped|in-progress → new)');
|
|
7441
8457
|
write(' enrich <id> stage the idea2prd hand-off (agent expands)');
|
|
7442
8458
|
write(` jira <id> draft a Jira issue (adapter: ${[...BACKLOG_BACKENDS].join('|')})`);
|
|
7443
8459
|
write(' harmonize [--apply][--threshold 0-1] batch semantic dedup of the backlog');
|
|
@@ -7796,12 +8812,16 @@ export async function runCli(argv, io = {}) {
|
|
|
7796
8812
|
return await cmdScout(options, flags, cwd, write);
|
|
7797
8813
|
case 'workflow':
|
|
7798
8814
|
return cmdWorkflow(options, flags, cwd, write);
|
|
8815
|
+
case 'workflow-lint':
|
|
8816
|
+
return cmdWorkflowLint(options, flags, cwd, write);
|
|
8817
|
+
case 'workflow-trace':
|
|
8818
|
+
return cmdWorkflowTrace(options, flags, cwd, write);
|
|
7799
8819
|
case 'migrate':
|
|
7800
8820
|
return cmdMigrate(options, cwd, write);
|
|
7801
8821
|
case 'doctor':
|
|
7802
8822
|
return await cmdDoctor(options, flags, cwd, write);
|
|
7803
8823
|
case 'install':
|
|
7804
|
-
return await cmdInstall(options, flags, cwd, write);
|
|
8824
|
+
return await cmdInstall(options, flags, cwd, write, io.installRunner);
|
|
7805
8825
|
case 'bundle':
|
|
7806
8826
|
return cmdBundle(options, flags, cwd, write);
|
|
7807
8827
|
case 'teach':
|
|
@@ -7880,6 +8900,8 @@ export async function runCli(argv, io = {}) {
|
|
|
7880
8900
|
return cmdChallenge(options, flags, cwd, write);
|
|
7881
8901
|
case 'discrimination-check':
|
|
7882
8902
|
return cmdDiscriminationCheck(options, flags, cwd, write);
|
|
8903
|
+
case 'mutation-gate':
|
|
8904
|
+
return cmdMutationGate(options, flags, cwd, write);
|
|
7883
8905
|
case 'delivery-check':
|
|
7884
8906
|
return cmdDeliveryCheck(options, flags, cwd, write);
|
|
7885
8907
|
case 'skills-verify':
|