harness-everything 0.47.5 → 0.49.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 (35) hide show
  1. package/.agents/harness-everything/project-docs.json +2 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +36 -0
  5. package/ci/fixtures/doc-obligations/reconciliation-s2a/run13-workflow-requirements.json +42 -0
  6. package/ci/mechanism-e1-dependency-artifact-scope.test.js +657 -0
  7. package/ci/mechanism-prb-outline-review.test.js +966 -48
  8. package/docs/doc-workflow-reconciliation.md +50 -2
  9. package/harness-everything/SKILL.md +1 -1
  10. package/harness-everything/scripts/doc-lifecycle.js +11 -3
  11. package/harness-everything/scripts/project-docs-resolver.js +12 -0
  12. package/hooks/scripts/lib/doc-reconciliation-core.js +56 -0
  13. package/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
  14. package/hooks/scripts/lib/doc-reconciliation-provider.js +334 -10
  15. package/hooks/scripts/state-persist.js +5 -0
  16. package/multi-agent-workspace/SKILL.md +1 -1
  17. package/multi-agent-workspace/scripts/project-docs-resolver.js +12 -0
  18. package/opencode-plugin/plugin.json +1 -1
  19. package/package.json +1 -1
  20. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  21. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-core.js +56 -0
  22. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-producer.js +274 -12
  23. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +334 -10
  24. package/plugins/harness-everything/hooks/scripts/state-persist.js +5 -0
  25. package/plugins/harness-everything/plugin.json +1 -1
  26. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  27. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +11 -3
  28. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +12 -0
  29. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  30. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +12 -0
  31. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  32. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +12 -0
  33. package/sbom.cdx.json +7 -7
  34. package/to-spec/SKILL.md +1 -1
  35. package/to-spec/scripts/project-docs-resolver.js +12 -0
@@ -406,7 +406,9 @@ cannot replace that baseline with the latest HEAD or trust a persisted PASS.
406
406
  and governed cases exercise actual diffs. Fixtures without `baseFiles` remain non-Git, so their
407
407
  baseline is unavailable and credit is withheld. Fixture maps alone are not runtime authority.
408
408
 
409
- - **Accepted S1 residual (gitignored paths):** ignored, untracked candidate content is outside the non-ignored inventory. S1 grants no positive credit either way; the S2 producer must close this gap or explicitly re-accept it.
409
+ - **F4 inventory coverage (S2 producer):** ignored and untracked paths remain in the candidate inventory, including new files and rename destinations, except for the bounded dependency-artifact policy below. With no policy, there are no new exclusions.
410
+ - **Dependency-artifact policy:** the original project config `.agents/harness-everything/project-docs.json` may set `inventory.dependencyArtifactRoots` to any of `node_modules` and `.husky/_` (each is optional). These exact roots are pinned from baseline B; the candidate field must match B. Candidate-only config, host config, `.gitignore`, globs, aliases and other paths do not authorize pruning.
411
+ - Before pruning, baseline and current-index paths, config sources, governed/legacy selectors, derived sources/outputs, and explicit task inputs are checked for overlap. Any overlap, ambiguous selector, unsafe root (file, symlink, alias, or outside the workspace), or retained-path symlink makes capture unavailable. Package files and `.bin` symlinks inside an approved root are not traversed; lockfiles, other paths, and ignored content outside the roots remain covered. Exclusion means installed bytes are outside this task's identity and ordinary code/frontmatter facts; it does not attest dependency contents or execution behavior.
410
412
 
411
413
  ### Refusal and completion contract
412
414
 
@@ -1258,7 +1260,53 @@ flowchart LR
1258
1260
  | Worker and D | The dispatch key is `(session_id, tool_use_id)`. Exactly one matching Task/Agent result must carry `tool_response.status === 'completed'`; its observed `tool_response.agentId` must map to exactly one `SubagentStart` and one `SubagentStop` with the same `session_id` and `agent_id`. Require PreToolUse < SubagentStart < SubagentStop in journal order, but do not order the completed result against Stop. Both worker events need exact canonical `cwd === workerRoot`. `prompt_id` and `parent_tool_use_id` are not correlation keys; `interrupted` is not completion proof. Async launches, failures, cancellation, missing/duplicate events, replayed IDs, worktree isolation and root mismatch stay unresolved. D is checked against the current candidate separately from O. O must be a strict ancestor of D. A squash may retain the cycle only when a coordinator correspondence record proves identical target blobs between retained pre-merge D and the merged tree; changed bytes need fresh final quality review. |
1259
1261
  | Checker outcome | `outline-confirmation` has its own pass/unresolved lane. Generic command success and waivers never satisfy it. No D is incomplete/unresolved. Its result is separate from the aggregate result over every MUST derived from original B→D; an outline pass never waives lint, conflict, quality review, requirement-sync or other MUSTs. |
1260
1262
 
