harness-everything 0.36.1 → 0.36.3

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 (28) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/ci/mechanism-2aw-project-docs-config.test.js +450 -10
  5. package/ci/mechanism-335-baseline-evidence.test.js +355 -7
  6. package/docs/doc-evidence-baseline-binding.md +28 -7
  7. package/docs/document-lifecycle.md +60 -8
  8. package/harness-everything/SKILL.md +1 -1
  9. package/harness-everything/scripts/doc-evidence.js +69 -9
  10. package/harness-everything/scripts/project-docs-resolver.js +30 -13
  11. package/multi-agent-workspace/SKILL.md +1 -1
  12. package/multi-agent-workspace/scripts/project-docs-resolver.js +30 -13
  13. package/opencode-plugin/plugin.json +1 -1
  14. package/package.json +1 -1
  15. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  16. package/plugins/harness-everything/plugin.json +1 -1
  17. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  18. package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +69 -9
  19. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +30 -13
  20. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  21. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +30 -13
  22. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  23. package/plugins/harness-everything/skills/to-spec/scripts/check-project-docs.js +136 -4
  24. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +30 -13
  25. package/sbom.cdx.json +7 -7
  26. package/to-spec/SKILL.md +1 -1
  27. package/to-spec/scripts/check-project-docs.js +136 -4
  28. package/to-spec/scripts/project-docs-resolver.js +30 -13
