harness-everything 0.36.4 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +24 -0
- package/ci/fixtures/doc-obligations/providers-s3/cases.json +535 -0
- package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +70 -11
- package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +24 -556
- package/ci/fixtures/doc-obligations/reconciliation-prep/provider-contract.json +1 -1
- package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +6842 -0
- package/ci/fixtures/doc-obligations/reconciliation-s1/fail-session-writes.js +116 -0
- package/ci/lib/doc-reconciliation-prep-fixtures.js +116 -10
- package/ci/mechanism-325-3b-preparation.test.js +8 -8
- package/ci/mechanism-325-3b-s1-review.test.js +831 -0
- package/ci/mechanism-325-3b-s1.test.js +188 -0
- package/ci/mechanism-325-3b-s3-providers.test.js +378 -0
- package/ci/prep/doc-reconciliation-red.js +2 -1
- package/docs/doc-workflow-reconciliation.md +333 -28
- package/docs/document-lifecycle.md +4 -4
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-lifecycle.js +1 -1
- package/harness-everything/scripts/doc-providers.js +113 -0
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +152 -0
- package/hooks/scripts/lib/workflow-obligations.js +44 -0
- package/hooks/scripts/workflow-disposition.js +2 -0
- package/hooks/scripts/workflow-stop-gate.js +24 -1
- 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-pilot.js +152 -0
- package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +44 -0
- package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -0
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +24 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-providers.js +113 -0
- package/sbom.cdx.json +7 -7
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { atomicWriteJson } = require('./fable-contracts');
|
|
7
|
+
|
|
8
|
+
const enabled = () => process.env.HARNESS_DOC_RECONCILIATION_PILOT === '1';
|
|
9
|
+
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
const env = { ...process.env };
|
|
12
|
+
for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
15
|
+
env, encoding: 'utf8', timeout: 10000, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw new Error('baseline-unavailable', { cause });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runtimeModule(name) {
|
|
23
|
+
for (const relative of ['../../../harness-everything/scripts', '../../../skills/harness-everything/scripts']) {
|
|
24
|
+
const file = path.resolve(__dirname, relative, name + '.js');
|
|
25
|
+
if (fs.existsSync(file)) return require(file);
|
|
26
|
+
}
|
|
27
|
+
throw new Error('document runtime unavailable');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only capture at plan/start. A later credit assessment must never advance the baseline to HEAD.
|
|
31
|
+
function captureBaseline(context) {
|
|
32
|
+
if (!enabled() || context.workflow.docReconciliationBaseline) return;
|
|
33
|
+
try {
|
|
34
|
+
const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
|
|
35
|
+
const commit = git(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
|
|
36
|
+
context.workflow.docReconciliationBaseline = { root, commit };
|
|
37
|
+
} catch {
|
|
38
|
+
// Absence is intentionally retained; credit assessment will fail closed.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// PR #351 review P1 (config authority): derivation runs on the current config, so a candidate
|
|
43
|
+
// config edit could de-scope a baseline-governed path. Every resolver-selectable config source —
|
|
44
|
+
// `.agents/harness-everything/project-docs.json` and each host `<home>/harness-everything/manifest.json`
|
|
45
|
+
// for the resolver's PLATFORM_HOMES — is byte-compared at the recorded baseline commit and in the
|
|
46
|
+
// working tree. An edit, an addition or a removal all fail closed before derivation; absence of a
|
|
47
|
+
// source on either side is allowed and only a difference is fatal.
|
|
48
|
+
const PROJECT_DOCS_CONFIG_RELPATH = '.agents/harness-everything/project-docs.json';
|
|
49
|
+
|
|
50
|
+
function configSourcePaths(resolver) {
|
|
51
|
+
return [
|
|
52
|
+
PROJECT_DOCS_CONFIG_RELPATH,
|
|
53
|
+
...(resolver.PLATFORM_HOMES || []).map(home => `${home}/harness-everything/manifest.json`),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bytesAtBaseline(root, commit, relative) {
|
|
58
|
+
const listed = git(root, ['ls-tree', '-r', '--name-only', '-z', commit, '--', relative]);
|
|
59
|
+
if (!listed.split('\0').includes(relative)) return null; // absent at the baseline is allowed
|
|
60
|
+
return git(root, ['show', `${commit}:${relative}`]); // other Git failures stay baseline-unavailable
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bytesInTree(root, relative) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(path.join(root, relative), 'utf8');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') return null; // absent in the working tree is allowed
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertConfigScopeUnchanged(root, baseline, resolver) {
|
|
73
|
+
for (const relative of configSourcePaths(resolver)) {
|
|
74
|
+
if (bytesAtBaseline(root, baseline.commit, relative) !== bytesInTree(root, relative)) {
|
|
75
|
+
throw new Error('config-scope-changed');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collect(context) {
|
|
81
|
+
const baseline = context.workflow.docReconciliationBaseline;
|
|
82
|
+
if (!baseline) throw new Error('baseline-unavailable');
|
|
83
|
+
const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
|
|
84
|
+
if (root !== baseline.root || !/^[0-9a-f]{40,64}$/.test(baseline.commit)) throw new Error('baseline-unavailable');
|
|
85
|
+
const resolver = runtimeModule('project-docs-resolver');
|
|
86
|
+
const resolved = resolver.resolveDocsConfig(root);
|
|
87
|
+
if (!['project', 'host'].includes(resolved.status)) throw new Error('config-unavailable');
|
|
88
|
+
assertConfigScopeUnchanged(root, baseline, resolver);
|
|
89
|
+
const { readGitBaseInventory, scanWorkspaceFiles } = runtimeModule('doc-lifecycle');
|
|
90
|
+
// Broaden artifact discovery only, never the config passed to obligation derivation. Code/REQ-test
|
|
91
|
+
// facts require baseline bytes outside governed document globs as well.
|
|
92
|
+
const base = readGitBaseInventory(root, baseline.commit, { governed: [{ path: '**/*', role: 'reference' }] });
|
|
93
|
+
const basePaths = git(root, ['ls-tree', '-r', '--name-only', '-z', baseline.commit]).split('\0').filter(Boolean);
|
|
94
|
+
if (!base.available) throw new Error('baseline-unavailable');
|
|
95
|
+
if (base.files.length !== basePaths.length) throw new Error('scope-unavailable');
|
|
96
|
+
const paths = [...new Set(git(root, ['ls-files', '-c', '-o', '--exclude-standard', '-z']).split('\0').filter(Boolean))];
|
|
97
|
+
const existing = paths.filter(relative => {
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.lstatSync(path.join(root, relative)).isFile()) throw new Error('scope-unavailable');
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT') return false; // tracked deletion
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const candidate = scanWorkspaceFiles(root, { paths: existing });
|
|
107
|
+
if (candidate.length !== existing.length) throw new Error('scope-unavailable');
|
|
108
|
+
const snapshots = files => ({ files: Object.fromEntries(files.map(item => [item.path, item.bytes.toString('utf8')])) });
|
|
109
|
+
const { deriveDocFacts, deriveDocObligations } = runtimeModule('doc-obligations');
|
|
110
|
+
const { facts } = deriveDocFacts({ base: snapshots(base.files), candidate: snapshots(candidate), config: resolved.config, inventory: { complete: true } });
|
|
111
|
+
return deriveDocObligations(facts).filter(item => item.strength === 'MUST' && item.applicability !== 'N/A');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Assessment is deliberately negative-credit only in S1. Unsupported topologies never enter
|
|
115
|
+
// generic step reconciliation, and failures (including report persistence) never escape to Stop.
|
|
116
|
+
function assess(context, set) {
|
|
117
|
+
if (!enabled()) return { withhold: false };
|
|
118
|
+
const report = {
|
|
119
|
+
schemaVersion: 1, reportOrigin: 'runtime', pilotStatus: 'unavailable', pilotCredit: false,
|
|
120
|
+
identity: null, expectedMustIds: [], results: [], unsatisfied: [], diagnostics: [],
|
|
121
|
+
};
|
|
122
|
+
const supported = set?.stepBindingsEnabled === true && !String(context.workflow.strategy || '').startsWith('fable-');
|
|
123
|
+
let withhold = false;
|
|
124
|
+
if (!supported) report.diagnostics.push('unsupported-workflow');
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
const musts = collect(context);
|
|
128
|
+
withhold = musts.length > 0;
|
|
129
|
+
report.pilotStatus = 'unresolved';
|
|
130
|
+
report.expectedMustIds = musts.map(item => item.id);
|
|
131
|
+
report.unsatisfied = musts.map(item => ({
|
|
132
|
+
obligationId: item.id, state: item.applicability === 'unknown' ? 'unknown' : 'pending',
|
|
133
|
+
reasonCode: item.applicability === 'unknown' ? 'unknown-applicability' : 'producer-unavailable',
|
|
134
|
+
}));
|
|
135
|
+
if (!withhold) report.diagnostics.push('trusted-non-document-scope');
|
|
136
|
+
} catch (error) {
|
|
137
|
+
withhold = true;
|
|
138
|
+
const reason = ['baseline-unavailable', 'config-unavailable', 'scope-unavailable', 'config-scope-changed'].includes(error.message)
|
|
139
|
+
? error.message : 'collector-unavailable';
|
|
140
|
+
report.diagnostics.push(reason);
|
|
141
|
+
report.unsatisfied.push({ obligationId: 'doc-change:scope', state: 'unavailable', reasonCode: reason });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try { atomicWriteJson(path.join(context.sessionDir, 'doc-reconciliation-pilot.json'), report); }
|
|
145
|
+
catch {
|
|
146
|
+
if (supported) withhold = true;
|
|
147
|
+
console.error('[Workflow Reminder] Document pilot report unavailable; no document verification credit.');
|
|
148
|
+
}
|
|
149
|
+
return { withhold, report };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { enabled, captureBaseline, assess };
|
|
@@ -510,6 +510,33 @@ function planningUnresolved(context, plan = context.workflow?.pendingPlan || con
|
|
|
510
510
|
return [...new Set(unresolved)];
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
function withholdPilotVerification(context, set) {
|
|
514
|
+
// PR #351 review P2 (persistence failure): the credit decision is applied in memory first —
|
|
515
|
+
// aggregate verify becomes pending and a satisfied workflow becomes running — and only then is
|
|
516
|
+
// that state persisted best-effort. A failed obligations-file write records the
|
|
517
|
+
// withhold-persist-failed diagnostic and a reminder; it never escapes, never leaves the decision
|
|
518
|
+
// unapplied, and never changes the callers' refusal contract (exit 2 / step:<verify>:pending).
|
|
519
|
+
// PR #351 re-review P2a (scope of the recovery): this run made an actual ON, supported,
|
|
520
|
+
// withheld pilot decision. The marker is transient and per-run — it lives on the in-memory
|
|
521
|
+
// context object itself (saveWorkflow persists only context.workflow) and is never written
|
|
522
|
+
// to disk; Stop's pending-path persistence recovery is scoped by it.
|
|
523
|
+
context.docReconciliationWithheldRun = true;
|
|
524
|
+
const verify = set.obligations.find(item => item.id === 'verify');
|
|
525
|
+
if (verify) verify.status = 'pending';
|
|
526
|
+
if (context.workflow.state === 'satisfied') context.workflow.state = 'running';
|
|
527
|
+
try {
|
|
528
|
+
atomicWriteJson(obligationPath(context.sessionDir), set);
|
|
529
|
+
} catch {
|
|
530
|
+
if (!Array.isArray(context.workflow.docReconciliationDiagnostics)) {
|
|
531
|
+
context.workflow.docReconciliationDiagnostics = [];
|
|
532
|
+
}
|
|
533
|
+
if (!context.workflow.docReconciliationDiagnostics.includes('withhold-persist-failed')) {
|
|
534
|
+
context.workflow.docReconciliationDiagnostics.push('withhold-persist-failed');
|
|
535
|
+
}
|
|
536
|
+
console.error('[Workflow Reminder] Document verification withheld in memory; persisting the obligations state failed (withhold-persist-failed). No document verification credit.');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
513
540
|
function updateObligation(context, obligationId, disposition, options = {}) {
|
|
514
541
|
if (!SAFE_ID.test(obligationId || '')) throw new Error('--obligation-id must be a stable obligation id');
|
|
515
542
|
if (!['pass', 'escaped', 'blocked'].includes(disposition)) throw new Error('--disposition must be pass, escaped, or blocked');
|
|
@@ -529,6 +556,12 @@ function updateObligation(context, obligationId, disposition, options = {}) {
|
|
|
529
556
|
if (!scope) throw new Error('--scope is required when escaping an obligation');
|
|
530
557
|
}
|
|
531
558
|
if (disposition === 'blocked' && !reasonCode) throw new Error('--reason-code is required when blocking an obligation');
|
|
559
|
+
if (disposition === 'pass' && ['execute', 'verify'].includes(obligationId)
|
|
560
|
+
&& require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
561
|
+
obligation.status = 'pending';
|
|
562
|
+
withholdPilotVerification(context, loaded.set);
|
|
563
|
+
throw new Error('document verification pending: S1 grants no document credit');
|
|
564
|
+
}
|
|
532
565
|
obligation.status = disposition;
|
|
533
566
|
obligation.evidence = evidence;
|
|
534
567
|
obligation.reasonCode = reasonCode;
|
|
@@ -635,6 +668,11 @@ function updateStep(context, stepId, disposition, options = {}) {
|
|
|
635
668
|
throw new Error('resolve active-step bindings before passing the step: ' + ids.join(', '));
|
|
636
669
|
}
|
|
637
670
|
}
|
|
671
|
+
if (disposition === 'pass' && step.stepType === 'verification'
|
|
672
|
+
&& require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
673
|
+
withholdPilotVerification(context, loaded.set);
|
|
674
|
+
throw new Error('document verification pending: S1 grants no document credit');
|
|
675
|
+
}
|
|
638
676
|
step.status = disposition;
|
|
639
677
|
step.evidence = evidence;
|
|
640
678
|
step.reasonCode = reasonCode;
|
|
@@ -651,6 +689,12 @@ function unresolvedObligations(context) {
|
|
|
651
689
|
const unresolved = planningUnresolved(context, plan);
|
|
652
690
|
const loaded = loadObligationSet(context, plan);
|
|
653
691
|
if (!loaded.set) return unresolved;
|
|
692
|
+
if (!unresolved.length && require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
693
|
+
withholdPilotVerification(context, loaded.set);
|
|
694
|
+
for (const step of loaded.set.requirements.filter(item => item.stepType === 'verification')) {
|
|
695
|
+
unresolved.push('step:' + step.id + ':pending');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
654
698
|
if (!['running', 'satisfied', 'blocked', 'failed'].includes(context.workflow.state)) {
|
|
655
699
|
unresolved.push('execution:not-started');
|
|
656
700
|
}
|
|
@@ -173,6 +173,7 @@ function main() {
|
|
|
173
173
|
if (!['pending', 'active'].includes(workflow.state)) throw new Error('requirements/workflow composition belongs before execution starts');
|
|
174
174
|
const selectedPlan = recomposePlan(workflow, args.strategy);
|
|
175
175
|
const result = recordPlanning(context, args.requirementsJson, selectedPlan, args.strategy, args.evidence);
|
|
176
|
+
require('./lib/doc-reconciliation-pilot').captureBaseline(context);
|
|
176
177
|
saveWorkflow(context);
|
|
177
178
|
process.stdout.write(JSON.stringify({
|
|
178
179
|
state: workflow.state,
|
|
@@ -192,6 +193,7 @@ function main() {
|
|
|
192
193
|
const unresolvedPlanning = planningUnresolved(context, plan);
|
|
193
194
|
if (unresolvedPlanning.length) throw new Error('resolve requirement decomposition/workflow selection first: ' + unresolvedPlanning.join(', '));
|
|
194
195
|
|
|
196
|
+
require('./lib/doc-reconciliation-pilot').captureBaseline(context);
|
|
195
197
|
if (!String(plan.strategy || '').startsWith('fable-')) {
|
|
196
198
|
activatePlan(workflow, plan);
|
|
197
199
|
workflow.revision++;
|
|
@@ -36,6 +36,7 @@ function decide(payload) {
|
|
|
36
36
|
|
|
37
37
|
required(() => planningUnresolved(context));
|
|
38
38
|
if (String(workflow.strategy || '').startsWith('fable-')) {
|
|
39
|
+
if (!unresolved.length) require('./lib/doc-reconciliation-pilot').assess(context, null);
|
|
39
40
|
const match = matchingRun(context);
|
|
40
41
|
unresolved.push(...(match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing']));
|
|
41
42
|
} else {
|
|
@@ -52,7 +53,29 @@ function decide(payload) {
|
|
|
52
53
|
// A failed required validation leaves the workflow file as recorded; it is never marked satisfied.
|
|
53
54
|
if (!requiredFault) {
|
|
54
55
|
workflow.unresolved = pending;
|
|
55
|
-
|
|
56
|
+
// PR #351 follow-up (unwritable session store): persisting the pending downgrade is
|
|
57
|
+
// best-effort. A failing saveWorkflow() must not swallow the decision or leak exception
|
|
58
|
+
// text: record the withhold-persist-failed diagnostic on the shared context diagnostics
|
|
59
|
+
// (also when the earlier obligations write already failed) and still emit the reminder
|
|
60
|
+
// below. Stop stays non-blocking: exit 0, no retry loop, no lock.
|
|
61
|
+
// Scope of the recovery (PR #351 re-review): the local recovery and the
|
|
62
|
+
// withhold-persist-failed diagnostic apply only when this Stop run made an actual ON,
|
|
63
|
+
// supported, withheld pilot decision (`withholdPilotVerification` sets the transient
|
|
64
|
+
// per-run marker on the context). On switch-OFF, Fable and persisted-untyped paths the
|
|
65
|
+
// marker is never set, so the error is rethrown and reaches Stop's outer handler exactly
|
|
66
|
+
// as the pre-S1 legacy reference: same stderr, same exit 0, no pilot diagnostic.
|
|
67
|
+
try {
|
|
68
|
+
saveWorkflow(context);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (context.docReconciliationWithheldRun !== true) throw error;
|
|
71
|
+
if (!Array.isArray(context.workflow.docReconciliationDiagnostics)) {
|
|
72
|
+
context.workflow.docReconciliationDiagnostics = [];
|
|
73
|
+
}
|
|
74
|
+
if (!context.workflow.docReconciliationDiagnostics.includes('withhold-persist-failed')) {
|
|
75
|
+
context.workflow.docReconciliationDiagnostics.push('withhold-persist-failed');
|
|
76
|
+
}
|
|
77
|
+
warn('Pending-workflow persistence failed (withhold-persist-failed); the unresolved evidence below is still binding.');
|
|
78
|
+
}
|
|
56
79
|
}
|
|
57
80
|
warn('Unresolved workflow evidence: ' + pending.join(', ') + '. Resolve/verify before claiming completion.');
|
|
58
81
|
return;
|
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { atomicWriteJson } = require('./fable-contracts');
|
|
7
|
+
|
|
8
|
+
const enabled = () => process.env.HARNESS_DOC_RECONCILIATION_PILOT === '1';
|
|
9
|
+
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
const env = { ...process.env };
|
|
12
|
+
for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
15
|
+
env, encoding: 'utf8', timeout: 10000, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw new Error('baseline-unavailable', { cause });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runtimeModule(name) {
|
|
23
|
+
for (const relative of ['../../../harness-everything/scripts', '../../../skills/harness-everything/scripts']) {
|
|
24
|
+
const file = path.resolve(__dirname, relative, name + '.js');
|
|
25
|
+
if (fs.existsSync(file)) return require(file);
|
|
26
|
+
}
|
|
27
|
+
throw new Error('document runtime unavailable');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only capture at plan/start. A later credit assessment must never advance the baseline to HEAD.
|
|
31
|
+
function captureBaseline(context) {
|
|
32
|
+
if (!enabled() || context.workflow.docReconciliationBaseline) return;
|
|
33
|
+
try {
|
|
34
|
+
const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
|
|
35
|
+
const commit = git(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
|
|
36
|
+
context.workflow.docReconciliationBaseline = { root, commit };
|
|
37
|
+
} catch {
|
|
38
|
+
// Absence is intentionally retained; credit assessment will fail closed.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// PR #351 review P1 (config authority): derivation runs on the current config, so a candidate
|
|
43
|
+
// config edit could de-scope a baseline-governed path. Every resolver-selectable config source —
|
|
44
|
+
// `.agents/harness-everything/project-docs.json` and each host `<home>/harness-everything/manifest.json`
|
|
45
|
+
// for the resolver's PLATFORM_HOMES — is byte-compared at the recorded baseline commit and in the
|
|
46
|
+
// working tree. An edit, an addition or a removal all fail closed before derivation; absence of a
|
|
47
|
+
// source on either side is allowed and only a difference is fatal.
|
|
48
|
+
const PROJECT_DOCS_CONFIG_RELPATH = '.agents/harness-everything/project-docs.json';
|
|
49
|
+
|
|
50
|
+
function configSourcePaths(resolver) {
|
|
51
|
+
return [
|
|
52
|
+
PROJECT_DOCS_CONFIG_RELPATH,
|
|
53
|
+
...(resolver.PLATFORM_HOMES || []).map(home => `${home}/harness-everything/manifest.json`),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bytesAtBaseline(root, commit, relative) {
|
|
58
|
+
const listed = git(root, ['ls-tree', '-r', '--name-only', '-z', commit, '--', relative]);
|
|
59
|
+
if (!listed.split('\0').includes(relative)) return null; // absent at the baseline is allowed
|
|
60
|
+
return git(root, ['show', `${commit}:${relative}`]); // other Git failures stay baseline-unavailable
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bytesInTree(root, relative) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(path.join(root, relative), 'utf8');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') return null; // absent in the working tree is allowed
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertConfigScopeUnchanged(root, baseline, resolver) {
|
|
73
|
+
for (const relative of configSourcePaths(resolver)) {
|
|
74
|
+
if (bytesAtBaseline(root, baseline.commit, relative) !== bytesInTree(root, relative)) {
|
|
75
|
+
throw new Error('config-scope-changed');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collect(context) {
|
|
81
|
+
const baseline = context.workflow.docReconciliationBaseline;
|
|
82
|
+
if (!baseline) throw new Error('baseline-unavailable');
|
|
83
|
+
const root = git(context.root, ['rev-parse', '--show-toplevel']).trim();
|
|
84
|
+
if (root !== baseline.root || !/^[0-9a-f]{40,64}$/.test(baseline.commit)) throw new Error('baseline-unavailable');
|
|
85
|
+
const resolver = runtimeModule('project-docs-resolver');
|
|
86
|
+
const resolved = resolver.resolveDocsConfig(root);
|
|
87
|
+
if (!['project', 'host'].includes(resolved.status)) throw new Error('config-unavailable');
|
|
88
|
+
assertConfigScopeUnchanged(root, baseline, resolver);
|
|
89
|
+
const { readGitBaseInventory, scanWorkspaceFiles } = runtimeModule('doc-lifecycle');
|
|
90
|
+
// Broaden artifact discovery only, never the config passed to obligation derivation. Code/REQ-test
|
|
91
|
+
// facts require baseline bytes outside governed document globs as well.
|
|
92
|
+
const base = readGitBaseInventory(root, baseline.commit, { governed: [{ path: '**/*', role: 'reference' }] });
|
|
93
|
+
const basePaths = git(root, ['ls-tree', '-r', '--name-only', '-z', baseline.commit]).split('\0').filter(Boolean);
|
|
94
|
+
if (!base.available) throw new Error('baseline-unavailable');
|
|
95
|
+
if (base.files.length !== basePaths.length) throw new Error('scope-unavailable');
|
|
96
|
+
const paths = [...new Set(git(root, ['ls-files', '-c', '-o', '--exclude-standard', '-z']).split('\0').filter(Boolean))];
|
|
97
|
+
const existing = paths.filter(relative => {
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.lstatSync(path.join(root, relative)).isFile()) throw new Error('scope-unavailable');
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT') return false; // tracked deletion
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const candidate = scanWorkspaceFiles(root, { paths: existing });
|
|
107
|
+
if (candidate.length !== existing.length) throw new Error('scope-unavailable');
|
|
108
|
+
const snapshots = files => ({ files: Object.fromEntries(files.map(item => [item.path, item.bytes.toString('utf8')])) });
|
|
109
|
+
const { deriveDocFacts, deriveDocObligations } = runtimeModule('doc-obligations');
|
|
110
|
+
const { facts } = deriveDocFacts({ base: snapshots(base.files), candidate: snapshots(candidate), config: resolved.config, inventory: { complete: true } });
|
|
111
|
+
return deriveDocObligations(facts).filter(item => item.strength === 'MUST' && item.applicability !== 'N/A');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Assessment is deliberately negative-credit only in S1. Unsupported topologies never enter
|
|
115
|
+
// generic step reconciliation, and failures (including report persistence) never escape to Stop.
|
|
116
|
+
function assess(context, set) {
|
|
117
|
+
if (!enabled()) return { withhold: false };
|
|
118
|
+
const report = {
|
|
119
|
+
schemaVersion: 1, reportOrigin: 'runtime', pilotStatus: 'unavailable', pilotCredit: false,
|
|
120
|
+
identity: null, expectedMustIds: [], results: [], unsatisfied: [], diagnostics: [],
|
|
121
|
+
};
|
|
122
|
+
const supported = set?.stepBindingsEnabled === true && !String(context.workflow.strategy || '').startsWith('fable-');
|
|
123
|
+
let withhold = false;
|
|
124
|
+
if (!supported) report.diagnostics.push('unsupported-workflow');
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
const musts = collect(context);
|
|
128
|
+
withhold = musts.length > 0;
|
|
129
|
+
report.pilotStatus = 'unresolved';
|
|
130
|
+
report.expectedMustIds = musts.map(item => item.id);
|
|
131
|
+
report.unsatisfied = musts.map(item => ({
|
|
132
|
+
obligationId: item.id, state: item.applicability === 'unknown' ? 'unknown' : 'pending',
|
|
133
|
+
reasonCode: item.applicability === 'unknown' ? 'unknown-applicability' : 'producer-unavailable',
|
|
134
|
+
}));
|
|
135
|
+
if (!withhold) report.diagnostics.push('trusted-non-document-scope');
|
|
136
|
+
} catch (error) {
|
|
137
|
+
withhold = true;
|
|
138
|
+
const reason = ['baseline-unavailable', 'config-unavailable', 'scope-unavailable', 'config-scope-changed'].includes(error.message)
|
|
139
|
+
? error.message : 'collector-unavailable';
|
|
140
|
+
report.diagnostics.push(reason);
|
|
141
|
+
report.unsatisfied.push({ obligationId: 'doc-change:scope', state: 'unavailable', reasonCode: reason });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try { atomicWriteJson(path.join(context.sessionDir, 'doc-reconciliation-pilot.json'), report); }
|
|
145
|
+
catch {
|
|
146
|
+
if (supported) withhold = true;
|
|
147
|
+
console.error('[Workflow Reminder] Document pilot report unavailable; no document verification credit.');
|
|
148
|
+
}
|
|
149
|
+
return { withhold, report };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { enabled, captureBaseline, assess };
|
|
@@ -510,6 +510,33 @@ function planningUnresolved(context, plan = context.workflow?.pendingPlan || con
|
|
|
510
510
|
return [...new Set(unresolved)];
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
function withholdPilotVerification(context, set) {
|
|
514
|
+
// PR #351 review P2 (persistence failure): the credit decision is applied in memory first —
|
|
515
|
+
// aggregate verify becomes pending and a satisfied workflow becomes running — and only then is
|
|
516
|
+
// that state persisted best-effort. A failed obligations-file write records the
|
|
517
|
+
// withhold-persist-failed diagnostic and a reminder; it never escapes, never leaves the decision
|
|
518
|
+
// unapplied, and never changes the callers' refusal contract (exit 2 / step:<verify>:pending).
|
|
519
|
+
// PR #351 re-review P2a (scope of the recovery): this run made an actual ON, supported,
|
|
520
|
+
// withheld pilot decision. The marker is transient and per-run — it lives on the in-memory
|
|
521
|
+
// context object itself (saveWorkflow persists only context.workflow) and is never written
|
|
522
|
+
// to disk; Stop's pending-path persistence recovery is scoped by it.
|
|
523
|
+
context.docReconciliationWithheldRun = true;
|
|
524
|
+
const verify = set.obligations.find(item => item.id === 'verify');
|
|
525
|
+
if (verify) verify.status = 'pending';
|
|
526
|
+
if (context.workflow.state === 'satisfied') context.workflow.state = 'running';
|
|
527
|
+
try {
|
|
528
|
+
atomicWriteJson(obligationPath(context.sessionDir), set);
|
|
529
|
+
} catch {
|
|
530
|
+
if (!Array.isArray(context.workflow.docReconciliationDiagnostics)) {
|
|
531
|
+
context.workflow.docReconciliationDiagnostics = [];
|
|
532
|
+
}
|
|
533
|
+
if (!context.workflow.docReconciliationDiagnostics.includes('withhold-persist-failed')) {
|
|
534
|
+
context.workflow.docReconciliationDiagnostics.push('withhold-persist-failed');
|
|
535
|
+
}
|
|
536
|
+
console.error('[Workflow Reminder] Document verification withheld in memory; persisting the obligations state failed (withhold-persist-failed). No document verification credit.');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
513
540
|
function updateObligation(context, obligationId, disposition, options = {}) {
|
|
514
541
|
if (!SAFE_ID.test(obligationId || '')) throw new Error('--obligation-id must be a stable obligation id');
|
|
515
542
|
if (!['pass', 'escaped', 'blocked'].includes(disposition)) throw new Error('--disposition must be pass, escaped, or blocked');
|
|
@@ -529,6 +556,12 @@ function updateObligation(context, obligationId, disposition, options = {}) {
|
|
|
529
556
|
if (!scope) throw new Error('--scope is required when escaping an obligation');
|
|
530
557
|
}
|
|
531
558
|
if (disposition === 'blocked' && !reasonCode) throw new Error('--reason-code is required when blocking an obligation');
|
|
559
|
+
if (disposition === 'pass' && ['execute', 'verify'].includes(obligationId)
|
|
560
|
+
&& require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
561
|
+
obligation.status = 'pending';
|
|
562
|
+
withholdPilotVerification(context, loaded.set);
|
|
563
|
+
throw new Error('document verification pending: S1 grants no document credit');
|
|
564
|
+
}
|
|
532
565
|
obligation.status = disposition;
|
|
533
566
|
obligation.evidence = evidence;
|
|
534
567
|
obligation.reasonCode = reasonCode;
|
|
@@ -635,6 +668,11 @@ function updateStep(context, stepId, disposition, options = {}) {
|
|
|
635
668
|
throw new Error('resolve active-step bindings before passing the step: ' + ids.join(', '));
|
|
636
669
|
}
|
|
637
670
|
}
|
|
671
|
+
if (disposition === 'pass' && step.stepType === 'verification'
|
|
672
|
+
&& require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
673
|
+
withholdPilotVerification(context, loaded.set);
|
|
674
|
+
throw new Error('document verification pending: S1 grants no document credit');
|
|
675
|
+
}
|
|
638
676
|
step.status = disposition;
|
|
639
677
|
step.evidence = evidence;
|
|
640
678
|
step.reasonCode = reasonCode;
|
|
@@ -651,6 +689,12 @@ function unresolvedObligations(context) {
|
|
|
651
689
|
const unresolved = planningUnresolved(context, plan);
|
|
652
690
|
const loaded = loadObligationSet(context, plan);
|
|
653
691
|
if (!loaded.set) return unresolved;
|
|
692
|
+
if (!unresolved.length && require('./doc-reconciliation-pilot').assess(context, loaded.set).withhold) {
|
|
693
|
+
withholdPilotVerification(context, loaded.set);
|
|
694
|
+
for (const step of loaded.set.requirements.filter(item => item.stepType === 'verification')) {
|
|
695
|
+
unresolved.push('step:' + step.id + ':pending');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
654
698
|
if (!['running', 'satisfied', 'blocked', 'failed'].includes(context.workflow.state)) {
|
|
655
699
|
unresolved.push('execution:not-started');
|
|
656
700
|
}
|
|
@@ -173,6 +173,7 @@ function main() {
|
|
|
173
173
|
if (!['pending', 'active'].includes(workflow.state)) throw new Error('requirements/workflow composition belongs before execution starts');
|
|
174
174
|
const selectedPlan = recomposePlan(workflow, args.strategy);
|
|
175
175
|
const result = recordPlanning(context, args.requirementsJson, selectedPlan, args.strategy, args.evidence);
|
|
176
|
+
require('./lib/doc-reconciliation-pilot').captureBaseline(context);
|
|
176
177
|
saveWorkflow(context);
|
|
177
178
|
process.stdout.write(JSON.stringify({
|
|
178
179
|
state: workflow.state,
|
|
@@ -192,6 +193,7 @@ function main() {
|
|
|
192
193
|
const unresolvedPlanning = planningUnresolved(context, plan);
|
|
193
194
|
if (unresolvedPlanning.length) throw new Error('resolve requirement decomposition/workflow selection first: ' + unresolvedPlanning.join(', '));
|
|
194
195
|
|
|
196
|
+
require('./lib/doc-reconciliation-pilot').captureBaseline(context);
|
|
195
197
|
if (!String(plan.strategy || '').startsWith('fable-')) {
|
|
196
198
|
activatePlan(workflow, plan);
|
|
197
199
|
workflow.revision++;
|
|
@@ -36,6 +36,7 @@ function decide(payload) {
|
|
|
36
36
|
|
|
37
37
|
required(() => planningUnresolved(context));
|
|
38
38
|
if (String(workflow.strategy || '').startsWith('fable-')) {
|
|
39
|
+
if (!unresolved.length) require('./lib/doc-reconciliation-pilot').assess(context, null);
|
|
39
40
|
const match = matchingRun(context);
|
|
40
41
|
unresolved.push(...(match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing']));
|
|
41
42
|
} else {
|
|
@@ -52,7 +53,29 @@ function decide(payload) {
|
|
|
52
53
|
// A failed required validation leaves the workflow file as recorded; it is never marked satisfied.
|
|
53
54
|
if (!requiredFault) {
|
|
54
55
|
workflow.unresolved = pending;
|
|
55
|
-
|
|
56
|
+
// PR #351 follow-up (unwritable session store): persisting the pending downgrade is
|
|
57
|
+
// best-effort. A failing saveWorkflow() must not swallow the decision or leak exception
|
|
58
|
+
// text: record the withhold-persist-failed diagnostic on the shared context diagnostics
|
|
59
|
+
// (also when the earlier obligations write already failed) and still emit the reminder
|
|
60
|
+
// below. Stop stays non-blocking: exit 0, no retry loop, no lock.
|
|
61
|
+
// Scope of the recovery (PR #351 re-review): the local recovery and the
|
|
62
|
+
// withhold-persist-failed diagnostic apply only when this Stop run made an actual ON,
|
|
63
|
+
// supported, withheld pilot decision (`withholdPilotVerification` sets the transient
|
|
64
|
+
// per-run marker on the context). On switch-OFF, Fable and persisted-untyped paths the
|
|
65
|
+
// marker is never set, so the error is rethrown and reaches Stop's outer handler exactly
|
|
66
|
+
// as the pre-S1 legacy reference: same stderr, same exit 0, no pilot diagnostic.
|
|
67
|
+
try {
|
|
68
|
+
saveWorkflow(context);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (context.docReconciliationWithheldRun !== true) throw error;
|
|
71
|
+
if (!Array.isArray(context.workflow.docReconciliationDiagnostics)) {
|
|
72
|
+
context.workflow.docReconciliationDiagnostics = [];
|
|
73
|
+
}
|
|
74
|
+
if (!context.workflow.docReconciliationDiagnostics.includes('withhold-persist-failed')) {
|
|
75
|
+
context.workflow.docReconciliationDiagnostics.push('withhold-persist-failed');
|
|
76
|
+
}
|
|
77
|
+
warn('Pending-workflow persistence failed (withhold-persist-failed); the unresolved evidence below is still binding.');
|
|
78
|
+
}
|
|
56
79
|
}
|
|
57
80
|
warn('Unresolved workflow evidence: ' + pending.join(', ') + '. Resolve/verify before claiming completion.');
|
|
58
81
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.38.0",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
|
@@ -2396,6 +2396,6 @@ function main(argv = process.argv.slice(2)) {
|
|
|
2396
2396
|
return analysis.diagnostics.some(item => item.severity === 'error') ? 1 : 0;
|
|
2397
2397
|
}
|
|
2398
2398
|
|
|
2399
|
-
module.exports = { parseFrontmatter, parseRequirementBlocks, collectDocs, analyzeDocs, resolveRef, lintDocs, buildIndex, diffRevisions };
|
|
2399
|
+
module.exports = { readGitBaseInventory, scanWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, analyzeDocs, resolveRef, lintDocs, buildIndex, diffRevisions };
|
|
2400
2400
|
|
|
2401
2401
|
if (require.main === module) process.exitCode = main();
|