1261
- Capture and validation failures stay fail-open to host tools and withhold evidence credit. Unclassified shell commands and writes outside the correlated worker/root remain unresolved. A same-root subagent start is unresolved as a competing writer when it may overlap the exclusive draft interval, from accepted dispatch PreToolUse through the uniquely correlated draft worker’s Stop. A uniquely identified agent whose Start and unique Stop both precede dispatch, or whose Start occurs strictly after that worker Stop, does not establish overlap by its Start alone. Missing, duplicated or inconsistent lifecycle evidence stays unresolved. This exception does not authorize writes outside the correlated draft worker/root or relax independent review, artifact identity or final-byte validation.
1263
+ Capture and validation failures stay fail-open to host tools and withhold evidence credit. Writer activity is decided per captured record by the phase-aware policy below (see [Writer policy](#writer-policy-phase-aware)); anything it does not allow stays `outline-writer-activity-ambiguous`. A same-root subagent start is unresolved as a competing writer when it may overlap the exclusive draft interval, from accepted dispatch PreToolUse through the uniquely correlated draft worker’s Stop. A uniquely identified agent whose Start and unique Stop both precede dispatch, or whose Start occurs strictly after that worker Stop, does not establish overlap by its Start alone. Missing, duplicated or inconsistent lifecycle evidence stays unresolved. This exception does not authorize writes outside the correlated draft worker/root or relax independent review, artifact identity or final-byte validation.
1264
+
1265
+ ### Writer policy (phase-aware)
1266
+
1267
+ Each captured writer record is decided by principal × phase × operation × target. Captured identity and journal position are the only inputs; a command string never decides on its own. This replaces the earlier blanket rule that rejected any writer other than the draft worker.
1268
+
1269
+ ```mermaid
1270
+ flowchart LR
1271
+ R["captured writer record"] --> P{"journal phase"}
1272
+ P -->|"before reviewer launch"| B["B→O: coordinator writes or git add/commit of the pinned target"]
1273
+ P -->|"reviewer launch to its Stop"| V["review: no writer"]
1274
+ P -->|"review Stop to dispatch"| H["review→dispatch: no writer (W3 unverifiable)"]
1275
+ P -->|"dispatch to worker Stop"| D["draft: correlated worker in its root"]
1276
+ P -->|"after worker Stop"| A["after D: no writer"]
1277
+ B --> S["O commit must change exactly the pinned target(s) against B"]
1278
+ ```
1279
+
1280
+ | Phase | Principal | Operation and target | Result |
1281
+ |---|---|---|---|
1282
+ | Before reviewer launch | Coordinator: no `agent_id`, author `session_id` | `Write`, `Edit` or `apply_patch` whose every path is a pinned target | Allowed |
1283
+ | Before reviewer launch | Coordinator | `git add [--] <literal pinned file>...` or `git commit -m <message>`; R11-1 (b): record before review, validate at dispatch; uncaptured/pre-B index effects are UNCHECKED | Allowed only under this grammar; unbound or unverified index effects receive no credit |
1284
+ | Before reviewer launch | Any | Any other write, chained or unknown shell command, or a path that is not the target | `outline-writer-activity-ambiguous` |
1285
+ | Review | Reviewer or any other agent | Any writer | Ambiguous |
1286
+ | Review to dispatch | Coordinator | Any write, including handoff, lease, pin and merge-correspondence files | Ambiguous |
1287
+ | Dispatch to worker Stop | Correlated worker, `cwd` equal to the worker root | Any writer | Allowed |
1288
+ | Dispatch to worker Stop | Other agent or coordinator | Any writer | Ambiguous; an overlapping same-root start is `outline-competing-writer-start` |
1289
+ | After worker Stop | Any | Any writer | Ambiguous |
1290
+
1291
+ - The reviewer launch is the first journal record that is the reviewer's own, or the launch result sharing its `tool_use_id`. The review ends at the reviewer's `SubagentStop`.
1292
+ - Write-family records carry `writeTargets` (the `file_path`, or the `apply_patch` headers). A record without a target is ambiguous.
1293
+ - **Exact target spelling.** For pinned `T` under captured root `R`, credit requires every extracted file-tool or Git-add operand to match `T`, `R/T` or product-canonical `C/T` byte-for-byte (Windows native-separator forms are allowed only for file tools), with captured cwd exactly `R` or `C`; normalization, folding, expansion and URL decoding never create identity. Other host renderings remain UNCHECKED without retained payload.
1294
+ - **F1 symlink boundary.** Coordinator writer credit requires an in-root path with no symlink components and a regular current leaf (Git add also requires a regular leaf at PreToolUse); O citations are parsed only from an exact pinned-tree `blob` entry of mode `100644` or `100755`. The PreToolUse topology snapshot does not lock the path through the operation, so any unbound operation-time state is UNCHECKED; locally rewritten events remain within the F13 limit.
1295
+ - The O commit scope check requires `git diff --name-only B O` to equal the pinned target paths, otherwise `outline-commit-scope-invalid`.
1296
+ - **Pre-review Git identity (W2).** This is the immutable Git object identity of Outline O, taken before independent review. It is not the Git author's name or email. Harness records `{baselineCommit, outlineCommit, outlineTree, targetPaths, targetBlobs}` (per target: `blobOid` and `storedBytesSha256`) at each `SubagentStart` inside an outline task. The reviewer's launch record therefore predates its review, and the reviewer does not echo any identity. Dispatch requires the handoff identity to equal the reviewer's record exactly; any difference is `outline-identity-mismatch`, and a fresh review is needed for a new O.
1297
+
1298
+ ```mermaid
1299
+ flowchart LR
1300
+ S["reviewer SubagentStart<br/>records O identity"] --> R["review<br/>bound to that record"]
1301
+ R --> D{"dispatch: handoff identity equal?"}
1302
+ D -->|"yes"| W["worker starts from clean O"]
1303
+ D -->|"no: amend, replacement, tree or target change"| X["outline-identity-mismatch: fresh review"]
1304
+ ```
1305
+
1306
+ - Suggested contract wording (owner, #341 comment 6708822276): "Exact task and target-path binding, captured writer operations, and immutable Outline O Git commit/tree/artifact identity validated before independent review and revalidated at dispatch."
1307
+ - **W3 stays rejected.** Handoff, lease, pin and merge-correspondence files are written outside capture and their content is not bound at capture time, so no coordinator state write is verifiable from evidence yet. The dispatch seam still validates those bytes; the writes themselves are rejected.
1308
+ - **W4 is unchanged.** Outline credit still needs a host-captured `SubagentStop` review and a correlated Task/Agent result from an agent distinct from the coordinator and worker. Only the Claude Code host is captured for outline credit; OpenCode and other reviewer providers are not.
1309
+ - Negative controls: the mechanism file injects each rejected case through the real hook, provider and `pilot.assess()` (`writer-policy negative controls`, `pre-review writer positive controls`).
1262
1310
 
1263
1311
  Retries use a fresh attempt, token, clean worktree and immutable handoff; only one non-superseded leaf is current. An identical same-tool-ID/input callback may reuse its stored seam, but repeated PreToolUse records remain ambiguous for evidence credit; changed input or token reuse is unresolved. OpenCode and Codex remain unsupported for positive outline credit.
1264
1312
 
@@ -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.47.3
7
+ version: 0.48.0
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -1701,6 +1701,15 @@ function parseInventoryDoc(relativePath, bytes) {
1701
1701
  };
1702
1702
  }
1703
1703
 
1704
+ function convertWorkspaceFiles(root, inventory) {
1705
+ const files = (Array.isArray(inventory) ? inventory : []).map(item => ({
1706
+ relative: item.path,
1707
+ bytes: Buffer.from(item.bytes)
1708
+ }));
1709
+ const rules = readWorkingTreeRules(root, files.filter(item => item.bytes.includes(0x0d)).map(item => item.relative));
1710
+ return files.map(item => ({ path: item.relative, bytes: workingStoredBytes(root, item.relative, item.bytes, rules) }));
1711
+ }
1712
+
1704
1713
  function scanWorkspaceFiles(root, scope = null) {
1705
1714
  if (!root) return [];
1706
1715
  const workspace = path.resolve(root);
@@ -1728,8 +1737,7 @@ function scanWorkspaceFiles(root, scope = null) {
1728
1737
  }
1729
1738
  }
1730
1739
  walk(workspace, '');
1731
- const rules = readWorkingTreeRules(workspace, files.filter(item => item.bytes.includes(0x0d)).map(item => item.relative));
1732
- return files.map(item => ({ path: item.relative, bytes: workingStoredBytes(workspace, item.relative, item.bytes, rules) }));
1740
+ return convertWorkspaceFiles(workspace, files.map(item => ({ path: item.relative, bytes: item.bytes })));
1733
1741
  }
1734
1742
 
1735
1743
  function scanWorkspaceInventory(root) {
@@ -2785,6 +2793,6 @@ function main(argv = process.argv.slice(2)) {
2785
2793
  return analysis.diagnostics.some(item => item.severity === 'error') ? 1 : 0;
2786
2794
  }
2787
2795
 
2788
- module.exports = { readGitBaseInventory, scanWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, documentCorpusPaths, analyzeDocs, resolveRef, lintDocs, buildIndex, buildIndexSnapshot, configSourceInventory, diffRevisions, buildGitDocumentInventory };
2796
+ module.exports = { readGitBaseInventory, scanWorkspaceFiles, convertWorkspaceFiles, parseFrontmatter, parseRequirementBlocks, collectDocs, documentCorpusPaths, analyzeDocs, resolveRef, lintDocs, buildIndex, buildIndexSnapshot, configSourceInventory, diffRevisions, buildGitDocumentInventory };
2789
2797
 
2790
2798
  if (require.main === module) process.exitCode = main();
@@ -21,6 +21,7 @@ const PROJECT_DOCS_CONFIG = path.join('.agents', 'harness-everything', 'project-
21
21
  const PROJECT_DOC_ROLES = Object.freeze(['prd', 'adr', 'ticket', 'reference', 'log', 'report']);
22
22
  const PROJECT_DOC_CONFIG_KEYS = new Set([
23
23
  'governed', 'domains', 'legacy', 'derived', 'tickets',
24
+ 'inventory',
24
25
  'docLocation', 'tracker', 'issueDefinition', 'contextMap', 'contextMapPath',
25
26
  'decisionLocation', 'decisions', 'adrLocation', 'adr',
26
27
  'domainLocation', 'contextLocation', 'contexts', 'domain',
@@ -327,6 +328,17 @@ function validateDocsConfig(config) {
327
328
  }
328
329
  }
329
330
 
331
+ if (Object.prototype.hasOwnProperty.call(config, 'inventory')) {
332
+ const inventory = config.inventory;
333
+ const roots = inventory && inventory.dependencyArtifactRoots;
334
+ const supportedRoots = new Set(['node_modules', '.husky/_']);
335
+ if (!isPlainObject(inventory) || Object.keys(inventory).some(key => key !== 'dependencyArtifactRoots')
336
+ || !Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !supportedRoots.has(root))
337
+ || new Set(roots).size !== roots.length) {
338
+ invalid('invalid-dependency-artifact-roots', 'inventory.dependencyArtifactRoots must contain distinct exact roots from node_modules and .husky/_');
339
+ }
340
+ }
341
+
330
342
  if (Object.prototype.hasOwnProperty.call(config, 'tickets') &&
331
343
  config.tickets !== 'tracker' && !isRepoRelativePath(config.tickets)) {
332
344
  invalid('invalid-tickets', 'tickets must be "tracker" or a repo-relative path');
@@ -12,6 +12,7 @@ function runtimeModule(name) {
12
12
  }
13
13
  const { checkDocObligations } = runtimeModule('doc-evidence.js');
14
14
  const { deriveDocFacts, deriveDocObligations } = runtimeModule('doc-obligations.js');
15
+ const { validateDocsConfig } = runtimeModule('project-docs-resolver.js');
15
16
  const text = value => typeof value === 'string' && value.trim().length > 0;
16
17
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
17
18
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
@@ -38,6 +39,9 @@ const sameIdentity = (a, b) => validIdentity(a) && validIdentity(b) && identityF
38
39
  const diagnostic = (code, context = {}) => ({ code, message: code.replace(/-/g, ' '), ...context });
39
40
  const pathCompare = (a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path));
40
41
  const codedError = code => Object.assign(new Error(code), { code });
42
+ const PROJECT_CONFIG_PATH = '.agents/harness-everything/project-docs.json';
43
+ const DEPENDENCY_ARTIFACT_ROOTS = new Set(['node_modules', '.husky/_']);
44
+ const DEPENDENCY_POLICY_VERSION = 'dependency-artifact-scope/1';
41
45
  // The derive contract (doc-obligations.js) decides applicability from governed/legacy documents, derived
42
46
  // outputs and code/REQ-test paths. Records outside them cannot affect document obligations.
43
47
  // shortcut: mirrors derive path rules, update them together until a shared classifier is exposed.
@@ -136,6 +140,57 @@ function snapshotDigest(kind, baseline, coverage, records) {
136
140
  for (const item of [...records].sort(pathCompare)) fields.push(item.path, item.presence, item.type, item.mode, bytes(item.storedBytes));
137
141
  return hashFrames(fields);
138
142
  }
143
+ function pathUnderDependencyRoot(relative, root) {
144
+ const candidate = String(relative).replace(/\\/g, '/').normalize('NFC').toLowerCase();
145
+ const prefix = root.normalize('NFC').toLowerCase();
146
+ return candidate === prefix || candidate.startsWith(`${prefix}/`);
147
+ }
148
+ function projectConfigFromStoredBytes(value) {
149
+ try {
150
+ const config = JSON.parse(bytes(value).toString('utf8'));
151
+ return validateDocsConfig(config).valid ? config : null;
152
+ } catch { return null; }
153
+ }
154
+ function validCoverage(snapshot) {
155
+ const coverage = snapshot.candidate?.coverage;
156
+ if (!record(coverage) || !Array.isArray(coverage.excluded) || !record(coverage.scope)) return false;
157
+ if (coverage.kind === 'whole-project') {
158
+ return Object.keys(coverage).sort(compare).join('\0') === ['excluded', 'kind', 'scope'].sort(compare).join('\0')
159
+ && stable(coverage.excluded) === stable(['.git']);
160
+ }
161
+ const fields = ['dependencyArtifactRoots', 'excluded', 'excludedRootStates', 'guardOutcomes', 'kind', 'policySource', 'policyVersion', 'scope'];
162
+ if (coverage.kind !== 'project-with-dependency-artifact-exclusions'
163
+ || Object.keys(coverage).sort(compare).join('\0') !== fields.sort(compare).join('\0')
164
+ || coverage.policyVersion !== DEPENDENCY_POLICY_VERSION || !record(coverage.policySource)
165
+ || Object.keys(coverage.policySource).sort(compare).join('\0') !== ['baselineCommit', 'path', 'storedBytesDigest'].sort(compare).join('\0')
166
+ || coverage.policySource.path !== PROJECT_CONFIG_PATH || coverage.policySource.baselineCommit !== snapshot.baseline.commit
167
+ || !/^[a-f0-9]{64}$/.test(coverage.policySource.storedBytesDigest || '')
168
+ || !Array.isArray(coverage.dependencyArtifactRoots) || !coverage.dependencyArtifactRoots.length
169
+ || coverage.dependencyArtifactRoots.some(root => !DEPENDENCY_ARTIFACT_ROOTS.has(root))
170
+ || stable(coverage.dependencyArtifactRoots) !== stable([...coverage.dependencyArtifactRoots].sort())
171
+ || new Set(coverage.dependencyArtifactRoots).size !== coverage.dependencyArtifactRoots.length
172
+ || stable(coverage.excluded) !== stable(['.git', ...coverage.dependencyArtifactRoots])) return false;
173
+ const roots = coverage.dependencyArtifactRoots;
174
+ const baselinePolicy = snapshot.baseline.records.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
175
+ const candidatePolicy = snapshot.config.capture.sources.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
176
+ if (!baselinePolicy || !candidatePolicy || sha(bytes(baselinePolicy.storedBytes)) !== coverage.policySource.storedBytesDigest) return false;
177
+ const baseConfig = projectConfigFromStoredBytes(baselinePolicy.storedBytes);
178
+ const candidateConfig = projectConfigFromStoredBytes(candidatePolicy.bytes);
179
+ const baseRoots = baseConfig?.inventory?.dependencyArtifactRoots;
180
+ const candidateRoots = candidateConfig?.inventory?.dependencyArtifactRoots;
181
+ if (!Array.isArray(baseRoots) || !Array.isArray(candidateRoots)
182
+ || stable([...baseRoots].sort()) !== stable([...candidateRoots].sort())
183
+ || stable([...baseRoots].sort()) !== stable(roots)) return false;
184
+ if ([...snapshot.baseline.records, ...snapshot.candidate.records].some(item => roots.some(root => pathUnderDependencyRoot(item.path, root)))) return false;
185
+ if (!Array.isArray(coverage.excludedRootStates) || coverage.excludedRootStates.length !== roots.length
186
+ || coverage.excludedRootStates.some((item, index) => !record(item) || item.path !== roots[index]
187
+ || !['absent', 'present-directory'].includes(item.presence)
188
+ || Object.keys(item).sort(compare).join('\0') !== ['path', 'presence'].sort(compare).join('\0'))) return false;
189
+ const guards = ['baseline-paths', 'index-paths', 'config-sources', 'config-selectors', 'task-inputs', 'excluded-root-types'];
190
+ return Array.isArray(coverage.guardOutcomes) && coverage.guardOutcomes.length === guards.length
191
+ && coverage.guardOutcomes.every((item, index) => record(item) && item.guard === guards[index] && item.state === 'clear'
192
+ && text(item.reason) && (index !== guards.length - 1 || stable(item.roots) === stable(coverage.excludedRootStates)));
193
+ }
139
194
  function configDigest(config) {
140
195
  const sources = [...config.capture.sources].sort(pathCompare);
141
196
  const fields = ['doc-config-v1', stable({ activeHost: config.capture.activeHost, selectionInputs: config.capture.selectionInputs, resolution: config.resolution }), String(sources.length)];
@@ -146,6 +201,7 @@ function validateSnapshot(snapshot) {
146
201
  const errors = [];
147
202
  try {
148
203
  if (!validIdentity(snapshot.identity) || !record(snapshot.facts) || !Array.isArray(snapshot.obligations)) throw new Error('invalid-input');
204
+ if (!validCoverage(snapshot)) errors.push(diagnostic('inventory-unsupported'));
149
205
  // Roots are canonicalized at capture; keep reconciliation detached and free of filesystem reads.
150
206
  const context = snapshot.originContext;
151
207
  if (!record(context) || !record(context.baseline) || !text(context.workspaceRoot)
@@ -4,14 +4,17 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
6
  const { _internals: U } = require('./doc-reconciliation-core');
7
- const { scanWorkspaceFiles } = U.runtimeModule('doc-lifecycle.js');
8
- const { resolveDocsConfigCaptured } = U.runtimeModule('project-docs-resolver.js');
7
+ const { convertWorkspaceFiles } = U.runtimeModule('doc-lifecycle.js');
8
+ const { resolveDocsConfigCaptured, validateDocsConfig } = U.runtimeModule('project-docs-resolver.js');
9
9
  const { deriveDocFacts, deriveDocObligations } = U.runtimeModule('doc-obligations.js');
10
10
 
11
11
  const MAX_FILES = 10000;
12
12
  const MAX_BYTES = 64 * 1024 * 1024;
13
13
  const POSIX_MODES = process.platform !== 'win32';
14
- function fail(code) { throw new Error(code); }
14
+ const PROJECT_CONFIG_PATH = '.agents/harness-everything/project-docs.json';
15
+ const SUPPORTED_ARTIFACT_ROOTS = new Set(['node_modules', '.husky/_']);
16
+ const POLICY_VERSION = 'dependency-artifact-scope/1';
17
+ function fail(code, reasonCode) { throw Object.assign(new Error(code), reasonCode ? { reasonCode } : {}); }
15
18
  // The JS realpath keeps Windows 8.3 spellings (RUNNER~1) while Git reports the long name, so
16
19
  // directory identity uses native resolution and is case-insensitive on Windows (as workflow-isolation).
17
20
  function canonicalPath(value) { return fs.realpathSync.native(path.resolve(value)); }
@@ -64,7 +67,249 @@ function captureBaseline(root, pin) {
64
67
  validatePaths(records);
65
68
  return { commit, treeRef, records };
66
69
  }
67
- function captureCandidate(root, baseline) {
70
+
71
+ function pathUnderRoot(relative, root) {
72
+ const candidate = String(relative).replace(/\\/g, '/').normalize('NFC').toLowerCase();
73
+ const expected = root.normalize('NFC').toLowerCase();
74
+ return candidate === expected || candidate.startsWith(`${expected}/`);
75
+ }
76
+ function rootMention(value, root) {
77
+ const normalized = String(value).replace(/\\/g, '/').normalize('NFC').toLowerCase();
78
+ const needle = root.normalize('NFC').toLowerCase();
79
+ let offset = 0;
80
+ while ((offset = normalized.indexOf(needle, offset)) >= 0) {
81
+ const before = offset === 0 || !/[a-z0-9_.-]/.test(normalized[offset - 1]);
82
+ const after = offset + needle.length;
83
+ const boundary = after === normalized.length || /[/*?\s"'`:#),;\]]/.test(normalized[after]);
84
+ if (before && boundary) return true;
85
+ offset++;
86
+ }
87
+ return false;
88
+ }
89
+ function globSegmentMatches(rawPattern, rawSegment) {
90
+ // Same NFC + lowercase folding as rootMention()/pathUnderRoot(), so a wildcard spelling cannot dodge it.
91
+ const pattern = rawPattern.normalize('NFC').toLowerCase();
92
+ const segment = rawSegment.normalize('NFC').toLowerCase();
93
+ if (/[\[\]{}]/.test(pattern)) return true;
94
+ const escaped = pattern.split(/([*?])/).map(part => part === '*' ? '[\\s\\S]*' : part === '?' ? '[\\s\\S]'
95
+ : part.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')).join('');
96
+ return new RegExp(`^${escaped}$`).test(segment);
97
+ }
98
+ function relativeSelectorMayIntersectRoot(selector, root) {
99
+ const normalized = selector.replace(/\\/g, '/');
100
+ if (/[\[\]{}]/.test(normalized) || normalized.split('/').includes('..')) return true;
101
+ // `.` and empty segments are redundant anywhere in a workspace-relative selector.
102
+ const pattern = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
103
+ const target = root.split('/');
104
+ let states = new Set([0]);
105
+ const closure = values => {
106
+ const expanded = new Set(values);
107
+ let changed = true;
108
+ while (changed) {
109
+ changed = false;
110
+ for (const index of expanded) {
111
+ if (pattern[index] === '**' && !expanded.has(index + 1)) {
112
+ expanded.add(index + 1);
113
+ changed = true;
114
+ }
115
+ }
116
+ }
117
+ return expanded;
118
+ };
119
+ states = closure(states);
120
+ for (const segment of target) {
121
+ const next = new Set();
122
+ for (const index of states) {
123
+ if (index >= pattern.length) continue;
124
+ if (pattern[index] === '**') next.add(index);
125
+ else if (globSegmentMatches(pattern[index], segment)) next.add(index + 1);
126
+ }
127
+ states = closure(next);
128
+ if (!states.size) return false;
129
+ }
130
+ return states.size > 0;
131
+ }
132
+ function relativePathWithin(workspace, candidate, pathApi) {
133
+ const relative = pathApi.relative(workspace, candidate);
134
+ if (!relative) return '';
135
+ const normalized = relative.replace(/\\/g, '/');
136
+ // Case and filesystem aliases that cannot be resolved exactly must fail closed.
137
+ const workspaceParts = workspace.replace(/\\/g, '/').split('/').filter(Boolean);
138
+ const candidateParts = candidate.replace(/\\/g, '/').split('/').filter(Boolean);
139
+ const samePrefix = workspaceParts.length <= candidateParts.length
140
+ && workspaceParts.every((part, index) => part.normalize('NFC').toLowerCase()
141
+ === candidateParts[index].normalize('NFC').toLowerCase());
142
+ if (samePrefix) return candidateParts.slice(workspaceParts.length).join('/');
143
+ if (pathApi.isAbsolute(relative) || normalized === '..' || normalized.startsWith('../')) return null;
144
+ return normalized;
145
+ }
146
+ function realpathWithMissingSuffix(candidate, pathApi) {
147
+ let current = candidate;
148
+ const suffix = [];
149
+ while (true) {
150
+ try { return pathApi.resolve(fs.realpathSync.native(current), ...suffix); }
151
+ catch (error) {
152
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) return null;
153
+ const parent = pathApi.dirname(current);
154
+ if (parent === current) return null;
155
+ suffix.unshift(pathApi.basename(current));
156
+ current = parent;
157
+ }
158
+ }
159
+ }
160
+ function absoluteSelectorRelatives(selector, workspaceRoot) {
161
+ const windowsDrive = /^[A-Za-z]:/.test(selector);
162
+ if ((windowsDrive || selector.startsWith('\\') || selector.startsWith('//')) && process.platform !== 'win32') return true;
163
+ const pathApi = process.platform === 'win32' ? path.win32 : path;
164
+ if (windowsDrive && !pathApi.isAbsolute(selector)) return true;
165
+ if (!pathApi.isAbsolute(selector)) return null;
166
+
167
+ const workspace = canonicalPath(workspaceRoot);
168
+ const absolute = pathApi.resolve(selector);
169
+ const lexicalRelative = relativePathWithin(workspace, absolute, pathApi);
170
+ const resolved = realpathWithMissingSuffix(absolute, pathApi);
171
+ if (!resolved) return true;
172
+ const realRelative = relativePathWithin(workspace, resolved, pathApi);
173
+ if (lexicalRelative !== null && realRelative !== null) return [lexicalRelative, realRelative];
174
+ if (lexicalRelative !== null || realRelative !== null) return true;
175
+ // A glob outside the workspace can still expand into it (via a parent such as /tmp/*/node_modules/**
176
+ // or a symlinked entry under an unrelated directory); only literal outside paths are provably disjoint.
177
+ if (/[*?\[\]{}]/.test(selector)) return true;
178
+ return false;
179
+ }
180
+ function selectorMayIntersectRoot(selector, root, workspaceRoot) {
181
+ if (typeof selector !== 'string' || !selector.trim()) return true;
182
+ const absolute = absoluteSelectorRelatives(selector, workspaceRoot);
183
+ if (absolute === true) return true;
184
+ if (absolute === false) return false;
185
+ if (absolute !== null) return absolute.some(relative => relativeSelectorMayIntersectRoot(relative || '.', root));
186
+ return relativeSelectorMayIntersectRoot(selector, root);
187
+ }
188
+ function valueMayIntersectRoots(value, roots, workspaceRoot) {
189
+ if (typeof value === 'string') {
190
+ const absolute = absoluteSelectorRelatives(value, workspaceRoot);
191
+ if (absolute === true) return true;
192
+ if (absolute === false) return false;
193
+ if (absolute !== null) return roots.some(root => absolute.some(relative => {
194
+ const selector = relative || '.';
195
+ return rootMention(selector, root)
196
+ || ((selector.includes('/') || /[*?\[\]{}]/.test(selector)) && selectorMayIntersectRoot(selector, root, workspaceRoot));
197
+ }));
198
+ return roots.some(root => rootMention(value, root)
199
+ || ((!value.trim() || value.includes('/') || /[*?\[\]{}]/.test(value))
200
+ && selectorMayIntersectRoot(value, root, workspaceRoot)));
201
+ }
202
+ if (Array.isArray(value)) return value.some(item => valueMayIntersectRoots(item, roots, workspaceRoot));
203
+ if (U.record(value)) return Object.entries(value).some(([key, item]) => valueMayIntersectRoots(key, roots, workspaceRoot)
204
+ || valueMayIntersectRoots(item, roots, workspaceRoot));
205
+ return false;
206
+ }
207
+ function withoutTaskPinIdentity(value) {
208
+ if (Array.isArray(value)) return value.map(withoutTaskPinIdentity);
209
+ if (!U.record(value)) return value;
210
+ return Object.fromEntries(Object.entries(value)
211
+ .filter(([key]) => !/ids?$/i.test(key))
212
+ .map(([key, item]) => [key, withoutTaskPinIdentity(item)]));
213
+ }
214
+ function projectConfigFromBytes(bytes) {
215
+ try {
216
+ const config = JSON.parse(Buffer.from(bytes).toString('utf8'));
217
+ return { config, valid: validateDocsConfig(config).valid };
218
+ } catch { return { config: null, valid: false }; }
219
+ }
220
+ function baselineProjectConfig(baseline) {
221
+ const source = baseline.records.find(item => item.path === PROJECT_CONFIG_PATH && item.presence === 'present');
222
+ return source ? { source, ...projectConfigFromBytes(source.storedBytes) } : { source: null, config: null, valid: false };
223
+ }
224
+ function capturedProjectConfig(config) {
225
+ const source = config.capture.sources.find(item => item.path === PROJECT_CONFIG_PATH);
226
+ return source && source.presence === 'present'
227
+ ? { source, ...projectConfigFromBytes(source.bytes) }
228
+ : { source: source || null, config: null, valid: false };
229
+ }
230
+ function dependencyRoots(config) {
231
+ return U.record(config) && U.record(config.inventory)
232
+ && Object.prototype.hasOwnProperty.call(config.inventory, 'dependencyArtifactRoots')
233
+ ? config.inventory.dependencyArtifactRoots : undefined;
234
+ }
235
+ function sameRootField(left, right) {
236
+ if (left === undefined || right === undefined) return left === right;
237
+ return U.stable([...left].sort()) === U.stable([...right].sort());
238
+ }
239
+ function validateExcludedRoots(workspaceRoot, roots) {
240
+ const workspace = canonicalPath(workspaceRoot);
241
+ const states = [];
242
+ for (const root of roots) {
243
+ let parent = workspace;
244
+ let presence = 'present-directory';
245
+ for (const component of root.split('/')) {
246
+ let entries;
247
+ try { entries = fs.readdirSync(parent, { withFileTypes: true }); }
248
+ catch { fail('inventory-incomplete', 'dependency-root-parent-unreadable'); }
249
+ const aliases = entries.filter(entry => entry.name.normalize('NFC').toLowerCase() === component.normalize('NFC').toLowerCase());
250
+ if (aliases.length > 1 || aliases.some(entry => entry.name !== component)) fail('inventory-unsupported', 'dependency-root-alias');
251
+ const current = path.join(parent, component);
252
+ let stat;
253
+ try { stat = fs.lstatSync(current); }
254
+ catch (error) {
255
+ if (error.code === 'ENOENT') { presence = 'absent'; break; }
256
+ fail('inventory-incomplete', 'dependency-root-unreadable');
257
+ }
258
+ if (stat.isSymbolicLink() || !stat.isDirectory()) fail('inventory-unsupported', 'dependency-root-not-plain-directory');
259
+ let real;
260
+ try { real = fs.realpathSync.native(current); }
261
+ catch { fail('inventory-incomplete', 'dependency-root-unreadable'); }
262
+ const within = process.platform === 'win32'
263
+ ? real.toLowerCase().startsWith(`${workspace.toLowerCase()}${path.sep}`)
264
+ : real.startsWith(`${workspace}${path.sep}`);
265
+ if (!within) fail('inventory-unsupported', 'dependency-root-outside-workspace');
266
+ parent = current;
267
+ }
268
+ states.push({ path: root, presence });
269
+ }
270
+ return states;
271
+ }
272
+ function guardedPolicy(root, baseline, capturedConfig, input) {
273
+ const base = baselineProjectConfig(baseline);
274
+ const current = capturedProjectConfig(capturedConfig);
275
+ const baselineRoots = base.valid ? dependencyRoots(base.config) : undefined;
276
+ const candidateRoots = current.valid ? dependencyRoots(current.config) : undefined;
277
+ if (current.valid && candidateRoots !== undefined && (!base.valid || !sameRootField(baselineRoots, candidateRoots))) {
278
+ fail('inventory-unsupported', 'dependency-artifact-policy-changed-or-unpinned');
279
+ }
280
+ if (!base.valid || baselineRoots === undefined || baselineRoots.length === 0) return null;
281
+ if (!current.valid || !sameRootField(baselineRoots, candidateRoots)) fail('inventory-unsupported', 'dependency-artifact-policy-changed-or-unpinned');
282
+ const roots = [...baselineRoots].sort();
283
+ const indexed = git(root, ['ls-files', '-z']).toString('utf8').split('\0').filter(Boolean);
284
+ if (baseline.records.some(item => roots.some(value => pathUnderRoot(item.path, value)))) fail('inventory-unsupported', 'baseline-path-overlap');
285
+ if (indexed.some(item => roots.some(value => pathUnderRoot(item, value)))) fail('inventory-unsupported', 'index-path-overlap');
286
+ if (capturedConfig.capture.sources.some(item => roots.some(value => pathUnderRoot(item.path, value)))) {
287
+ fail('inventory-unsupported', 'config-source-overlap');
288
+ }
289
+ const configs = [base.config, capturedConfig.resolution.config];
290
+ if (configs.some(config => {
291
+ if (!U.record(config)) return false;
292
+ const selectors = Object.fromEntries(Object.entries(config).filter(([key]) => key !== 'inventory'));
293
+ return valueMayIntersectRoots(selectors, roots, root);
294
+ })) fail('inventory-unsupported', 'config-selector-overlap');
295
+ if (valueMayIntersectRoots(withoutTaskPinIdentity(input.task), roots, root) || valueMayIntersectRoots(input.scope, roots, root)) {
296
+ fail('inventory-unsupported', 'task-input-overlap');
297
+ }
298
+ return {
299
+ roots,
300
+ baselineCommit: baseline.commit,
301
+ storedBytesDigest: U.sha(base.source.storedBytes),
302
+ guardOutcomes: [
303
+ { guard: 'baseline-paths', state: 'clear', reason: 'No baseline path intersects an approved root.' },
304
+ { guard: 'index-paths', state: 'clear', reason: 'No current index path intersects an approved root.' },
305
+ { guard: 'config-sources', state: 'clear', reason: 'No baseline or current config source intersects an approved root.' },
306
+ { guard: 'config-selectors', state: 'clear', reason: 'Baseline and current config declarations are disjoint from approved roots.' },
307
+ { guard: 'task-inputs', state: 'clear', reason: 'No explicit task or requirement input intersects an approved root.' }
308
+ ]
309
+ };
310
+ }
311
+ function captureCandidate(root, baseline, excludedRoots = []) {
312
+ const excludedRootStates = excludedRoots.length ? validateExcludedRoots(root, excludedRoots) : [];
68
313
  const raw = [];
69
314
  let total = 0;
70
315
  let visited = 0;
@@ -72,8 +317,9 @@ function captureCandidate(root, baseline) {
72
317
  const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => U.compare(a.name, b.name));
73
318
  for (const entry of entries) {
74
319
  if (!prefix && entry.name === '.git') continue;
75
- if (++visited > MAX_FILES) fail('inventory-unsupported');
76
320
  const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
321
+ if (excludedRoots.includes(relative)) continue;
322
+ if (++visited > MAX_FILES) fail('inventory-unsupported');
77
323
  const absolute = path.join(dir, entry.name);
78
324
  const stat = fs.lstatSync(absolute);
79
325
  if (stat.isDirectory()) {
@@ -102,7 +348,8 @@ function captureCandidate(root, baseline) {
102
348
  }
103
349
  }
104
350
  // Lifecycle owns text/eol/autocrlf conversion. A completeness walk above detects its skipped paths.
105
- const stored = new Map(scanWorkspaceFiles(root).map(item => [item.path, item.bytes]));
351
+ const stored = new Map(convertWorkspaceFiles(root, raw.map(item => ({ path: item.path, bytes: item.raw })))
352
+ .map(item => [item.path, item.bytes]));
106
353
  const records = raw.map(item => {
107
354
  const converted = stored.get(item.path);
108
355
  if (!converted) fail('inventory-unsupported');
@@ -111,7 +358,7 @@ function captureCandidate(root, baseline) {
111
358
  return fileRecord(item.path, item.mode, converted);
112
359
  });
113
360
  for (const item of baseline.records) if (!stored.has(item.path)) records.push(fileRecord(item.path, '', Buffer.alloc(0), 'deleted'));
114
- return records;
361
+ return { records, excludedRootStates };
115
362
  }
116
363
  const progressKeys = new Set(['status', 'state', 'evidence', 'loaded', 'loadedAt', 'updatedAt', 'createdAt', 'completedAt', 'timestamp', 'report', 'reports']);
117
364
  // Progress fields are excluded only at their defined record locations (top-level task/workflow, each
@@ -169,8 +416,20 @@ function produceDocSnapshot(input, dependencies = {}) {
169
416
  const baseline = captureBaseline(root, detached.baseline);
170
417
  const config = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
171
418
  if (!['project', 'host'].includes(config.resolution.status) || config.capture.sources.some(s => s.presence === 'unreadable')) fail('config-unavailable');
172
- const coverage = { kind: 'whole-project', excluded: ['.git'], scope: U.clone(detached.scope) };
173
- const candidate = { records: captureCandidate(root, baseline), coverage };
419
+ const policy = guardedPolicy(root, baseline, config, detached);
420
+ const firstCapture = captureCandidate(root, baseline, policy?.roots || []);
421
+ const coverage = policy ? {
422
+ kind: 'project-with-dependency-artifact-exclusions',
423
+ policyVersion: POLICY_VERSION,
424
+ policySource: { path: PROJECT_CONFIG_PATH, baselineCommit: policy.baselineCommit, storedBytesDigest: policy.storedBytesDigest },
425
+ dependencyArtifactRoots: policy.roots,
426
+ excluded: ['.git', ...policy.roots],
427
+ excludedRootStates: firstCapture.excludedRootStates,
428
+ guardOutcomes: [...policy.guardOutcomes,
429
+ { guard: 'excluded-root-types', state: 'clear', reason: 'Each approved root is an in-workspace directory or absent.', roots: firstCapture.excludedRootStates }],
430
+ scope: U.clone(detached.scope)
431
+ } : { kind: 'whole-project', excluded: ['.git'], scope: U.clone(detached.scope) };
432
+ const candidate = { records: firstCapture.records, coverage };
174
433
  // Same owned config bytes must be the bytes covered by the working-tree capture.
175
434
  for (const source of config.capture.sources) {
176
435
  const file = candidate.records.find(r => r.path === source.path && r.presence === 'present');
@@ -189,18 +448,21 @@ function produceDocSnapshot(input, dependencies = {}) {
189
448
  const facts = derived.facts;
190
449
  const obligations = deriveDocObligations(facts);
191
450
  const derivationReasons = Object.fromEntries(obligations.map(o => [o.id, { applicability: o.applicability, reason: `captured facts derive ${o.capability}: ${o.applicability}`, factRefs: ['facts', ...(o.scope?.paths || []).map(p => `inventory:${p}`)] }]));
192
- const again = captureCandidate(root, baseline);
451
+ const again = captureCandidate(root, baseline, policy?.roots || []);
193
452
  const configAgain = resolveDocsConfigCaptured(root, { activeHost: detached.activeHost });
453
+ const policyAgain = guardedPolicy(root, baseline, configAgain, detached);
194
454
  let finalContext;
195
455
  try { finalContext = contextFor(detached, originProvider); }
196
456
  catch { fail('snapshot-changed'); }
197
- if (U.snapshotDigest('candidate', baseline, coverage, again) !== candidate.digest || U.configDigest(configAgain) !== config.digest
457
+ if (U.snapshotDigest('candidate', baseline, coverage, again.records) !== candidate.digest
458
+ || U.stable(again.excludedRootStates) !== U.stable(firstCapture.excludedRootStates)
459
+ || U.stable(policyAgain) !== U.stable(policy) || U.configDigest(configAgain) !== config.digest
198
460
  || U.stable(originContext) !== U.stable(finalContext)) fail('snapshot-changed');
199
461
  return { state: 'ready', snapshot: { identity, baseline, candidate, config, originContext, taskSemantics: planIdentity, facts, obligations, derivationReasons }, diagnostics: [] };
200
462
  } catch (error) {
201
463
  const codes = ['invalid-input', 'baseline-unavailable', 'inventory-incomplete', 'inventory-unsupported', 'snapshot-changed', 'config-unavailable', 'unsupported-prd-root', 'identity-mismatch', 'origin-unavailable', 'origin-untrusted'];
202
464
  const code = codes.includes(error.message) ? error.message : 'inventory-unsupported';
203
- return { state: 'unavailable', snapshot: null, diagnostics: [U.diagnostic(code)] };
465
+ return { state: 'unavailable', snapshot: null, diagnostics: [U.diagnostic(code, error.reasonCode ? { reasonCode: error.reasonCode } : {})] };
204
466
  }
205
467
  }
206
468
  module.exports = { produceDocSnapshot };