harness-everything 0.40.0 → 0.41.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/.agents/harness-everything/project-docs.json +9 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +51 -0
- package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/1-source-clean.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/2-injected-architecture.md +229 -0
- package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/2-source-injected.stdout.json +33 -0
- package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/3-source-reverted.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/4a-packaged-clean.stdout.json +20 -0
- package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/4b-packaged-injected.stdout.json +33 -0
- package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/5-source-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/5b-packaged-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/README.md +54 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/1-source-clean.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-injected-document.md +328 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/2-source-injected.stdout.json +127 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/3-source-reverted.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4a-packaged-clean.stdout.json +115 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/4b-packaged-injected.stdout.json +127 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5-source-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stderr.txt +0 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/5b-packaged-missing-config.stdout.json +15 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.input.md +8 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/6-supplementary-fixture-ok.stdout.json +16 -0
- package/benchmarks/results/f1-doc-path-baseline/before-fix/README.md +34 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/after.json +248 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/before.json +254 -0
- package/benchmarks/results/f1-doc-path-baseline/probe-31-docs/probe.js +26 -0
- package/benchmarks/results/f2-doc-craft-baseline/README.md +32 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1-report.md +84 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1-task.md +21 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f1.diff +19 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2-report.md +110 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2-task.md +21 -0
- package/benchmarks/results/f2-doc-craft-baseline/arm-f2.diff +19 -0
- package/benchmarks/results/f3-derived-views-baseline/README.md +34 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2-report.md +43 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2-task.md +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f2.diff +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3-report.md +57 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3-task.md +21 -0
- package/benchmarks/results/f3-derived-views-baseline/arm-f3.diff +21 -0
- package/benchmarks/results/live-host/claude-325-3b-s2b/README.md +99 -0
- package/benchmarks/results/live-host/claude-325-3b-s2b/capture.sanitized.jsonl +26 -0
- package/ci/archive/325-prep/README.md +71 -0
- package/ci/{prep → archive/325-prep}/doc-reconciliation-red.js +9 -7
- package/ci/archive/325-prep/f4-expected-red.json +34 -0
- package/ci/archive/325-prep/historical-qualification.test.js +143 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +17 -4
- package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +8 -56
- package/ci/fixtures/doc-obligations/reconciliation-prep/trust-contract.json +2 -2
- package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +17 -5
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/harness.js +415 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/off-handoff-golden.json +16 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-failure.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success-minimal.json +9 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-post-tool-use-success.json +13 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-start.json +7 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-freetext-reject.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop-reject.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-subagent-stop.json +11 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/capture/payload-task-result.json +19 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-capture-only.json +99 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/off-legacy.json +67 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-concurrent-writers.json +62 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-lookalikes-runtime-mutations.json +67 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-malformed.json +85 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-normal-session.json +74 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-runtime-drift-later-npm-test.json +58 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/corpus/on-valid-direct-quoted-review.json +85 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/f4/cases.json +2267 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/cases.json +1110 -0
- package/ci/fixtures/doc-obligations/reconciliation-s2b/wiring/legacy-off-golden.json +674 -0
- package/ci/lib/doc-reconciliation-file-spy.js +23 -0
- package/ci/lib/doc-reconciliation-prep-fixtures.js +136 -10
- package/ci/lib/doc-reconciliation-qualification-preload.js +37 -0
- package/ci/lib/doc-reconciliation-replay.js +104 -0
- package/ci/lib/doc-reconciliation-staged-tree.js +102 -0
- package/ci/mechanism-2p-opencode-v2-entrypoint.test.js +179 -0
- package/ci/mechanism-325-3b-preparation.test.js +27 -101
- package/ci/mechanism-325-3b-s2b-capture.test.js +561 -0
- package/ci/mechanism-325-3b-s2b-wiring.test.js +668 -0
- package/ci/mechanism-325-f1-doc-check.test.js +674 -0
- package/ci/mechanism-325-f2-doc-craft.test.js +597 -0
- package/ci/mechanism-325-f3-derived-views.test.js +798 -0
- package/ci/mechanism-325-f4-invariants.test.js +606 -0
- package/ci/mechanism-325-f4-projections.test.js +382 -0
- package/ci/mechanism-325-f4-qualification.test.js +228 -0
- package/ci/mechanism-325-f4-trust-boundary.test.js +880 -0
- package/ci/mechanism-365-step-edit-accounting.test.js +395 -0
- package/ci/mutation-check.js +26 -3
- package/docs/architecture.md +5 -0
- package/docs/doc-workflow-reconciliation.md +331 -4
- package/docs/document-lifecycle.md +11 -0
- package/docs/governed-index.md +20 -0
- package/docs/platform-capabilities.md +22 -2
- package/grill-with-docs/SKILL.md +1 -1
- package/grill-with-docs/references/session-playbook.md +4 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/references/doc-check.md +186 -0
- package/harness-everything/references/documentation/_shared/maintenance.md +41 -0
- package/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
- package/harness-everything/references/triage-and-tiers.md +4 -0
- package/harness-everything/scripts/doc-check.js +442 -0
- package/harness-everything/scripts/doc-lifecycle.js +3 -0
- package/hooks/hooks.json +46 -0
- package/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
- package/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
- package/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
- package/hooks/scripts/lib/workflow-obligations.js +7 -0
- package/hooks/scripts/state-persist.js +105 -3
- package/hooks/scripts/workflow-stop-gate.js +7 -2
- package/opencode-plugin/README.md +15 -0
- package/opencode-plugin/index.mjs +77 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-capture.sanitized.jsonl +26 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +171 -2
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +1059 -0
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-qualification.json +34 -0
- package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +7 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +105 -3
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +7 -2
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
- package/plugins/harness-everything/skills/grill-with-docs/references/session-playbook.md +4 -0
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/references/doc-check.md +186 -0
- package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/maintenance.md +41 -0
- package/plugins/harness-everything/skills/harness-everything/references/documentation/_shared/writing-craft.md +59 -0
- package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +4 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-check.js +442 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +3 -0
- package/plugins/harness-everything/skills/repo-docs/SKILL.md +1 -1
- package/plugins/harness-everything/skills/repo-docs/references/process-guide.md +4 -0
- package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-spec/references/process.md +4 -0
- package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +4 -0
- package/repo-docs/SKILL.md +1 -1
- package/repo-docs/references/process-guide.md +4 -0
- package/sbom.cdx.json +7 -7
- package/to-spec/SKILL.md +1 -1
- package/to-spec/references/process.md +4 -0
- package/to-tickets/SKILL.md +1 -1
- package/to-tickets/references/publishing-and-templates.md +4 -0
|
@@ -25,9 +25,15 @@ const PILOT_SWITCH = 'HARNESS_DOC_RECONCILIATION_PILOT';
|
|
|
25
25
|
const PILOT_REPORT_FILE = 'doc-reconciliation-pilot.json';
|
|
26
26
|
const BASELINE_AUTHOR = Object.freeze({ name: 'doc-recon-replay', email: 'doc-recon-replay@invalid' });
|
|
27
27
|
const BASELINE_DATE = '2026-01-01T00:00:00Z';
|
|
28
|
-
const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints', 'seedState', 'gitCommit']);
|
|
28
|
+
const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints', 'seedState', 'gitCommit', 'editJournal', 'editDocCheck', 'observeStateTree']);
|
|
29
29
|
const CONTROLLER_OPS = new Set(['plan', 'start', 'binding', 'stageBinding', 'step', 'obligation']);
|
|
30
|
-
|
|
30
|
+
// Host-transport ops (F4): hostRun executes a registered command the way the host would; hookDeliver sends one raw
|
|
31
|
+
// host payload through the real hook entry (state-persist.js). Neither writes a journal or accepted record itself.
|
|
32
|
+
const HOST_OPS = new Set(['hostRun', 'hookDeliver']);
|
|
33
|
+
const ALL_OPS = new Set(['route', 'stop', 'stopGeneric', ...CONTROLLER_OPS, ...FIXTURE_OPS, ...HOST_OPS]);
|
|
34
|
+
// CI-only qualification preloader. Passed as `node --require <file>` to every child of a replay that asks for it.
|
|
35
|
+
const QUALIFICATION_PRELOAD = path.join(__dirname, 'doc-reconciliation-qualification-preload.js');
|
|
36
|
+
const CAPTURE_JOURNAL_FILE = 'doc-reconciliation-capture.json';
|
|
31
37
|
// The only session files a case may seed, and only inside its own temporary state home.
|
|
32
38
|
const SEEDABLE_FILES = new Set(['workflow-stages.json']);
|
|
33
39
|
|
|
@@ -38,14 +44,18 @@ const DISTRIBUTIONS = Object.freeze({
|
|
|
38
44
|
controller: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-disposition.js'),
|
|
39
45
|
stopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-stop-gate.js'),
|
|
40
46
|
kernel: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'kernel-router.js'),
|
|
41
|
-
genericStopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'stop-gate.js')
|
|
47
|
+
genericStopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'stop-gate.js'),
|
|
48
|
+
stateHook: path.join(REPO_ROOT, 'hooks', 'scripts', 'state-persist.js'),
|
|
49
|
+
docCheck: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'doc-check.js')
|
|
42
50
|
},
|
|
43
51
|
packaged: {
|
|
44
52
|
root: path.join(REPO_ROOT, 'plugins', 'harness-everything'),
|
|
45
53
|
controller: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-disposition.js'),
|
|
46
54
|
stopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-stop-gate.js'),
|
|
47
55
|
kernel: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'kernel-router.js'),
|
|
48
|
-
genericStopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'stop-gate.js')
|
|
56
|
+
genericStopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'stop-gate.js'),
|
|
57
|
+
stateHook: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'state-persist.js'),
|
|
58
|
+
docCheck: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'doc-check.js')
|
|
49
59
|
}
|
|
50
60
|
});
|
|
51
61
|
|
|
@@ -94,13 +104,26 @@ function childEnv(workspace, stateHome, pilot) {
|
|
|
94
104
|
return env;
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
// A distribution is a named one above, or an explicit set of entry paths (F4-T2: a test runs the real entries from its own
|
|
108
|
+
// staged tree, with its own export). Either way every entry point is an absolute path.
|
|
109
|
+
const DISTRIBUTION_KEYS = ['controller', 'stopGate', 'kernel', 'genericStopGate', 'stateHook', 'docCheck'];
|
|
110
|
+
function resolveDistribution(distribution) {
|
|
111
|
+
if (typeof distribution === 'string') {
|
|
112
|
+
if (!DISTRIBUTIONS[distribution]) throw new Error(`unknown distribution ${distribution}`);
|
|
113
|
+
return DISTRIBUTIONS[distribution];
|
|
114
|
+
}
|
|
115
|
+
if (distribution && typeof distribution === 'object' && DISTRIBUTION_KEYS.every(key => typeof distribution[key] === 'string')) return distribution;
|
|
116
|
+
throw new Error('unknown distribution');
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
function createSession(distribution, caseId, pilot) {
|
|
98
|
-
|
|
120
|
+
const dist = resolveDistribution(distribution);
|
|
99
121
|
const token = crypto.randomBytes(4).toString('hex');
|
|
100
122
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), `doc-recon-${caseId}-${distribution}-ws-${token}-`));
|
|
101
123
|
const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), `doc-recon-${caseId}-${distribution}-state-${token}-`));
|
|
102
124
|
return {
|
|
103
|
-
|
|
125
|
+
dist,
|
|
126
|
+
distribution: typeof distribution === 'string' ? distribution : 'custom',
|
|
104
127
|
caseId,
|
|
105
128
|
pilot,
|
|
106
129
|
workspace,
|
|
@@ -114,8 +137,11 @@ function disposeSession(handle) {
|
|
|
114
137
|
fs.rmSync(handle.stateHome, { recursive: true, force: true });
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
// The preloader is a test-driver argument only: a replay with `preload: true` adds it to every child it launches.
|
|
117
141
|
function runScript(handle, script, args, stdin, pilot) {
|
|
118
|
-
|
|
142
|
+
const preload = handle.preload ? ['--require', QUALIFICATION_PRELOAD] : [];
|
|
143
|
+
// Test-only extra arguments (for example a file-access spy). They come before the preloader, so the spy sees everything.
|
|
144
|
+
return spawnSync(process.execPath, [...(handle.nodeArgs || []), ...preload, script, ...args], {
|
|
119
145
|
cwd: handle.workspace,
|
|
120
146
|
encoding: 'utf8',
|
|
121
147
|
input: stdin === undefined ? undefined : JSON.stringify(stdin),
|
|
@@ -159,10 +185,13 @@ function readJsonOrNull(file) {
|
|
|
159
185
|
function snapshot(handle) {
|
|
160
186
|
const dir = sessionDirOf(handle);
|
|
161
187
|
if (!dir) return { workflow: null, obligations: null, handoff: null };
|
|
188
|
+
// The capture journal is included only when a hook actually wrote it, so legacy replays keep their shape.
|
|
189
|
+
const capture = readJsonOrNull(path.join(dir, CAPTURE_JOURNAL_FILE));
|
|
162
190
|
return {
|
|
163
191
|
workflow: readJsonOrNull(path.join(dir, 'workflow-run.json')),
|
|
164
192
|
obligations: readJsonOrNull(path.join(dir, 'workflow-obligations.json')),
|
|
165
|
-
handoff: readJsonOrNull(path.join(dir, 'handoff-state.json'))
|
|
193
|
+
handoff: readJsonOrNull(path.join(dir, 'handoff-state.json')),
|
|
194
|
+
...(capture ? { capture } : {})
|
|
166
195
|
};
|
|
167
196
|
}
|
|
168
197
|
|
|
@@ -207,6 +236,20 @@ function runFixture(handle, action, pilot) {
|
|
|
207
236
|
fs.writeFileSync(target, String(p.content), 'utf8');
|
|
208
237
|
return fixtureResult(handle, pilot, { op: 'mutateIdentity', kind: p.kind, path: p.path });
|
|
209
238
|
}
|
|
239
|
+
if (action.op === 'observeStateTree') {
|
|
240
|
+
// Read-only: lists the relative paths the replay wrote under its state home and workspace (F4-T12 matrix; no contents).
|
|
241
|
+
const list = root => { const out = []; const walk = dir => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else out.push(path.relative(root, full).split(path.sep).join('/')); } }; if (fs.existsSync(root)) walk(root); return out.sort(); };
|
|
242
|
+
return fixtureResult(handle, pilot, { op: 'observeStateTree', stateFiles: list(handle.stateHome), workspaceFiles: list(handle.workspace) });
|
|
243
|
+
}
|
|
244
|
+
if (action.op === 'editDocCheck') {
|
|
245
|
+
// Appends to the leg's installed doc-check file after a capture (F4-T6: the assessment-time digest check). Only a staged
|
|
246
|
+
// copy may change: the target must sit outside the repository.
|
|
247
|
+
if (typeof p.append !== 'string') throw new Error('editDocCheck needs an append string');
|
|
248
|
+
const target = handle.dist.docCheck;
|
|
249
|
+
if (path.basename(target) !== 'doc-check.js' || target.startsWith(REPO_ROOT + path.sep)) throw new Error('editDocCheck may only edit a staged doc-check copy');
|
|
250
|
+
fs.appendFileSync(target, p.append, 'utf8');
|
|
251
|
+
return fixtureResult(handle, pilot, { op: 'editDocCheck' });
|
|
252
|
+
}
|
|
210
253
|
if (action.op === 'gitCommit') {
|
|
211
254
|
// Commits the replay workspace's current tree into HEAD mid-workflow. The baseline recorded at plan is not moved by
|
|
212
255
|
// this fixture: the case exists to show that scope is still assessed against that recorded baseline. A clean tree
|
|
@@ -230,6 +273,17 @@ function runFixture(handle, action, pilot) {
|
|
|
230
273
|
fs.writeFileSync(target, JSON.stringify(p.json, null, 2), 'utf8');
|
|
231
274
|
return fixtureResult(handle, pilot, { op: 'seedState', file: p.file });
|
|
232
275
|
}
|
|
276
|
+
if (action.op === 'editJournal') {
|
|
277
|
+
// Rewrites one retained field of the replay's own capture journal, as an edited payload or result would appear on disk.
|
|
278
|
+
const dir = sessionDirOf(handle);
|
|
279
|
+
if (!dir) throw new Error('editJournal needs an existing session');
|
|
280
|
+
const file = path.join(dir, CAPTURE_JOURNAL_FILE);
|
|
281
|
+
const doc = readJsonOrNull(file);
|
|
282
|
+
if (!doc || !Array.isArray(doc.records) || !doc.records[p.index]) throw new Error('editJournal needs an existing journal record');
|
|
283
|
+
doc.records[p.index][p.key] = p.value;
|
|
284
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8');
|
|
285
|
+
return fixtureResult(handle, pilot, { op: 'editJournal', key: p.key });
|
|
286
|
+
}
|
|
233
287
|
if (action.op === 'timestampHints') {
|
|
234
288
|
const dir = sessionDirOf(handle);
|
|
235
289
|
if (!dir) throw new Error('timestampHints needs an existing session');
|
|
@@ -244,13 +298,82 @@ function runFixture(handle, action, pilot) {
|
|
|
244
298
|
throw new Error(`unknown fixture op ${action.op}`);
|
|
245
299
|
}
|
|
246
300
|
|
|
301
|
+
function resultOf(handle, result, pilot) {
|
|
302
|
+
return {
|
|
303
|
+
exitCode: result.status,
|
|
304
|
+
signal: result.signal,
|
|
305
|
+
error: result.error ? result.error.message : null,
|
|
306
|
+
errorCode: result.error ? result.error.code || null : null,
|
|
307
|
+
stdout: result.stdout || '',
|
|
308
|
+
stderr: result.stderr || '',
|
|
309
|
+
json: parseJsonOrNull(result.stdout || ''),
|
|
310
|
+
state: snapshot(handle),
|
|
311
|
+
pilot
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const DOC_CHECK_TOKEN = '{{DOC_CHECK}}';
|
|
316
|
+
const CAPTURE_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']);
|
|
317
|
+
|
|
318
|
+
// Host-transport ops (F4). hostRun executes the distribution's registered doc-check entry by argv, with no shell, the
|
|
319
|
+
// way the host runs a registered command. hookDeliver sends one raw host payload to the real state-persist hook, with
|
|
320
|
+
// the session and workspace fields the host would send. Payload content is declared by the case and then the entry
|
|
321
|
+
// itself records it; the replay never writes a journal record or accepted event directly.
|
|
322
|
+
function runHostOp(handle, action, pilot) {
|
|
323
|
+
const dist = handle.dist;
|
|
324
|
+
const p = action.payload || {};
|
|
325
|
+
if (action.op === 'hostRun') {
|
|
326
|
+
if (p.command !== 'doc-check' || !Array.isArray(p.args) || !p.args.every(arg => typeof arg === 'string')) {
|
|
327
|
+
throw new Error('hostRun needs command "doc-check" and string args');
|
|
328
|
+
}
|
|
329
|
+
return resultOf(handle, runScript(handle, scriptFor(handle, 'docCheck', dist.docCheck), p.args, undefined, pilot), pilot);
|
|
330
|
+
}
|
|
331
|
+
if (!CAPTURE_HOOK_EVENTS.has(p.event)) throw new Error(`hookDeliver event ${p.event} is not a capture event`);
|
|
332
|
+
const body = {
|
|
333
|
+
session_id: p.sessionId === undefined ? handle.sessionId : p.sessionId,
|
|
334
|
+
cwd: handle.workspace,
|
|
335
|
+
hook_event_name: p.event
|
|
336
|
+
};
|
|
337
|
+
if (p.event === 'PreToolUse' || p.event === 'PostToolUse' || p.event === 'PostToolUseFailure') {
|
|
338
|
+
if (typeof p.command !== 'string' || typeof p.toolUseId !== 'string') throw new Error('hookDeliver tool events need command and toolUseId');
|
|
339
|
+
body.tool_name = p.toolName === undefined ? 'Bash' : p.toolName;
|
|
340
|
+
// {{DOC_CHECK}} is the installed doc-check path of the leg under test, so a positive fixture invokes the path the
|
|
341
|
+
// host would really run; a relative spelling stays a literal negative case.
|
|
342
|
+
body.tool_input = { command: p.command.split(DOC_CHECK_TOKEN).join(dist.docCheck) };
|
|
343
|
+
body.tool_use_id = p.toolUseId;
|
|
344
|
+
// A subagent's own tool call carries its host agent_id at the top level (F4 executor identity).
|
|
345
|
+
if (p.agentId !== undefined) body.agent_id = p.agentId;
|
|
346
|
+
}
|
|
347
|
+
if (p.event === 'PostToolUse') {
|
|
348
|
+
if (p.fromRun !== undefined) {
|
|
349
|
+
const run = handle.actions[p.fromRun];
|
|
350
|
+
if (!run) throw new Error(`hookDeliver fromRun ${p.fromRun} names no earlier action`);
|
|
351
|
+
body.tool_response = { stdout: run.stdout, stderr: '', interrupted: false };
|
|
352
|
+
} else {
|
|
353
|
+
body.tool_response = p.response;
|
|
354
|
+
}
|
|
355
|
+
if (p.interrupted !== undefined) body.tool_response.interrupted = p.interrupted;
|
|
356
|
+
if (p.omitInterrupted === true) delete body.tool_response.interrupted;
|
|
357
|
+
} else if (p.event === 'PostToolUseFailure') {
|
|
358
|
+
body.error = p.error;
|
|
359
|
+
body.is_interrupt = p.isInterrupt === true;
|
|
360
|
+
} else {
|
|
361
|
+
body.agent_id = p.agentId;
|
|
362
|
+
body.agent_type = p.agentType;
|
|
363
|
+
if (p.event === 'SubagentStop') body.last_assistant_message = p.lastAssistantMessage;
|
|
364
|
+
}
|
|
365
|
+
// The hook is fail-open and prints nothing, so only its exit status and the journal it writes are observable.
|
|
366
|
+
return resultOf(handle, runScript(handle, scriptFor(handle, 'hookDeliver', dist.stateHook), [], body, pilot), pilot);
|
|
367
|
+
}
|
|
368
|
+
|
|
247
369
|
// Actions are the only way replay state changes. Each controller/stop op maps to the existing public entry point.
|
|
248
370
|
function runAction(handle, action, pilot) {
|
|
249
|
-
const dist =
|
|
371
|
+
const dist = handle.dist;
|
|
250
372
|
const sid = handle.sessionId;
|
|
251
373
|
const p = action.payload || {};
|
|
252
374
|
if (!ALL_OPS.has(action.op)) throw new Error(`unsupported replay op ${action.op}`);
|
|
253
375
|
if (FIXTURE_OPS.has(action.op)) return runFixture(handle, action, pilot);
|
|
376
|
+
if (HOST_OPS.has(action.op)) return runHostOp(handle, action, pilot);
|
|
254
377
|
let result;
|
|
255
378
|
if (action.op === 'route') {
|
|
256
379
|
result = runScript(handle, scriptFor(handle, 'route', dist.kernel), [], { session_id: sid, cwd: handle.workspace, prompt: p.prompt }, pilot);
|
|
@@ -376,13 +499,16 @@ function runtimePilotOf(handle) {
|
|
|
376
499
|
// Replays one case through one distribution. Actions run in order and the replay stops at its first infrastructure
|
|
377
500
|
// fault: the faulted action is recorded, no later action runs, and the temporary directories are still removed.
|
|
378
501
|
// A crash is recorded, not hidden.
|
|
502
|
+
// `options.preload: true` adds the CI-only qualification preloader to every child (F4 test transport). Production
|
|
503
|
+
// replays never set it, so the production gate stays unmet.
|
|
379
504
|
function replayCase(caseDef, distribution, options = {}) {
|
|
380
505
|
const limits = limitsFor(caseDef);
|
|
381
506
|
const mode = options.pilot === undefined ? 'off' : 'on';
|
|
382
|
-
const handle = { ...createSession(distribution, caseDef.id, options.pilot),
|
|
507
|
+
const handle = { ...createSession(distribution, caseDef.id, options.pilot), limits, preload: options.preload === true, nodeArgs: Array.isArray(options.nodeArgs) ? options.nodeArgs : [] };
|
|
383
508
|
try {
|
|
384
509
|
materialiseInput(handle, caseDef.input);
|
|
385
510
|
const actions = {};
|
|
511
|
+
handle.actions = actions;
|
|
386
512
|
for (const action of caseDef.actions) {
|
|
387
513
|
if (Object.prototype.hasOwnProperty.call(actions, action.id)) throw new Error(`duplicate action id ${action.id}`);
|
|
388
514
|
const payload = action.payload && action.payload.requirements === '$requirements'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// #325 Phase 3b F4 CI-only qualification preloader (docs/doc-workflow-reconciliation.md, F4 "Test transport").
|
|
4
|
+
//
|
|
5
|
+
// Loaded only by the test driver: `node --require <this file> <real entry> ...`. It is not packaged, no
|
|
6
|
+
// production code reads it, and no environment or argv switch enables it.
|
|
7
|
+
//
|
|
8
|
+
// Boundary: it supplies only the explicitly fake qualification object through the origin-provider factory,
|
|
9
|
+
// and only when the caller omitted qualification. It never touches the snapshot, resolveEvent outcomes,
|
|
10
|
+
// principals, capability, accepted records or core eligibility.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const FAKE_QUALIFICATION = Object.freeze({
|
|
16
|
+
source: 'fake-test-fixture',
|
|
17
|
+
faked: true,
|
|
18
|
+
ref: 'ci-qualification-preload',
|
|
19
|
+
toolUseIdPopulated: true,
|
|
20
|
+
agentIdPopulated: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The entry decides which provider copy is patched, so the canonical and packaged legs are each covered.
|
|
24
|
+
const entry = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
25
|
+
const providerFile = path.join(path.dirname(entry), 'lib', 'doc-reconciliation-provider.js');
|
|
26
|
+
|
|
27
|
+
if (fs.existsSync(providerFile)) {
|
|
28
|
+
const provider = require(providerFile);
|
|
29
|
+
const factory = provider.createClaudeOriginProvider;
|
|
30
|
+
provider.createClaudeOriginProvider = input => {
|
|
31
|
+
const safe = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
32
|
+
return factory({
|
|
33
|
+
...safe,
|
|
34
|
+
qualification: safe.qualification === undefined ? FAKE_QUALIFICATION : safe.qualification,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
7
|
+
const fx = require('./doc-reconciliation-prep-fixtures');
|
|
8
|
+
|
|
9
|
+
const CORPUS = path.join(__dirname, '..', 'fixtures', 'doc-obligations', 'reconciliation-s2b', 'corpus');
|
|
10
|
+
const CASES = path.join(__dirname, '..', 'fixtures', 'doc-obligations', 'reconciliation-s2b', 'f4', 'cases.json');
|
|
11
|
+
const EVENT_HOOKS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']);
|
|
12
|
+
const SITES = ['moduleLoads', 'stateFiles', 'captureRecords', 'pilotReport', 'workflowStep', 'workflowStop', 'genericStop'];
|
|
13
|
+
|
|
14
|
+
function load(streamId) {
|
|
15
|
+
const file = path.join(CORPUS, `${streamId}.json`);
|
|
16
|
+
const stream = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
if (!stream.id || !Array.isArray(stream.events) || !stream.expectations) throw new Error(`invalid stream ${file}`);
|
|
18
|
+
for (const site of SITES) if (!stream.expectations[site]) throw new Error(`${stream.id} missing expectation site ${site}`);
|
|
19
|
+
return stream;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tracePreloader(file) {
|
|
23
|
+
fs.writeFileSync(file, `const M=require('module'), fs=require('fs'); const old=M._load; M._load=function(id,p,m){const x=old.apply(this,arguments);if(typeof id==='string'&&/(doc-reconciliation|state-persist|workflow-disposition|stop-gate)/.test(id))fs.appendFileSync(process.env.F4_C1_TRACE,JSON.stringify({pid:process.pid,module:id,requester:m&&m.filename})+'\\n');return x};\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function actionsFor(stream, template) {
|
|
27
|
+
const actions = template.actions.filter(a => !['lint-run','post-lint','verify-pass','stop','stop-generic'].includes(a.id));
|
|
28
|
+
stream.events.forEach((event, i) => {
|
|
29
|
+
if (!EVENT_HOOKS.has(event.event)) throw new Error(`${stream.id}: unsupported event ${event.event}`);
|
|
30
|
+
const id = `capture-${String(i + 1).padStart(2, '0')}`;
|
|
31
|
+
if (event.runArgs) actions.push({ id: `${id}-run`, op: 'hostRun', payload: { command: 'doc-check', args: event.runArgs } });
|
|
32
|
+
const payload = { event: event.event };
|
|
33
|
+
if (event.toolName) payload.toolName = event.toolName;
|
|
34
|
+
if (event.command !== undefined) payload.command = event.command;
|
|
35
|
+
if (event.toolUseId) payload.toolUseId = event.toolUseId;
|
|
36
|
+
if (event.runArgs && event.event === 'PostToolUse') payload.fromRun = `${id}-run`;
|
|
37
|
+
if (event.event === 'PostToolUse') payload.response = { stdout: event.runArgs ? 'doc-check completed' : 'completed', stderr: '', interrupted: false };
|
|
38
|
+
if (event.event === 'PostToolUseFailure') { payload.error = 'stable fixture failure'; payload.isInterrupt = false; }
|
|
39
|
+
if (event.event === 'SubagentStart' || event.event === 'SubagentStop') {
|
|
40
|
+
payload.agentId = `agent-${i + 1}`; payload.agentType = 'reviewer';
|
|
41
|
+
if (event.event === 'SubagentStop') payload.lastAssistantMessage = 'APPROVE: reviewed the governed document.';
|
|
42
|
+
}
|
|
43
|
+
actions.push({ id, op: 'hookDeliver', payload });
|
|
44
|
+
});
|
|
45
|
+
actions.push({ id: 'verify-pass', op: 'step', payload: { stepId: 'req-verify', disposition: 'pass', evidence: 'recorded document verification' }, expectExit: { on: [0, 2] } });
|
|
46
|
+
actions.push({ id: 'stop', op: 'stop' }, { id: 'stop-generic', op: 'stopGeneric' });
|
|
47
|
+
return actions;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function concurrent(stream, leg, mode) {
|
|
51
|
+
const dist = fx.DISTRIBUTIONS[leg];
|
|
52
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'f4c1-concurrent-'));
|
|
53
|
+
const stateHome = path.join(root, 'state'); const workspace = path.join(root, 'workspace');
|
|
54
|
+
fs.mkdirSync(workspace); fs.mkdirSync(stateHome);
|
|
55
|
+
const barrier = path.join(root, 'barrier'); fs.mkdirSync(barrier);
|
|
56
|
+
const worker = path.join(root, 'worker.js');
|
|
57
|
+
fs.writeFileSync(worker, `const fs=require('fs'),cp=require('child_process'),path=require('path');const [hook,barrier,home,workspace,sid,index,pilot]=process.argv.slice(2);fs.writeFileSync(path.join(barrier,'ready-'+index),'ready');while(!fs.existsSync(path.join(barrier,'go')))Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,20);const p={session_id:sid,cwd:workspace,hook_event_name:'PostToolUse',tool_name:'Bash',tool_input:{command:'npm test'},tool_use_id:'concurrent-'+index,tool_response:{stdout:'ok',stderr:'',interrupted:false}};const r=cp.spawnSync(process.execPath,[hook],{input:JSON.stringify(p),encoding:'utf8',env:{...process.env,HARNESS_STATE_HOME:home,HARNESS_WORKSPACE_ROOT:workspace,...(pilot==='on'?{HARNESS_DOC_RECONCILIATION_PILOT:'1'}:{})}});process.exit(r.status||0);`);
|
|
58
|
+
const children = [1, 2].map(i => spawn(process.execPath, [worker, dist.stateHook, barrier, stateHome, workspace, 'concurrent-session', String(i), mode], { stdio: 'ignore' }));
|
|
59
|
+
while (![1, 2].every(i => fs.existsSync(path.join(barrier, `ready-${i}`)))) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
60
|
+
fs.writeFileSync(path.join(barrier, 'go'), 'release');
|
|
61
|
+
const exits = children.map(child => new Promise(resolve => child.on('exit', (code, signal) => resolve({ code, signal }))));
|
|
62
|
+
return Promise.all(exits).then(results => {
|
|
63
|
+
const files=[]; const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=path.join(d,e.name);if(e.isDirectory())walk(f);else files.push({path:path.relative(stateHome,f),json:f.endsWith('.json')?safeJson(f):undefined,provenance:'concurrent state-persist process'});}};walk(stateHome);
|
|
64
|
+
fs.rmSync(root,{recursive:true,force:true}); return {results,files};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function safeJson(file){try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch(_){return null;}}
|
|
68
|
+
|
|
69
|
+
async function replay(streamId, leg='source', mode='on') {
|
|
70
|
+
if (!fx.DISTRIBUTIONS[leg]) throw new Error(`unknown distribution ${leg}`);
|
|
71
|
+
if (!['on','off'].includes(mode)) throw new Error(`unknown mode ${mode}`);
|
|
72
|
+
const stream=load(streamId); const cases=JSON.parse(fs.readFileSync(CASES,'utf8')).cases;
|
|
73
|
+
const template=cases.find(item=>item.id==='F4-POS-body-edit');
|
|
74
|
+
const definition={...template,id:stream.id,actions:actionsFor(stream,template)};
|
|
75
|
+
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'f4c1-')); const spy=path.join(scratch,'trace.js'); const trace=path.join(scratch,'modules.jsonl'); tracePreloader(spy);
|
|
76
|
+
const previous=process.env.F4_C1_TRACE; process.env.F4_C1_TRACE=trace;
|
|
77
|
+
let root;
|
|
78
|
+
try { root=fx.replayCase(definition,leg,{...(mode==='on'?{pilot:'1'}:{}),nodeArgs:['--require',spy]}); }
|
|
79
|
+
finally { if(previous===undefined)delete process.env.F4_C1_TRACE;else process.env.F4_C1_TRACE=previous; }
|
|
80
|
+
const modules=fs.existsSync(trace)?fs.readFileSync(trace,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse):[];
|
|
81
|
+
const stateFiles=[]; const provenance=[];
|
|
82
|
+
for(const [actionId,action] of Object.entries(root.actions)) for(const key of ['workflow','obligations','handoff','capture']) if(action.state&&action.state[key]) {
|
|
83
|
+
stateFiles.push({actionId,key,provenance:`snapshot after ${actionId}`,value:action.state[key]});
|
|
84
|
+
if(key==='capture') for(const record of action.state.capture.records||[]) provenance.push({actionId,event:record.event,toolUseId:record.tool_use_id||record.toolUseId,provenance:'state-persist capture journal'});
|
|
85
|
+
}
|
|
86
|
+
const result={stream:stream.id,leg,mode,infrastructureErrors:fx.infrastructureFaults(definition,root,mode),moduleLoads:modules,stateFiles,captureRecords:provenance,pilotReport:root.pilot,pilotReportOrigin:root.pilotReportOrigin,workflowStep:Object.fromEntries(Object.entries(root.actions).filter(([id])=>id==='verify-pass').map(([id,a])=>[id,{exitCode:a.exitCode,stdout:a.stdout,stderr:a.stderr}])),workflowStop:root.actions.stop&&{exitCode:root.actions.stop.exitCode,stdout:root.actions.stop.stdout,stderr:root.actions.stop.stderr},genericStop:root.actions['stop-generic']&&{exitCode:root.actions['stop-generic'].exitCode,stdout:root.actions['stop-generic'].stdout,stderr:root.actions['stop-generic'].stderr},expectations:stream.expectations};
|
|
87
|
+
if(stream.concurrency) result.concurrency=await concurrent(stream,leg,mode);
|
|
88
|
+
fs.rmSync(scratch,{recursive:true,force:true});
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function selfCheck(){
|
|
93
|
+
const ids=fs.readdirSync(CORPUS).filter(f=>f.endsWith('.json')).map(f=>path.basename(f,'.json')).sort();
|
|
94
|
+
let failed=false;
|
|
95
|
+
for(const leg of ['source','packaged']) for(const id of ids){const stream=load(id);const result=await replay(id,leg,stream.mode);const journal=result.concurrency&&result.concurrency.files.find(file=>file.path.endsWith('doc-reconciliation-capture.json'));const concurrentBad=result.concurrency&&(result.concurrency.results.some(x=>x.code!==0)||!journal||journal.json.records.length!==2);const bad=result.infrastructureErrors.length||concurrentBad;console.log(`${bad?'INFRA':'OK'} ${leg}/${id}${bad?`: ${JSON.stringify(result.infrastructureErrors)} ${JSON.stringify(result.concurrency&&result.concurrency.results)} journalRecords=${journal&&journal.json.records.length}`:''}`);failed ||= Boolean(bad);}
|
|
96
|
+
return failed?1:0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if(require.main===module){
|
|
100
|
+
if(process.argv[2]==='--worker')process.exit(0);
|
|
101
|
+
if(process.argv[2]==='--self-check')selfCheck().then(code=>process.exit(code));
|
|
102
|
+
else replay(process.argv[2],process.argv[3]||'source',process.argv[4]||'on').then(r=>console.log(JSON.stringify(r,null,2)));
|
|
103
|
+
}
|
|
104
|
+
module.exports={load,replay,selfCheck};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// #325 F4-T2 test helper: stages a scratch copy of one leg's real entries (hooks, harness scripts and the retained
|
|
4
|
+
// live-host capture), so a test chooses the qualification export itself. A staged tree never carries the repository's
|
|
5
|
+
// export, and nothing here writes into the repository. The real entries run from the copy, with copied siblings, so
|
|
6
|
+
// the provider walks up to the copied retained directory exactly as it would in the checkout.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const LIVE_REL = path.join('benchmarks', 'results', 'live-host', 'claude-325-3b-s2b');
|
|
15
|
+
const EXPORT_FILE = 'doc-reconciliation-qualification.json';
|
|
16
|
+
const CAPTURE_FILE = 'capture.sanitized.jsonl';
|
|
17
|
+
// The export names its sanitized capture as a sibling in the same lib directory, so source and packaged copies resolve it
|
|
18
|
+
// the same way (the provider reads it from there).
|
|
19
|
+
const SIBLING_CAPTURE = 'doc-reconciliation-capture.sanitized.jsonl';
|
|
20
|
+
// Facts observed in the retained Run 2b capture (see benchmarks/results/live-host/claude-325-3b-s2b/README.md). A valid
|
|
21
|
+
// export states exactly these; the runtime checks them against the sibling file.
|
|
22
|
+
const OBSERVED_PAYLOAD_KINDS = {
|
|
23
|
+
PostToolUse: ['agent_id', 'interrupted', 'tool_use_id'],
|
|
24
|
+
PreToolUse: ['agent_id', 'tool_use_id'],
|
|
25
|
+
SubagentStart: ['agent_id'],
|
|
26
|
+
SubagentStop: ['agent_id'],
|
|
27
|
+
};
|
|
28
|
+
const OBSERVED_CORRELATION = { preToPostByToolUseId: true, interruptedExplicit: true, subagentStartStopSameAgent: true, agentResultAliasCorrelated: true };
|
|
29
|
+
|
|
30
|
+
// Top-level entries the source leg never reads: VCS and dependency trees, the worktrees, the test corpus, the evidence
|
|
31
|
+
// directories and the packaging copies. The live-host capture is copied separately, below.
|
|
32
|
+
const SOURCE_EXCLUDE = new Set(['.git', 'node_modules', '.worktrees', 'ci', 'benchmarks', 'behavioral-evals', 'evals', 'docs', 'plugins', 'submission', 'opencode-plugin']);
|
|
33
|
+
|
|
34
|
+
function sha256File(file) {
|
|
35
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stages one leg. Returns the replay distribution (explicit entry paths inside the copy) and the helpers a test needs.
|
|
39
|
+
// The live-host directory is copied to the top of the tree, which is where the provider's upward walk finds it.
|
|
40
|
+
function stage(leg) {
|
|
41
|
+
if (leg !== 'source' && leg !== 'packaged') throw new Error(`unknown leg ${leg}`);
|
|
42
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `f4-staged-${leg}-`));
|
|
43
|
+
let base;
|
|
44
|
+
if (leg === 'source') {
|
|
45
|
+
// The source package: the root carries the hooks, the harness scripts, the shared libraries and the root skill
|
|
46
|
+
// folders that bindings resolve. Only data that the entries never read is left out.
|
|
47
|
+
for (const entry of fs.readdirSync(ROOT)) {
|
|
48
|
+
if (SOURCE_EXCLUDE.has(entry)) continue;
|
|
49
|
+
fs.cpSync(path.join(ROOT, entry), path.join(root, entry), { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
base = root;
|
|
52
|
+
} else {
|
|
53
|
+
fs.cpSync(path.join(ROOT, 'plugins', 'harness-everything'), path.join(root, 'plugin'), { recursive: true });
|
|
54
|
+
base = path.join(root, 'plugin');
|
|
55
|
+
}
|
|
56
|
+
fs.cpSync(path.join(ROOT, LIVE_REL), path.join(root, LIVE_REL), { recursive: true });
|
|
57
|
+
const libDir = path.join(base, 'hooks', 'scripts', 'lib');
|
|
58
|
+
// The copied tree may carry a checkout's own export: a staged tree starts without one.
|
|
59
|
+
fs.rmSync(path.join(libDir, EXPORT_FILE), { force: true });
|
|
60
|
+
fs.rmSync(path.join(libDir, SIBLING_CAPTURE), { force: true });
|
|
61
|
+
fs.copyFileSync(path.join(root, LIVE_REL, CAPTURE_FILE), path.join(libDir, SIBLING_CAPTURE));
|
|
62
|
+
const skillScripts = leg === 'source' ? path.join(base, 'harness-everything', 'scripts') : path.join(base, 'skills', 'harness-everything', 'scripts');
|
|
63
|
+
const dist = {
|
|
64
|
+
controller: path.join(base, 'hooks', 'scripts', 'workflow-disposition.js'),
|
|
65
|
+
stopGate: path.join(base, 'hooks', 'scripts', 'workflow-stop-gate.js'),
|
|
66
|
+
kernel: path.join(skillScripts, 'kernel-router.js'),
|
|
67
|
+
genericStopGate: path.join(base, 'hooks', 'scripts', 'stop-gate.js'),
|
|
68
|
+
stateHook: path.join(base, 'hooks', 'scripts', 'state-persist.js'),
|
|
69
|
+
docCheck: path.join(skillScripts, 'doc-check.js'),
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
root,
|
|
73
|
+
leg,
|
|
74
|
+
dist,
|
|
75
|
+
libDir,
|
|
76
|
+
exportFile: path.join(libDir, EXPORT_FILE),
|
|
77
|
+
captureFile: path.join(libDir, SIBLING_CAPTURE),
|
|
78
|
+
retainedCaptureFile: path.join(root, LIVE_REL, CAPTURE_FILE),
|
|
79
|
+
// The export that binds the staged capture bytes and records the two populated fields.
|
|
80
|
+
// The valid export for this capture. `overrides` lets a test change one field, to show the runtime refuses it.
|
|
81
|
+
validExport(overrides = {}) {
|
|
82
|
+
return {
|
|
83
|
+
schema: 'harness.doc-reconciliation.qualification.v1',
|
|
84
|
+
host: 'claude-code',
|
|
85
|
+
hostVersion: '2.1.308',
|
|
86
|
+
captureRecordVersion: 'claude-capture-record/1',
|
|
87
|
+
payloadKinds: OBSERVED_PAYLOAD_KINDS,
|
|
88
|
+
correlation: OBSERVED_CORRELATION,
|
|
89
|
+
sanitizedCapture: { file: SIBLING_CAPTURE, sha256: sha256File(path.join(libDir, SIBLING_CAPTURE)) },
|
|
90
|
+
...overrides,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
writeExport(content) {
|
|
94
|
+
fs.writeFileSync(this.exportFile, typeof content === 'string' ? content : JSON.stringify(content, null, 2) + '\n', 'utf8');
|
|
95
|
+
},
|
|
96
|
+
cleanup() {
|
|
97
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { stage, EXPORT_FILE, SIBLING_CAPTURE, LIVE_REL, CAPTURE_FILE };
|