@@ -72,8 +72,18 @@ function artifact(entry) {
72
72
 
73
73
  // #335 P2: a baseline-bound rename destination must declare its own digest. An inherited
74
74
  // `digest` (for example Object.create({ digest })) is not explicit candidate evidence.
75
- function ownArtifactDigest(entry) {
76
- return isPlainObject(entry) && hasOwnEntry(entry, 'digest') ? artifact(entry) : '';
75
+ // P2(r4321077631): reads the own digest exactly once. `artifact()` inspects `entry.digest` up
76
+ // to three times (typeof, length, value), so a stateful accessor could validate one value and
77
+ // have the captured destination snapshot carry another. Only the applicable rename-destination
78
+ // snapshot uses this helper; non-baseline and inapplicable lanes keep the legacy `artifact()`
79
+ // reads for their audit identity. A throwing digest getter propagates to the caller's guarded
80
+ // block and fails the inspection closed there. This is the single authoritative own-digest
81
+ // helper; nothing else in this module reads a destination's own digest.
82
+ function ownDigestOnce(entry) {
83
+ if (!isPlainObject(entry)) return '';
84
+ if (!hasOwnEntry(entry, 'digest')) return '';
85
+ const digest = entry.digest;
86
+ return typeof digest === 'string' && digest.length > 0 ? digest : '';
77
87
  }
78
88
 
79
89
  function provenanceText(record) {
@@ -142,7 +152,20 @@ function checkDocObligations({ obligations, artifacts, baseline, executionRecord
142
152
  }
143
153
  return candidateInventoryKnown;
144
154
  };
145
- const baselineDigest = isPlainObject(baseline) && nonEmptyString(baseline.digest) ? baseline.digest : '';
155
+ // P2(r4321077638): the whole-baseline identity is read exactly once and that same scalar is
156
+ // validated, so a stateful getter cannot pass validation as A and have every later comparison
157
+ // use B. A baseline identity whose inspection throws is unusable input: the captured digest
158
+ // stays '', so baseline-bound evaluations fail closed through `baseline-identity-missing`
159
+ // instead of escaping the checker, and non-baseline callers keep the legacy '' shape.
160
+ let baselineDigest = '';
161
+ if (isPlainObject(baseline)) {
162
+ try {
163
+ const declaredBaselineDigest = baseline.digest;
164
+ if (nonEmptyString(declaredBaselineDigest)) baselineDigest = declaredBaselineDigest;
165
+ } catch {
166
+ baselineDigest = '';
167
+ }
168
+ }
146
169
  const executionList = Array.isArray(executionRecords) ? executionRecords.filter(isRecord) : [];
147
170
  const reviewList = Array.isArray(reviewRecords) ? reviewRecords.filter(isRecord) : [];
148
171
 
@@ -184,6 +207,16 @@ function evaluateObligation(obligation, context) {
184
207
  // #340: every accepted entry sharing a rename destination must validate the same own digest,
185
208
  // or the shared-destination snapshot is contradictory and the obligation fails closed.
186
209
  const validatedDestinationDigestMap = new Map();
210
+ // #342 P3: a rename destination whose own-digest read throws or returns an unusable value is
211
+ // still a visited snapshot. Its captured ('') value is recorded here so the ordinary capture
212
+ // and the audit identity never re-read that destination entry: the single metadata read stays
213
+ // the only digest read for that declaring entry, and a stateful getter cannot ship a later
214
+ // non-empty value into the audit key even though its entry was rejected. A poisoned
215
+ // destination is honored inside the metadata loop (a later declaring entry fails closed
216
+ // without reading it) and keeps precedence over a previously seeded successful snapshot;
217
+ // multiple accepted entries still read the destination once each by design, because their
218
+ // agreement check detects shared-destination contradictions.
219
+ const failedDestinationDigests = new Map();
187
220
  // #340 P2(r4320818749): the validated per-path baseline digest is captured once during
188
221
  // metadata validation. The audit mirror, the stale-baseline diagnostics, and baselineBound()
189
222
  // reuse that captured value instead of re-reading the caller map, so a stateful own getter
@@ -289,19 +322,38 @@ function evaluateObligation(obligation, context) {
289
322
  // keeps the un-gated legacy candidate read; only applicable obligations capture and
290
323
  // validate the destination snapshot.
291
324
  if (applicability === 'applicable') {
325
+ // #342 review P3: a destination poisoned by an earlier entry's failed snapshot is
326
+ // honored inside the metadata loop; the failed visited snapshot is never read again
327
+ // and this entry fails closed with it, so a stateful getter's later successful
328
+ // value cannot repair a shared destination or enter the audit.
329
+ if (failedDestinationDigests.has(entryRenameTo)) {
330
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} failed an earlier snapshot validation for this obligation; the failed destination is not read again and this entry fails closed with it`);
331
+ continue;
332
+ }
292
333
  let destinationDigest = '';
293
334
  try {
294
335
  if (hasOwnEntry(artifactMap, entryRenameTo)) {
295
- destinationDigest = ownArtifactDigest(artifactMap[entryRenameTo]);
336
+ // P2(r4321077631): read the destination's own digest exactly once; the captured
337
+ // value is the one validated below and the one that flows to the metadata cache,
338
+ // binding, the stale diagnostics, and the audit identity. A stateful destination
339
+ // getter cannot be validated as B while a record carrying only its later C passes.
340
+ destinationDigest = ownDigestOnce(artifactMap[entryRenameTo]);
296
341
  }
297
342
  } catch {
298
343
  candidateInventoryInspectionFailed = true;
344
+ // #342 P3: the destination is a visited snapshot even when its digest read threw;
345
+ // record it so the capture and audit lanes never re-read it.
346
+ failedDestinationDigests.set(entryRenameTo, '');
299
347
  continue;
300
348
  }
301
- // #340: the captured own value must be a usable non-empty string; `artifact()` can read
302
- // a stateful accessor three times, so a non-string result is not carried or seeded.
349
+ // #340: the captured own value must be a usable non-empty string; an unusable result
350
+ // is not carried or seeded.
303
351
  if (!nonEmptyString(destinationDigest)) {
304
352
  invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} has no candidate artifact`);
353
+ // #342 P3: the destination is a visited snapshot even when its own digest is
354
+ // unusable; record it so the capture and audit lanes never re-read it and cannot
355
+ // describe a stateful getter's later non-empty value.
356
+ failedDestinationDigests.set(entryRenameTo, '');
305
357
  continue;
306
358
  }
307
359
  // #340: entries that share one rename destination must agree on the validated own
@@ -377,7 +429,9 @@ function evaluateObligation(obligation, context) {
377
429
  // its own digest during metadata. Seed the captured snapshot from that validated value and
378
430
  // never read the destination through the raw candidate map again, so a destination accessor
379
431
  // that throws or changes after validation cannot make capture/binding/audit disagree. The
380
- // seeded value is the validated own digest, never a caller-asserted rawEntry field.
432
+ // seeded value is the validated own digest, never a caller-asserted rawEntry field. A
433
+ // rejected destination is still visited: its failed capture (see failedDestinationDigests)
434
+ // suppresses the ordinary read below, so no destination entry is ever read twice.
381
435
  for (const entry of baselineEntries.values()) {
382
436
  if (entry.reason === 'rename-from' && nonEmptyString(entry.renameDestinationDigest)
383
437
  && !candidateDigestCache.has(entry.renameTo)) {
@@ -385,7 +439,8 @@ function evaluateObligation(obligation, context) {
385
439
  }
386
440
  }
387
441
  for (const path of scopePaths) {
388
- if (baselineEntries.has(path) || candidateDigestCache.has(path)) continue;
442
+ if (baselineEntries.has(path) || candidateDigestCache.has(path)
443
+ || failedDestinationDigests.has(path)) continue;
389
444
  try {
390
445
  candidateDigestCache.set(path, artifact(artifactMap[path]));
391
446
  } catch {
@@ -428,6 +483,11 @@ function evaluateObligation(obligation, context) {
428
483
  // stale verdict disagree with the digest that actually bound evidence. Untouched paths and
429
484
  // unannotated callers keep the legacy read.
430
485
  const candidateDigestOf = (path) => {
486
+ // #342 P3: poisoned state takes precedence over the capture cache. A rejected rename
487
+ // destination keeps its visited '' capture even when an earlier accepted entry seeded a
488
+ // successful value, so the audit never describes a value the getter shipped after a failed
489
+ // snapshot made the obligation fail closed; the caller map is never read for it again.
490
+ if (failedDestinationDigests.has(path)) return '';
431
491
  if (candidateDigestCache.has(path)) return candidateDigestCache.get(path);
432
492
  // #340: only an applicable baseline-annotated obligation validated the source absence as
433
493
  // part of its captured snapshot. `N/A` and `unknown` obligations never bind evidence, so they
@@ -509,7 +569,7 @@ function evaluateObligation(obligation, context) {
509
569
  // candidate lane, the audit identity, and the stale diagnostics use, so a stateful nested
510
570
  // accessor cannot make binding disagree with them. Metadata validation already required the
511
571
  // destination to be an own-key candidate inventory entry carrying its own digest
512
- // (ownArtifactDigest), so a non-empty captured digest is required here and the accessor is
572
+ // (ownDigestOnce), so a non-empty captured digest is required here and the accessor is
513
573
  // never re-read; a missing destination skips capture and lands on this guard.
514
574
  const capturedDestination = candidateDigestCache.get(entry.renameTo);
515
575
  if (!nonEmptyString(capturedDestination)) return false;
@@ -78,6 +78,25 @@ function isPlainObject(value) {
78
78
  return prototype === Object.prototype || prototype === null;
79
79
  }
80
80
 
81
+ function normalizeHostProjectDocs(projectDocs) {
82
+ if (!isPlainObject(projectDocs)) return { projectDocs, warnings: [] };
83
+
84
+ const normalized = { ...projectDocs };
85
+ const warnings = [];
86
+ if (typeof normalized.domains === 'string') {
87
+ if (typeof normalized.domainLocation !== 'string' || !normalized.domainLocation.trim()) {
88
+ normalized.domainLocation = normalized.domains;
89
+ }
90
+ delete normalized.domains;
91
+ warnings.push({
92
+ code: 'deprecated-domains-string',
93
+ severity: 'warning',
94
+ message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
95
+ });
96
+ }
97
+ return { projectDocs: normalized, warnings };
98
+ }
99
+
81
100
  function projectDocsConfigPath(workspaceRoot) {
82
101
  return path.join(workspaceRoot, PROJECT_DOCS_CONFIG);
83
102
  }
@@ -148,22 +167,12 @@ function inspectHostProjectDocs(workspaceRoot) {
148
167
  continue;
149
168
  }
150
169
 
151
- const projectDocs = { ...manifest.projectDocs };
152
- if (typeof projectDocs.domains === 'string') {
153
- if (typeof projectDocs.domainLocation !== 'string' || !projectDocs.domainLocation.trim()) {
154
- projectDocs.domainLocation = projectDocs.domains;
155
- }
156
- delete projectDocs.domains;
157
- entry.warnings.push({
158
- code: 'deprecated-domains-string',
159
- severity: 'warning',
160
- path: configSource(workspaceRoot, file),
161
- message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
162
- });
163
- }
170
+ const normalizedHost = normalizeHostProjectDocs(manifest.projectDocs);
171
+ const projectDocs = normalizedHost.projectDocs;
164
172
 
165
173
  const validation = validateDocsConfig(projectDocs);
166
174
  const source = configSource(workspaceRoot, file);
175
+ entry.warnings.push(...normalizedHost.warnings.map(item => ({ ...item, path: source })));
167
176
  entry.warnings.push(...validation.warnings.map(item => ({ ...item, path: source })));
168
177
  if (!validation.valid) {
169
178
  const details = validation.diagnostics.map(item => item.message).join('; ');
@@ -236,6 +245,13 @@ function validateDocsConfig(config) {
236
245
  return { valid: false, diagnostics, warnings };
237
246
  }
238
247
 
248
+ for (const field of ['docLocation', 'tracker', 'issueDefinition']) {
249
+ if (config[field] !== undefined && typeof config[field] !== 'string') {
250
+ const diagnosticField = field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
251
+ invalid(`invalid-${diagnosticField}-type`, `${field} must be a string when provided`);
252
+ }
253
+ }
254
+
239
255
  for (const key of Object.keys(config).sort()) {
240
256
  if (!PROJECT_DOC_CONFIG_KEYS.has(key)) {
241
257
  warnings.push({
@@ -754,6 +770,7 @@ module.exports = {
754
770
  readProjectDocs,
755
771
  resolveDocsConfig,
756
772
  validateDocsConfig,
773
+ normalizeHostProjectDocs,
757
774
  parseContextMap,
758
775
  resolveProjectDocs,
759
776
  detectProjectDocsConventions,
@@ -4,7 +4,7 @@ description: "Scaffold a verified multi-agent workspace and select bounded speci
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.34.0
7
+ version: 0.36.3
8
8
  ---
9
9
 
10
10
  # Multi-Agent Workspace
@@ -78,6 +78,25 @@ function isPlainObject(value) {
78
78
  return prototype === Object.prototype || prototype === null;
79
79
  }
80
80
 
81
+ function normalizeHostProjectDocs(projectDocs) {
82
+ if (!isPlainObject(projectDocs)) return { projectDocs, warnings: [] };
83
+
84
+ const normalized = { ...projectDocs };
85
+ const warnings = [];
86
+ if (typeof normalized.domains === 'string') {
87
+ if (typeof normalized.domainLocation !== 'string' || !normalized.domainLocation.trim()) {
88
+ normalized.domainLocation = normalized.domains;
89
+ }
90
+ delete normalized.domains;
91
+ warnings.push({
92
+ code: 'deprecated-domains-string',
93
+ severity: 'warning',
94
+ message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
95
+ });
96
+ }
97
+ return { projectDocs: normalized, warnings };
98
+ }
99
+
81
100
  function projectDocsConfigPath(workspaceRoot) {
82
101
  return path.join(workspaceRoot, PROJECT_DOCS_CONFIG);
83
102
  }
@@ -148,22 +167,12 @@ function inspectHostProjectDocs(workspaceRoot) {
148
167
  continue;
149
168
  }
150
169
 
151
- const projectDocs = { ...manifest.projectDocs };
152
- if (typeof projectDocs.domains === 'string') {
153
- if (typeof projectDocs.domainLocation !== 'string' || !projectDocs.domainLocation.trim()) {
154
- projectDocs.domainLocation = projectDocs.domains;
155
- }
156
- delete projectDocs.domains;
157
- entry.warnings.push({
158
- code: 'deprecated-domains-string',
159
- severity: 'warning',
160
- path: configSource(workspaceRoot, file),
161
- message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
162
- });
163
- }
170
+ const normalizedHost = normalizeHostProjectDocs(manifest.projectDocs);
171
+ const projectDocs = normalizedHost.projectDocs;
164
172
 
165
173
  const validation = validateDocsConfig(projectDocs);
166
174
  const source = configSource(workspaceRoot, file);
175
+ entry.warnings.push(...normalizedHost.warnings.map(item => ({ ...item, path: source })));
167
176
  entry.warnings.push(...validation.warnings.map(item => ({ ...item, path: source })));
168
177
  if (!validation.valid) {
169
178
  const details = validation.diagnostics.map(item => item.message).join('; ');
@@ -236,6 +245,13 @@ function validateDocsConfig(config) {
236
245
  return { valid: false, diagnostics, warnings };
237
246
  }
238
247
 
248
+ for (const field of ['docLocation', 'tracker', 'issueDefinition']) {
249
+ if (config[field] !== undefined && typeof config[field] !== 'string') {
250
+ const diagnosticField = field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
251
+ invalid(`invalid-${diagnosticField}-type`, `${field} must be a string when provided`);
252
+ }
253
+ }
254
+
239
255
  for (const key of Object.keys(config).sort()) {
240
256
  if (!PROJECT_DOC_CONFIG_KEYS.has(key)) {
241
257
  warnings.push({
@@ -754,6 +770,7 @@ module.exports = {
754
770
  readProjectDocs,
755
771
  resolveDocsConfig,
756
772
  validateDocsConfig,
773
+ normalizeHostProjectDocs,
757
774
  parseContextMap,
758
775
  resolveProjectDocs,
759
776
  detectProjectDocsConventions,
@@ -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.1",
4
+ "version": "0.36.3",
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.1",
3
+ "version": "0.36.3",
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.1",
3
+ "version": "0.36.3",
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",
@@ -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.1",
4
+ "version": "0.36.3",
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.1
7
+ version: 0.36.3
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -72,8 +72,18 @@ function artifact(entry) {
72
72
 
73
73
  // #335 P2: a baseline-bound rename destination must declare its own digest. An inherited
74
74
  // `digest` (for example Object.create({ digest })) is not explicit candidate evidence.
75
- function ownArtifactDigest(entry) {
76
- return isPlainObject(entry) && hasOwnEntry(entry, 'digest') ? artifact(entry) : '';
75
+ // P2(r4321077631): reads the own digest exactly once. `artifact()` inspects `entry.digest` up
76
+ // to three times (typeof, length, value), so a stateful accessor could validate one value and
77
+ // have the captured destination snapshot carry another. Only the applicable rename-destination
78
+ // snapshot uses this helper; non-baseline and inapplicable lanes keep the legacy `artifact()`
79
+ // reads for their audit identity. A throwing digest getter propagates to the caller's guarded
80
+ // block and fails the inspection closed there. This is the single authoritative own-digest
81
+ // helper; nothing else in this module reads a destination's own digest.
82
+ function ownDigestOnce(entry) {
83
+ if (!isPlainObject(entry)) return '';
84
+ if (!hasOwnEntry(entry, 'digest')) return '';
85
+ const digest = entry.digest;
86
+ return typeof digest === 'string' && digest.length > 0 ? digest : '';
77
87
  }
78
88
 
79
89
  function provenanceText(record) {
@@ -142,7 +152,20 @@ function checkDocObligations({ obligations, artifacts, baseline, executionRecord
142
152
  }
143
153
  return candidateInventoryKnown;
144
154
  };
145
- const baselineDigest = isPlainObject(baseline) && nonEmptyString(baseline.digest) ? baseline.digest : '';
155
+ // P2(r4321077638): the whole-baseline identity is read exactly once and that same scalar is
156
+ // validated, so a stateful getter cannot pass validation as A and have every later comparison
157
+ // use B. A baseline identity whose inspection throws is unusable input: the captured digest
158
+ // stays '', so baseline-bound evaluations fail closed through `baseline-identity-missing`
159
+ // instead of escaping the checker, and non-baseline callers keep the legacy '' shape.
160
+ let baselineDigest = '';
161
+ if (isPlainObject(baseline)) {
162
+ try {
163
+ const declaredBaselineDigest = baseline.digest;
164
+ if (nonEmptyString(declaredBaselineDigest)) baselineDigest = declaredBaselineDigest;
165
+ } catch {
166
+ baselineDigest = '';
167
+ }
168
+ }
146
169
  const executionList = Array.isArray(executionRecords) ? executionRecords.filter(isRecord) : [];
147
170
  const reviewList = Array.isArray(reviewRecords) ? reviewRecords.filter(isRecord) : [];
148
171
 
@@ -184,6 +207,16 @@ function evaluateObligation(obligation, context) {
184
207
  // #340: every accepted entry sharing a rename destination must validate the same own digest,
185
208
  // or the shared-destination snapshot is contradictory and the obligation fails closed.
186
209
  const validatedDestinationDigestMap = new Map();
210
+ // #342 P3: a rename destination whose own-digest read throws or returns an unusable value is
211
+ // still a visited snapshot. Its captured ('') value is recorded here so the ordinary capture
212
+ // and the audit identity never re-read that destination entry: the single metadata read stays
213
+ // the only digest read for that declaring entry, and a stateful getter cannot ship a later
214
+ // non-empty value into the audit key even though its entry was rejected. A poisoned
215
+ // destination is honored inside the metadata loop (a later declaring entry fails closed
216
+ // without reading it) and keeps precedence over a previously seeded successful snapshot;
217
+ // multiple accepted entries still read the destination once each by design, because their
218
+ // agreement check detects shared-destination contradictions.
219
+ const failedDestinationDigests = new Map();
187
220
  // #340 P2(r4320818749): the validated per-path baseline digest is captured once during
188
221
  // metadata validation. The audit mirror, the stale-baseline diagnostics, and baselineBound()
189
222
  // reuse that captured value instead of re-reading the caller map, so a stateful own getter
@@ -289,19 +322,38 @@ function evaluateObligation(obligation, context) {
289
322
  // keeps the un-gated legacy candidate read; only applicable obligations capture and
290
323
  // validate the destination snapshot.
291
324
  if (applicability === 'applicable') {
325
+ // #342 review P3: a destination poisoned by an earlier entry's failed snapshot is
326
+ // honored inside the metadata loop; the failed visited snapshot is never read again
327
+ // and this entry fails closed with it, so a stateful getter's later successful
328
+ // value cannot repair a shared destination or enter the audit.
329
+ if (failedDestinationDigests.has(entryRenameTo)) {
330
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} failed an earlier snapshot validation for this obligation; the failed destination is not read again and this entry fails closed with it`);
331
+ continue;
332
+ }
292
333
  let destinationDigest = '';
293
334
  try {
294
335
  if (hasOwnEntry(artifactMap, entryRenameTo)) {
295
- destinationDigest = ownArtifactDigest(artifactMap[entryRenameTo]);
336
+ // P2(r4321077631): read the destination's own digest exactly once; the captured
337
+ // value is the one validated below and the one that flows to the metadata cache,
338
+ // binding, the stale diagnostics, and the audit identity. A stateful destination
339
+ // getter cannot be validated as B while a record carrying only its later C passes.
340
+ destinationDigest = ownDigestOnce(artifactMap[entryRenameTo]);
296
341
  }
297
342
  } catch {
298
343
  candidateInventoryInspectionFailed = true;
344
+ // #342 P3: the destination is a visited snapshot even when its digest read threw;
345
+ // record it so the capture and audit lanes never re-read it.
346
+ failedDestinationDigests.set(entryRenameTo, '');
299
347
  continue;
300
348
  }
301
- // #340: the captured own value must be a usable non-empty string; `artifact()` can read
302
- // a stateful accessor three times, so a non-string result is not carried or seeded.
349
+ // #340: the captured own value must be a usable non-empty string; an unusable result
350
+ // is not carried or seeded.
303
351
  if (!nonEmptyString(destinationDigest)) {
304
352
  invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} has no candidate artifact`);
353
+ // #342 P3: the destination is a visited snapshot even when its own digest is
354
+ // unusable; record it so the capture and audit lanes never re-read it and cannot
355
+ // describe a stateful getter's later non-empty value.
356
+ failedDestinationDigests.set(entryRenameTo, '');
305
357
  continue;
306
358
  }
307
359
  // #340: entries that share one rename destination must agree on the validated own
@@ -377,7 +429,9 @@ function evaluateObligation(obligation, context) {
377
429
  // its own digest during metadata. Seed the captured snapshot from that validated value and
378
430
  // never read the destination through the raw candidate map again, so a destination accessor
379
431
  // that throws or changes after validation cannot make capture/binding/audit disagree. The
380
- // seeded value is the validated own digest, never a caller-asserted rawEntry field.
432
+ // seeded value is the validated own digest, never a caller-asserted rawEntry field. A
433
+ // rejected destination is still visited: its failed capture (see failedDestinationDigests)
434
+ // suppresses the ordinary read below, so no destination entry is ever read twice.
381
435
  for (const entry of baselineEntries.values()) {
382
436
  if (entry.reason === 'rename-from' && nonEmptyString(entry.renameDestinationDigest)
383
437
  && !candidateDigestCache.has(entry.renameTo)) {
@@ -385,7 +439,8 @@ function evaluateObligation(obligation, context) {
385
439
  }
386
440
  }
387
441
  for (const path of scopePaths) {
388
- if (baselineEntries.has(path) || candidateDigestCache.has(path)) continue;
442
+ if (baselineEntries.has(path) || candidateDigestCache.has(path)
443
+ || failedDestinationDigests.has(path)) continue;
389
444
  try {
390
445
  candidateDigestCache.set(path, artifact(artifactMap[path]));
391
446
  } catch {
@@ -428,6 +483,11 @@ function evaluateObligation(obligation, context) {
428
483
  // stale verdict disagree with the digest that actually bound evidence. Untouched paths and
429
484
  // unannotated callers keep the legacy read.
430
485
  const candidateDigestOf = (path) => {
486
+ // #342 P3: poisoned state takes precedence over the capture cache. A rejected rename
487
+ // destination keeps its visited '' capture even when an earlier accepted entry seeded a
488
+ // successful value, so the audit never describes a value the getter shipped after a failed
489
+ // snapshot made the obligation fail closed; the caller map is never read for it again.
490
+ if (failedDestinationDigests.has(path)) return '';
431
491
  if (candidateDigestCache.has(path)) return candidateDigestCache.get(path);
432
492
  // #340: only an applicable baseline-annotated obligation validated the source absence as
433
493
  // part of its captured snapshot. `N/A` and `unknown` obligations never bind evidence, so they
@@ -509,7 +569,7 @@ function evaluateObligation(obligation, context) {
509
569
  // candidate lane, the audit identity, and the stale diagnostics use, so a stateful nested
510
570
  // accessor cannot make binding disagree with them. Metadata validation already required the
511
571
  // destination to be an own-key candidate inventory entry carrying its own digest
512
- // (ownArtifactDigest), so a non-empty captured digest is required here and the accessor is
572
+ // (ownDigestOnce), so a non-empty captured digest is required here and the accessor is
513
573
  // never re-read; a missing destination skips capture and lands on this guard.
514
574
  const capturedDestination = candidateDigestCache.get(entry.renameTo);
515
575
  if (!nonEmptyString(capturedDestination)) return false;
@@ -78,6 +78,25 @@ function isPlainObject(value) {
78
78
  return prototype === Object.prototype || prototype === null;
79
79
  }
80
80
 
81
+ function normalizeHostProjectDocs(projectDocs) {
82
+ if (!isPlainObject(projectDocs)) return { projectDocs, warnings: [] };
83
+
84
+ const normalized = { ...projectDocs };
85
+ const warnings = [];
86
+ if (typeof normalized.domains === 'string') {
87
+ if (typeof normalized.domainLocation !== 'string' || !normalized.domainLocation.trim()) {
88
+ normalized.domainLocation = normalized.domains;
89
+ }
90
+ delete normalized.domains;
91
+ warnings.push({
92
+ code: 'deprecated-domains-string',
93
+ severity: 'warning',
94
+ message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
95
+ });
96
+ }
97
+ return { projectDocs: normalized, warnings };
98
+ }
99
+
81
100
  function projectDocsConfigPath(workspaceRoot) {
82
101
  return path.join(workspaceRoot, PROJECT_DOCS_CONFIG);
83
102
  }
@@ -148,22 +167,12 @@ function inspectHostProjectDocs(workspaceRoot) {
148
167
  continue;
149
168
  }
150
169
 
151
- const projectDocs = { ...manifest.projectDocs };
152
- if (typeof projectDocs.domains === 'string') {
153
- if (typeof projectDocs.domainLocation !== 'string' || !projectDocs.domainLocation.trim()) {
154
- projectDocs.domainLocation = projectDocs.domains;
155
- }
156
- delete projectDocs.domains;
157
- entry.warnings.push({
158
- code: 'deprecated-domains-string',
159
- severity: 'warning',
160
- path: configSource(workspaceRoot, file),
161
- message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
162
- });
163
- }
170
+ const normalizedHost = normalizeHostProjectDocs(manifest.projectDocs);
171
+ const projectDocs = normalizedHost.projectDocs;
164
172
 
165
173
  const validation = validateDocsConfig(projectDocs);
166
174
  const source = configSource(workspaceRoot, file);
175
+ entry.warnings.push(...normalizedHost.warnings.map(item => ({ ...item, path: source })));
167
176
  entry.warnings.push(...validation.warnings.map(item => ({ ...item, path: source })));
168
177
  if (!validation.valid) {
169
178
  const details = validation.diagnostics.map(item => item.message).join('; ');
@@ -236,6 +245,13 @@ function validateDocsConfig(config) {
236
245
  return { valid: false, diagnostics, warnings };
237
246
  }
238
247
 
248
+ for (const field of ['docLocation', 'tracker', 'issueDefinition']) {
249
+ if (config[field] !== undefined && typeof config[field] !== 'string') {
250
+ const diagnosticField = field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
251
+ invalid(`invalid-${diagnosticField}-type`, `${field} must be a string when provided`);
252
+ }
253
+ }
254
+
239
255
  for (const key of Object.keys(config).sort()) {
240
256
  if (!PROJECT_DOC_CONFIG_KEYS.has(key)) {
241
257
  warnings.push({
@@ -754,6 +770,7 @@ module.exports = {
754
770
  readProjectDocs,
755
771
  resolveDocsConfig,
756
772
  validateDocsConfig,
773
+ normalizeHostProjectDocs,
757
774
  parseContextMap,
758
775
  resolveProjectDocs,
759
776
  detectProjectDocsConventions,
@@ -4,7 +4,7 @@ description: "Scaffold a verified multi-agent workspace and select bounded speci
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.34.0
7
+ version: 0.36.3
8
8
  ---
9
9
 
10
10
  # Multi-Agent Workspace
@@ -78,6 +78,25 @@ function isPlainObject(value) {
78
78
  return prototype === Object.prototype || prototype === null;
79
79
  }
80
80
 
81
+ function normalizeHostProjectDocs(projectDocs) {
82
+ if (!isPlainObject(projectDocs)) return { projectDocs, warnings: [] };
83
+
84
+ const normalized = { ...projectDocs };
85
+ const warnings = [];
86
+ if (typeof normalized.domains === 'string') {
87
+ if (typeof normalized.domainLocation !== 'string' || !normalized.domainLocation.trim()) {
88
+ normalized.domainLocation = normalized.domains;
89
+ }
90
+ delete normalized.domains;
91
+ warnings.push({
92
+ code: 'deprecated-domains-string',
93
+ severity: 'warning',
94
+ message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
95
+ });
96
+ }
97
+ return { projectDocs: normalized, warnings };
98
+ }
99
+
81
100
  function projectDocsConfigPath(workspaceRoot) {
82
101
  return path.join(workspaceRoot, PROJECT_DOCS_CONFIG);
83
102
  }
@@ -148,22 +167,12 @@ function inspectHostProjectDocs(workspaceRoot) {
148
167
  continue;
149
168
  }
150
169
 
151
- const projectDocs = { ...manifest.projectDocs };
152
- if (typeof projectDocs.domains === 'string') {
153
- if (typeof projectDocs.domainLocation !== 'string' || !projectDocs.domainLocation.trim()) {
154
- projectDocs.domainLocation = projectDocs.domains;
155
- }
156
- delete projectDocs.domains;
157
- entry.warnings.push({
158
- code: 'deprecated-domains-string',
159
- severity: 'warning',
160
- path: configSource(workspaceRoot, file),
161
- message: 'host projectDocs.domains as a string is deprecated; use domainLocation for the zone path and domains as the ID-prefix array'
162
- });
163
- }
170
+ const normalizedHost = normalizeHostProjectDocs(manifest.projectDocs);
171
+ const projectDocs = normalizedHost.projectDocs;
164
172
 
165
173
  const validation = validateDocsConfig(projectDocs);
166
174
  const source = configSource(workspaceRoot, file);
175
+ entry.warnings.push(...normalizedHost.warnings.map(item => ({ ...item, path: source })));
167
176
  entry.warnings.push(...validation.warnings.map(item => ({ ...item, path: source })));
168
177
  if (!validation.valid) {
169
178
  const details = validation.diagnostics.map(item => item.message).join('; ');
@@ -236,6 +245,13 @@ function validateDocsConfig(config) {
236
245
  return { valid: false, diagnostics, warnings };
237
246
  }
238
247
 
248
+ for (const field of ['docLocation', 'tracker', 'issueDefinition']) {
249
+ if (config[field] !== undefined && typeof config[field] !== 'string') {
250
+ const diagnosticField = field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
251
+ invalid(`invalid-${diagnosticField}-type`, `${field} must be a string when provided`);
252
+ }
253
+ }
254
+
239
255
  for (const key of Object.keys(config).sort()) {
240
256
  if (!PROJECT_DOC_CONFIG_KEYS.has(key)) {
241
257
  warnings.push({
@@ -754,6 +770,7 @@ module.exports = {
754
770
  readProjectDocs,
755
771
  resolveDocsConfig,
756
772
  validateDocsConfig,
773
+ normalizeHostProjectDocs,
757
774
  parseContextMap,
758
775
  resolveProjectDocs,
759
776
  detectProjectDocsConventions,