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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +24 -0
  4. package/ci/fixtures/doc-obligations/providers-s3/cases.json +535 -0
  5. package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +70 -11
  6. package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +24 -556
  7. package/ci/fixtures/doc-obligations/reconciliation-prep/provider-contract.json +1 -1
  8. package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +6842 -0
  9. package/ci/fixtures/doc-obligations/reconciliation-s1/fail-session-writes.js +116 -0
  10. package/ci/lib/doc-reconciliation-prep-fixtures.js +116 -10
  11. package/ci/mechanism-325-3b-preparation.test.js +8 -8
  12. package/ci/mechanism-325-3b-s1-review.test.js +831 -0
  13. package/ci/mechanism-325-3b-s1.test.js +188 -0
  14. package/ci/mechanism-325-3b-s3-providers.test.js +378 -0
  15. package/ci/prep/doc-reconciliation-red.js +2 -1
  16. package/docs/doc-workflow-reconciliation.md +333 -28
  17. package/docs/document-lifecycle.md +4 -4
  18. package/harness-everything/SKILL.md +1 -1
  19. package/harness-everything/scripts/doc-lifecycle.js +1 -1
  20. package/harness-everything/scripts/doc-providers.js +113 -0
  21. package/hooks/scripts/lib/doc-reconciliation-pilot.js +152 -0
  22. package/hooks/scripts/lib/workflow-obligations.js +44 -0
  23. package/hooks/scripts/workflow-disposition.js +2 -0
  24. package/hooks/scripts/workflow-stop-gate.js +24 -1
  25. package/opencode-plugin/plugin.json +1 -1
  26. package/package.json +1 -1
  27. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  28. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +152 -0
  29. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +44 -0
  30. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -0
  31. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +24 -1
  32. package/plugins/harness-everything/plugin.json +1 -1
  33. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  34. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +1 -1
  35. package/plugins/harness-everything/skills/harness-everything/scripts/doc-providers.js +113 -0
  36. 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
- saveWorkflow(context);
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
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.36.4",
4
+ "version": "0.38.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.36.4",
3
+ "version": "0.38.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.36.4",
3
+ "version": "0.38.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -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
- saveWorkflow(context);
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.36.4",
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",
@@ -4,7 +4,7 @@ description: "Route software/project work through Harness: classify tier/topolog
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.36.4
7
+ version: 0.38.0
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -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();