harness-everything 0.48.0 → 0.50.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.
@@ -68,6 +68,7 @@ const OUTLINE_CAPTURE_MAPPING = Object.freeze({
68
68
  const OUTLINE_SEAM_SCHEMA = 'harness.outline-dispatch-seam.v1';
69
69
  const OUTLINE_HANDOFF_SCHEMA = 'harness.outline-handoff.v1';
70
70
  const OUTLINE_LEASE_SCHEMA = 'harness.outline-writer-lease.v1';
71
+ const OUTLINE_WRITE_TOOLS = ['Write', 'Edit', 'apply_patch'];
71
72
  const OUTLINE_INVENTORY_SOURCE = '/Users/daniel.chang/Desktop/Skills/Harness-everything/.worktrees/packets/adr-e1-2026-10-27/report-inv.md';
72
73
  const OUTLINE_INVENTORY_SHA256 = '2741bd4514d2cd68f57d93a2b3ce94cbe597ca62d30e26f0ca58e882df5b03c6';
73
74
  const OUTLINE_INVENTORY_PIN = Object.freeze({
@@ -960,6 +961,47 @@ function outlineHandoffDirectory(sessionDir, taskId) {
960
961
  return path.join(sessionDir, 'doc-reconciliation', 'outline-confirmation', taskId);
961
962
  }
962
963
 
964
+ // #341 step 1: Harness records O's identity at each SubagentStart inside an outline task. A reviewer's launch record predates
965
+ // its review, so the identity it carries is the O the reviewer evaluated; the record is not taken from the reviewer's prose.
966
+ function captureOutlineStartIdentity({ sessionDir } = {}) {
967
+ const taskState = outlineTaskState(sessionDir);
968
+ if (taskState.state !== 'accepted') return null;
969
+ const root = taskState.baseline.root;
970
+ try {
971
+ const commit = outlineGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
972
+ const tree = outlineGit(root, ['rev-parse', '--verify', 'HEAD^{tree}']).trim();
973
+ const targetPaths = outlineGit(root, ['diff', '--name-only', '--no-renames', '-z', taskState.baseline.commit, commit])
974
+ .split('\0').filter(Boolean).sort(compareKeys);
975
+ const targetBlobs = {};
976
+ for (const target of targetPaths) {
977
+ targetBlobs[target] = { blobOid: outlineGit(root, ['rev-parse', `${commit}:${target}`]).trim(),
978
+ storedBytesSha256: outlineSha(outlineGit(root, ['show', `${commit}:${target}`], true)) };
979
+ }
980
+ return { state: 'accepted', identity: { baselineCommit: taskState.baseline.commit, outlineCommit: commit,
981
+ outlineTree: tree, targetPaths, targetBlobs } };
982
+ } catch {
983
+ return { state: 'unresolved', reason: 'outline-identity-unavailable' };
984
+ }
985
+ }
986
+
987
+ // The reviewer has exactly one launch record, it precedes the review Stop, and its identity equals the handoff's O identity.
988
+ function validateOutlineIdentity({ handoff, captures } = {}) {
989
+ const refs = (Array.isArray(captures) ? captures : []).filter(recordOf);
990
+ const stop = refs.find(item => item.ref === handoff.reviewCaptureRef && item.event === 'SubagentStop');
991
+ if (!stop || !textOf(stop.agent_id)) return { state: 'unresolved', reason: 'outline-review-capture-missing-or-ambiguous' };
992
+ const starts = refs.filter(item => item.event === 'SubagentStart' && item.agent_id === stop.agent_id);
993
+ if (starts.length !== 1) return { state: 'unresolved', reason: 'outline-review-result-missing-or-ambiguous' };
994
+ if (refs.indexOf(starts[0]) > refs.indexOf(stop)) return { state: 'unresolved', reason: 'outline-identity-mismatch' };
995
+ const recorded = starts[0].outlineIdentity;
996
+ if (!recordOf(recorded) || recorded.state !== 'accepted' || !recordOf(recorded.identity)) {
997
+ return { state: 'unresolved', reason: 'outline-identity-unavailable' };
998
+ }
999
+ const current = { baselineCommit: handoff.baseline?.commit, outlineCommit: handoff.outline?.commit,
1000
+ outlineTree: handoff.outline?.tree, targetPaths: handoff.targetPaths, targetBlobs: handoff.outline?.targetBlobs };
1001
+ if (captureJson(recorded.identity) !== captureJson(current)) return { state: 'unresolved', reason: 'outline-identity-mismatch' };
1002
+ return { state: 'accepted', identityDigest: outlineSha(Buffer.from(captureJson(recorded.identity))) };
1003
+ }
1004
+
963
1005
  function validateOutlineReview({ handoff, attemptDir, captures, targetPaths, outlineDigests, inventory, sessionId } = {}) {
964
1006
  const refs = Array.isArray(captures) ? captures : [];
965
1007
  const matches = refs.filter(item => recordOf(item) && item.ref === handoff.reviewCaptureRef && item.event === 'SubagentStop');
@@ -1006,6 +1048,15 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
1006
1048
  || handoff.baseline.tree !== baseline.tree || handoff.configDigest !== taskBinding.configDigest) {
1007
1049
  return { state: 'unresolved', reason: 'handoff-identity-mismatch' };
1008
1050
  }
1051
+ // O must change exactly the declared targets. Checked before the identity so an extra file reports its scope reason.
1052
+ const changedAtO = outlineGit(baseline.root, ['diff', '--name-only', '--no-renames', '-z', baseline.commit, handoff.outline.commit])
1053
+ .split('\0').filter(Boolean).sort(compareKeys);
1054
+ if (captureJson(changedAtO) !== captureJson(Array.isArray(handoff.targetPaths) ? [...handoff.targetPaths].sort(compareKeys) : null)) {
1055
+ return { state: 'unresolved', reason: 'outline-commit-scope-invalid' };
1056
+ }
1057
+ // Dispatch must still name the O the reviewer recorded: checked before the worktree, lease and review checks.
1058
+ const recordedIdentity = validateOutlineIdentity({ handoff, captures });
1059
+ if (recordedIdentity.state !== 'accepted') return recordedIdentity;
1009
1060
  const markerRoot = canonicalOutlineRoot(parsed.workerRoot);
1010
1061
  const declaredRoot = canonicalOutlineRoot(handoff.draftWorktreeRoot);
1011
1062
  if (!sameCanonicalPath(markerRoot, declaredRoot)) return { state: 'unresolved', reason: 'worker-root-mismatch' };
@@ -1049,6 +1100,9 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
1049
1100
  for (const target of targetPaths) {
1050
1101
  const atBaseline = outlineGit(baseline.root, ['ls-tree', '-r', '--name-only', baseline.commit, '--', target]).trim();
1051
1102
  if (atBaseline.split('\n').filter(Boolean).includes(target)) return { state: 'unresolved', reason: 'outline-target-existed-at-B' };
1103
+ if (!outlineGitTreeHasRegularBlob(markerRoot, handoff.outline.tree, target)) {
1104
+ return { state: 'unresolved', reason: 'outline-target-object-not-regular' };
1105
+ }
1052
1106
  const bytes = outlineGit(markerRoot, ['show', `${handoff.outline.commit}:${target}`], true);
1053
1107
  const blobOid = outlineGit(markerRoot, ['rev-parse', `${handoff.outline.commit}:${target}`]).trim();
1054
1108
  const expected = outlineBlobs[target];
@@ -1087,7 +1141,7 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
1087
1141
  return { state: 'accepted', taskBinding, baseline, handoff, handoffDigest, attemptDir, markerRoot,
1088
1142
  worktree: { root: markerRoot, head, tree, statusDigest, clean: true },
1089
1143
  inventory, inventoryPinDigest: outlineSha(pinLoaded.bytes), row: citedRow, targetPaths, outlineDigests, review,
1090
- leaseDigest: leaseLoaded.digest, leaseId: lease.id };
1144
+ leaseDigest: leaseLoaded.digest, leaseId: lease.id, identityDigest: recordedIdentity.identityDigest };
1091
1145
  }
1092
1146
 
1093
1147
  function outlineDispatchUnresolved(parsed, reason) {
@@ -1147,7 +1201,8 @@ function captureOutlineDispatch(input = {}) {
1147
1201
  tokenDigest: parsed.tokenDigest, promptDigest: parsed.promptDigest, blockDigest: parsed.blockDigest,
1148
1202
  dispatchInputDigest: parsed.dispatchInputDigest, handoffDigest: validated.handoffDigest,
1149
1203
  reviewCaptureRef: validated.review.review.ref, reviewCaptureDigest: validated.review.captureDigest,
1150
- reviewDigest: validated.review.reviewDigest, inventoryPinDigest: validated.inventoryPinDigest,
1204
+ reviewDigest: validated.review.reviewDigest, outlineIdentityDigest: validated.identityDigest,
1205
+ inventoryPinDigest: validated.inventoryPinDigest,
1151
1206
  writerLeaseDigest: validated.leaseDigest, writerLeaseId: validated.leaseId,
1152
1207
  coordinatorPrincipal: payload.session_id, reviewerPrincipal: validated.review.reviewerPrincipal,
1153
1208
  worktree: validated.worktree, validationReasons: [],
@@ -1384,11 +1439,208 @@ function outlineAncestry(root, ancestor, descendant) {
1384
1439
 
1385
1440
  function possibleWriterCapture(item) {
1386
1441
  if (!recordOf(item)) return false;
1387
- if (['Write', 'Edit', 'apply_patch'].includes(item.tool_name)) return true;
1442
+ if (OUTLINE_WRITE_TOOLS.includes(item.tool_name)) return true;
1388
1443
  if (!['Bash', 'PowerShell', 'exec_command'].includes(item.tool_name)) return false;
1389
1444
  return !['read-only', 'worktree-setup'].includes(classifyShell(item.command));
1390
1445
  }
1391
1446
 
1447
+ function writeTargetsOf(toolName, toolInput) {
1448
+ if (toolName === 'apply_patch') {
1449
+ const patch = typeof toolInput === 'string' ? toolInput : (recordOf(toolInput) ? String(toolInput.patch || toolInput.input || '') : '');
1450
+ const targets = [];
1451
+ for (const match of patch.matchAll(/^\*\*\* ([^\r\n]*)(?:\r?$)/gm)) {
1452
+ const header = match[1];
1453
+ if (['Begin Patch', 'End Patch', 'End of File'].includes(header)) continue;
1454
+ const fileHeader = /^(?:Add File|Update File|Delete File|Move to): (.*)$/.exec(header);
1455
+ if (!fileHeader) return null;
1456
+ targets.push(fileHeader[1]);
1457
+ }
1458
+ return targets;
1459
+ }
1460
+ const file = recordOf(toolInput) ? toolInput.file_path || toolInput.path : undefined;
1461
+ return typeof file === 'string' && file.length ? [file] : [];
1462
+ }
1463
+
1464
+ function validOutlineTarget(target) {
1465
+ return typeof target === 'string' && target.length > 0 && !target.includes('\0') && !target.includes('\\')
1466
+ && !target.includes(':') && !path.posix.isAbsolute(target)
1467
+ && target.split('/').every(part => part && part !== '.' && part !== '..');
1468
+ }
1469
+
1470
+ function outlineRootSpellings(root) {
1471
+ try {
1472
+ if (typeof root !== 'string' || !path.isAbsolute(root)) return [];
1473
+ return [...new Set([root, canonicalOutlineRoot(root)])];
1474
+ } catch { return []; }
1475
+ }
1476
+
1477
+ function outlineCwdMatchesRoot(cwd, root, allowVerifiedAlias = false) {
1478
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
1479
+ if (outlineRootSpellings(root).includes(cwd)) return true;
1480
+ if (!allowVerifiedAlias) return false;
1481
+ try { return canonicalOutlineRoot(cwd) === canonicalOutlineRoot(root); }
1482
+ catch { return false; }
1483
+ }
1484
+
1485
+ function outlineCaptureRootSpellings(root, cwd) {
1486
+ const roots = outlineRootSpellings(root);
1487
+ // Hook setup may retain the canonical root while the captured cwd keeps its original spelling; this affects topology only.
1488
+ if (outlineCwdMatchesRoot(cwd, root, true)) roots.push(cwd);
1489
+ return [...new Set(roots)];
1490
+ }
1491
+
1492
+ function outlineRelativeSpellingKey(spelling, toolName) {
1493
+ if (validOutlineTarget(spelling)) return spelling;
1494
+ if (process.platform !== 'win32' || !OUTLINE_WRITE_TOOLS.includes(toolName)
1495
+ || !spelling.includes(path.sep) || spelling.includes('/')) return null;
1496
+ const candidate = spelling.split(path.sep).join('/');
1497
+ return validOutlineTarget(candidate) ? candidate : null;
1498
+ }
1499
+
1500
+ function outlineSpellingKey(spelling, { root, cwd, toolName } = {}) {
1501
+ if (typeof spelling !== 'string' || !spelling.length || spelling.includes('\0')
1502
+ || !outlineCwdMatchesRoot(cwd, root, true)) return null;
1503
+ const relative = outlineRelativeSpellingKey(spelling, toolName);
1504
+ if (relative) return relative;
1505
+ for (const rootSpelling of outlineCaptureRootSpellings(root, cwd)) {
1506
+ const prefix = rootSpelling.endsWith(path.sep) ? rootSpelling : `${rootSpelling}${path.sep}`;
1507
+ if (!spelling.startsWith(prefix)) continue;
1508
+ return outlineRelativeSpellingKey(spelling.slice(prefix.length), toolName);
1509
+ }
1510
+ return null;
1511
+ }
1512
+
1513
+ function outlineTargetSpellings(target, { root, toolName } = {}) {
1514
+ if (!validOutlineTarget(target)) return [];
1515
+ const targetSpellings = [target];
1516
+ if (process.platform === 'win32' && OUTLINE_WRITE_TOOLS.includes(toolName)) {
1517
+ targetSpellings.push(target.split('/').join(path.sep));
1518
+ }
1519
+ const spellings = [...targetSpellings];
1520
+ for (const rootSpelling of outlineRootSpellings(root)) {
1521
+ const prefix = rootSpelling.endsWith(path.sep) ? rootSpelling : `${rootSpelling}${path.sep}`;
1522
+ for (const renderedTarget of targetSpellings) spellings.push(prefix + renderedTarget);
1523
+ }
1524
+ return spellings;
1525
+ }
1526
+
1527
+ // Capture can prove a lexical path/topology before the later handoff supplies the pin. Its candidate is non-authorizing;
1528
+ // the assessment call below must match the same raw spelling against the validated pinned target set.
1529
+ function recognizeOutlineTarget(spelling, { root, cwd, targets, toolName } = {}) {
1530
+ const hasPinnedTargets = Array.isArray(targets);
1531
+ if (typeof spelling !== 'string' || !spelling.length || spelling.includes('\0')
1532
+ || !outlineCwdMatchesRoot(cwd, root, !hasPinnedTargets)) return null;
1533
+ if (Array.isArray(targets)) {
1534
+ const target = targets.find(item => outlineTargetSpellings(item, { root, toolName }).includes(spelling));
1535
+ return target ? { candidate: target, target } : null;
1536
+ }
1537
+ const candidate = outlineSpellingKey(spelling, { root, cwd, toolName });
1538
+ if (!candidate) return null;
1539
+ return { candidate, target: null };
1540
+ }
1541
+
1542
+ function outlineBToOGitAllowed(command, { cwd, root, targets, topology }) {
1543
+ const parsed = safeTokenize(String(command || '').trim());
1544
+ if (!parsed.ok || parsed.tokens[0] !== 'git' || parsed.tokens.length < 2) return false;
1545
+ const [operation, ...args] = parsed.tokens.slice(1);
1546
+ if (operation === 'add') {
1547
+ const hasOperandMarker = args[0] === '--';
1548
+ const operands = hasOperandMarker ? args.slice(1) : args;
1549
+ if (!operands.length || !Array.isArray(topology) || topology.length !== operands.length) return false;
1550
+ if (!hasOperandMarker && operands.some(operand => operand.startsWith('-'))) return false;
1551
+ return operands.every((operand, index) => {
1552
+ if (!operand || operand.startsWith(':') || /[*?\[\]{}]/.test(operand) || /[\\/]$/.test(operand)) return false;
1553
+ const target = recognizeOutlineTarget(operand, { root, cwd, targets, toolName: 'Bash' })?.target;
1554
+ const proof = topology[index];
1555
+ return Boolean(target && proof?.path === target && proof.state === 'regular'
1556
+ && outlineFilesystemProof(root, root, target, false).state === 'regular');
1557
+ });
1558
+ }
1559
+ return operation === 'commit' && args.length === 2 && args[0] === '-m' && args[1] !== '';
1560
+ }
1561
+
1562
+ function outlineFilesystemProof(root, cwd, target, allowMissing) {
1563
+ let relativePath;
1564
+ try {
1565
+ const canonicalRoot = canonicalOutlineRoot(root);
1566
+ const base = canonicalOutlineRoot(cwd || root);
1567
+ if (base !== canonicalRoot) return { path: null, state: 'unresolved' };
1568
+ const relative = path.relative(canonicalRoot, path.resolve(canonicalRoot, target));
1569
+ if (!relative || path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) {
1570
+ return { path: null, state: 'unresolved' };
1571
+ }
1572
+ const absolute = path.resolve(canonicalRoot, relative);
1573
+ relativePath = path.relative(canonicalRoot, absolute);
1574
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {
1575
+ return { path: null, state: 'unresolved' };
1576
+ }
1577
+ const relativeKey = relativePath.split(path.sep).join('/');
1578
+ let current = canonicalRoot;
1579
+ const parts = relativePath.split(path.sep);
1580
+ for (let index = 0; index < parts.length; index++) {
1581
+ current = path.join(current, parts[index]);
1582
+ let stat;
1583
+ try { stat = fs.lstatSync(current); }
1584
+ catch (error) {
1585
+ return error.code === 'ENOENT' && allowMissing
1586
+ ? { path: relativeKey, state: 'absent' } : { path: relativeKey, state: 'unresolved' };
1587
+ }
1588
+ if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) {
1589
+ return { path: relativeKey, state: 'unresolved' };
1590
+ }
1591
+ if (index === parts.length - 1) return { path: relativeKey, state: stat.isFile() ? 'regular' : 'unresolved' };
1592
+ }
1593
+ } catch { /* filesystem state is evidence only when every component can be checked */ }
1594
+ return { path: relativePath ? relativePath.split(path.sep).join('/') : null, state: 'unresolved' };
1595
+ }
1596
+
1597
+ function outlineGitTreeHasRegularBlob(root, tree, target) {
1598
+ try {
1599
+ const entries = outlineGit(root, ['--literal-pathspecs', 'ls-tree', '-z', tree, '--', target], true)
1600
+ .toString('utf8').split('\0').filter(Boolean);
1601
+ if (entries.length !== 1) return false;
1602
+ const tab = entries[0].indexOf('\t');
1603
+ if (tab < 0 || entries[0].slice(tab + 1) !== target) return false;
1604
+ const [mode, type, oid] = entries[0].slice(0, tab).split(/\s+/);
1605
+ return type === 'blob' && ['100644', '100755'].includes(mode) && /^[0-9a-f]+$/i.test(oid);
1606
+ } catch { return false; }
1607
+ }
1608
+
1609
+ // Phase-aware writer decision (W1–W3). Principal, phase and operation-time path topology come from captured records.
1610
+ // Allowed: coordinator Write/Edit/apply_patch of exactly a pinned target before the reviewer lifecycle starts; coordinator
1611
+ // target-bound git add or simple git commit before review; the correlated draft worker
1612
+ // between dispatch and its Stop. Everything else, including any coordinator state write after review, stays ambiguous.
1613
+ function outlineWriterAllowed(item, { index, sessionId, root, targets, worker, workerRoot, reviewStartIndex, reviewStopIndex,
1614
+ dispatchIndex, workerStopIndex, captures }) {
1615
+ const phase = index < reviewStartIndex ? 'b-to-o' : index <= reviewStopIndex ? 'review'
1616
+ : index < dispatchIndex ? 'review-to-dispatch' : index <= workerStopIndex ? 'dispatch-to-draft' : 'after-draft';
1617
+ if (phase === 'dispatch-to-draft') return item.agent_id === worker.agent_id && sameCanonicalPath(item.cwd, workerRoot);
1618
+ if (phase !== 'b-to-o' || textOf(item.agent_id) || item.session_id !== sessionId) return false;
1619
+ if (item.event !== 'PreToolUse') {
1620
+ const pre = (Array.isArray(captures) ? captures : []).find(record => record.event === 'PreToolUse'
1621
+ && record.session_id === item.session_id && record.tool_use_id === item.tool_use_id
1622
+ && record.tool_name === item.tool_name && record.command === item.command
1623
+ && captureJson(record.writeTargets || null) === captureJson(item.writeTargets || null)
1624
+ && samePathKey(record.cwd || root, item.cwd || root));
1625
+ return Boolean(pre && outlineWriterAllowed(pre, { index: captures.indexOf(pre), sessionId, root, targets, worker, workerRoot,
1626
+ reviewStartIndex, reviewStopIndex, dispatchIndex, workerStopIndex, captures }));
1627
+ }
1628
+ if (OUTLINE_WRITE_TOOLS.includes(item.tool_name)) {
1629
+ const topology = item.outlineWriteTopology;
1630
+ return Array.isArray(item.writeTargets) && item.writeTargets.length > 0
1631
+ && Array.isArray(topology) && topology.length === item.writeTargets.length
1632
+ && item.writeTargets.every((file, index) => {
1633
+ const target = recognizeOutlineTarget(file, { root, cwd: item.cwd, targets, toolName: item.tool_name })?.target;
1634
+ const proof = topology[index];
1635
+ return Boolean(target && proof?.path === target && ['absent', 'regular'].includes(proof.state)
1636
+ && outlineFilesystemProof(root, root, target, false).state === 'regular');
1637
+ });
1638
+ }
1639
+ const cwd = item.cwd;
1640
+ return item.tool_name === 'Bash' && outlineCwdMatchesRoot(cwd, root)
1641
+ && outlineBToOGitAllowed(item.command, { cwd, root, targets, topology: item.outlineWriteTopology });
1642
+ }
1643
+
1392
1644
  function sameCanonicalPath(left, right) {
1393
1645
  try {
1394
1646
  fs.statSync(left);
@@ -1418,7 +1670,10 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
1418
1670
  const dispatch = cap?.outlineDispatch;
1419
1671
  if (!recordOf(dispatch) || dispatch.schema !== OUTLINE_DISPATCH_SCHEMA || dispatch.state !== 'accepted'
1420
1672
  || !textOf(dispatch.seamRef) || dispatch.captureRef !== cap.ref || dispatch.toolUseId !== cap.tool_use_id) {
1421
- return fail('outline-pre-dispatch-seam-unresolved');
1673
+ // The dispatch-time reason (for example outline-identity-mismatch) is kept beside the wrapper code, so the rejection names its cause.
1674
+ const reasons = recordOf(dispatch) && Array.isArray(dispatch.reasons) ? dispatch.reasons.filter(reason => /^[a-z0-9-]+$/.test(String(reason))) : [];
1675
+ return { status: 'rejected', diagnostics: [diagnostic('outline-pre-dispatch-seam-unresolved', { reason: 'outline-pre-dispatch-seam-unresolved' }),
1676
+ ...reasons.map(reason => diagnostic(reason, { reason }))] };
1422
1677
  }
1423
1678
  if (cap.event !== 'PreToolUse' || !['Task', 'Agent'].includes(cap.tool_name)
1424
1679
  || cap.session_id !== safeInput.sessionId || cap.observedIdentity?.taskPin?.taskId !== safeInput.taskId) {
@@ -1475,6 +1730,9 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
1475
1730
  const outlineDigests = {};
1476
1731
  const reviewRows = {};
1477
1732
  for (const target of targetPaths) {
1733
+ if (!outlineGitTreeHasRegularBlob(seam.worktree.root, handoff.outline.tree, target)) {
1734
+ return fail('outline-target-object-not-regular');
1735
+ }
1478
1736
  const bytes = outlineGit(seam.worktree.root, ['show', `${handoff.outline.commit}:${target}`], true);
1479
1737
  outlineDigests[target] = outlineSha(bytes);
1480
1738
  const citation = parseOutlineCitation(bytes, target);
@@ -1527,9 +1785,44 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
1527
1785
  && item.event === 'SubagentStart' && item.ref !== worker.ref && sameCanonicalPath(item.cwd, workerRoot)
1528
1786
  && !stoppedBeforeDispatch(item) && !startedAfterWorkerStop(item));
1529
1787
  if (competingStarts.length) return fail('outline-competing-writer-start');
1530
- const unattributedWriters = (Array.isArray(captures) ? captures : []).filter(item => possibleWriterCapture(item)
1531
- && (item.agent_id !== worker.agent_id || !sameCanonicalPath(item.cwd, workerRoot)));
1788
+ // Reviewer lifecycle is exactly one Start and one Stop for its identity; a duplicate or missing record withholds credit.
1789
+ const reviewerLifecycle = event => journal.filter(item => recordOf(item) && item.event === event
1790
+ && item.agent_id === review.reviewerPrincipal).length;
1791
+ if (reviewerLifecycle('SubagentStart') !== 1 || reviewerLifecycle('SubagentStop') !== 1) {
1792
+ return fail('outline-review-result-missing-or-ambiguous');
1793
+ }
1794
+ // The O the reviewer recorded at launch must still be the O dispatch and the handoff name (#341 step 1).
1795
+ const identityNow = validateOutlineIdentity({ handoff, captures });
1796
+ if (identityNow.state !== 'accepted' || identityNow.identityDigest !== seam.outlineIdentityDigest) {
1797
+ return fail(identityNow.reason || 'outline-identity-mismatch');
1798
+ }
1799
+ // Review window: the reviewer's first record (or its launch result) through its SubagentStop.
1800
+ const reviewLaunchIds = new Set(journal.filter(item => recordOf(item) && item.event === 'PostToolUse'
1801
+ && ['Task', 'Agent'].includes(item.tool_name) && item.agent_id === review.reviewerPrincipal && textOf(item.tool_use_id))
1802
+ .map(item => item.tool_use_id));
1803
+ const writerWindow = {
1804
+ sessionId: safeInput.sessionId, root: taskState.baseline.root, targets: targetPaths, worker, workerRoot,
1805
+ captures: journal,
1806
+ reviewStartIndex: journal.findIndex(item => recordOf(item) && (item.agent_id === review.reviewerPrincipal
1807
+ || (textOf(item.tool_use_id) && reviewLaunchIds.has(item.tool_use_id)))),
1808
+ reviewStopIndex: journal.indexOf(review.review), dispatchIndex, workerStopIndex: journal.indexOf(correlation.stop),
1809
+ };
1810
+ const unattributedWriters = journal.filter((item, index) => possibleWriterCapture(item)
1811
+ && !outlineWriterAllowed(item, { ...writerWindow, index }));
1532
1812
  if (unattributedWriters.length) return fail('outline-writer-activity-ambiguous');
1813
+ // The B→O commit is accepted only if O changes exactly the pinned target(s) relative to B.
1814
+ const oChanged = outlineGit(seam.worktree.root, ['diff', '--name-only', '--no-renames', '-z', taskState.baseline.commit,
1815
+ handoff.outline.commit]).split('\0').filter(Boolean).sort(compareKeys);
1816
+ if (captureJson(oChanged) !== captureJson([...targetPaths].sort(compareKeys))) return fail('outline-commit-scope-invalid');
1817
+ // The cumulative diff hides content committed then reverted inside B..O, so every commit (merges too, per parent) must touch only the targets.
1818
+ const targetSet = new Set(targetPaths);
1819
+ const rangeCommits = outlineGit(seam.worktree.root, ['rev-list', `${taskState.baseline.commit}..${handoff.outline.commit}`])
1820
+ .split('\n').filter(Boolean);
1821
+ for (const commit of rangeCommits) {
1822
+ const touched = outlineGit(seam.worktree.root, ['diff-tree', '-r', '--root', '-m', '--no-commit-id', '--name-only',
1823
+ '--no-renames', '-z', commit]).split('\0').filter(Boolean);
1824
+ if (touched.some(file => !targetSet.has(file))) return fail('outline-commit-scope-invalid');
1825
+ }
1533
1826
 
1534
1827
  let candidateRoot;
1535
1828
  try { candidateRoot = canonicalOutlineRoot(safeInput.workspaceRoot); }
@@ -2227,12 +2520,40 @@ function buildCaptureRecord(payload, eventName, workspaceRoot) {
2227
2520
  responseDigest: captureDigest(toolResponse),
2228
2521
  responseRef: `host-payload/${event}/${captureId}`,
2229
2522
  };
2523
+ const parsedWriterCommand = event === 'PreToolUse' && payload.tool_name === 'Bash'
2524
+ ? safeTokenize(record.command.trim()) : null;
2525
+ const writerMayNeedCwd = OUTLINE_WRITE_TOOLS.includes(payload.tool_name)
2526
+ || (parsedWriterCommand?.ok && parsedWriterCommand.tokens[0] === 'git'
2527
+ && ['add', 'commit'].includes(parsedWriterCommand.tokens[1]));
2230
2528
  if (typeof payload.cwd === 'string' && payload.cwd) record.cwd = payload.cwd;
2231
- else if (!['SubagentStart', 'SubagentStop'].includes(event) && typeof workspaceRoot === 'string' && workspaceRoot) record.cwd = workspaceRoot;
2529
+ else if (!writerMayNeedCwd && !['SubagentStart', 'SubagentStop'].includes(event)
2530
+ && typeof workspaceRoot === 'string' && workspaceRoot) record.cwd = workspaceRoot;
2232
2531
  if (payload.prompt_id !== undefined) record.prompt_id = payload.prompt_id;
2233
2532
  if (payload.parent_tool_use_id !== undefined) record.parent_tool_use_id = payload.parent_tool_use_id;
2234
2533
  // The launch tool name separates the Agent/Task result surface and the subagent launch from executed tools (F4/F5).
2235
2534
  if (typeof payload.tool_name === 'string') record.tool_name = payload.tool_name;
2535
+ // The write target and its PreToolUse topology snapshot are path evidence for W1/W2; absent for other tools.
2536
+ if (OUTLINE_WRITE_TOOLS.includes(payload.tool_name)) record.writeTargets = writeTargetsOf(payload.tool_name, payload.tool_input);
2537
+ // shortcut: this snapshot cannot close capture-to-operation races; upgrade if the host can bind path proof atomically.
2538
+ if (event === 'PreToolUse') {
2539
+ let topologyTargets = OUTLINE_WRITE_TOOLS.includes(payload.tool_name) ? record.writeTargets : null;
2540
+ let allowMissing = OUTLINE_WRITE_TOOLS.includes(payload.tool_name);
2541
+ if (payload.tool_name === 'Bash') {
2542
+ const parsed = safeTokenize(record.command.trim());
2543
+ if (parsed.ok && parsed.tokens[0] === 'git' && parsed.tokens[1] === 'add') {
2544
+ const args = parsed.tokens.slice(2);
2545
+ topologyTargets = args[0] === '--' ? args.slice(1) : args;
2546
+ allowMissing = false;
2547
+ }
2548
+ }
2549
+ if (Array.isArray(topologyTargets)) {
2550
+ record.outlineWriteTopology = topologyTargets.map(target => {
2551
+ const recognized = recognizeOutlineTarget(target, { root: workspaceRoot, cwd: record.cwd, toolName: payload.tool_name });
2552
+ return recognized ? outlineFilesystemProof(workspaceRoot, record.cwd, recognized.candidate, allowMissing)
2553
+ : { path: null, state: 'unresolved' };
2554
+ });
2555
+ }
2556
+ }
2236
2557
  if (outlinePre) {
2237
2558
  if (Object.prototype.hasOwnProperty.call(toolInput, 'run_in_background')) record.run_in_background = toolInput.run_in_background;
2238
2559
  record.isolationSet = Object.prototype.hasOwnProperty.call(toolInput, 'isolation');
@@ -2317,6 +2638,8 @@ function buildCaptureRecord(payload, eventName, workspaceRoot) {
2317
2638
  // Each capture is one event file, created exclusively and never rewritten by this code. The compatibility journal
2318
2639
  // `doc-reconciliation-capture.json` is rebuilt from those files, and readers compare the two. This detects accidental and
2319
2640
  // journal-only edits. It does not stop the local agent from rewriting event files, which the owner's bound accepts (F13).
2641
+ // F13 includes rewriting the reviewer Start event's W2 `outlineIdentity`; W2 revalidation assumes authentic captured
2642
+ // events and does not authenticate locally rewritten capture state.
2320
2643
  const EVENT_DIR = 'doc-reconciliation-capture';
2321
2644
  const JOURNAL_FILE = 'doc-reconciliation-capture.json';
2322
2645
  const EVENT_NAME = /^\d{8}\.json$/;
@@ -2410,8 +2733,9 @@ function reviewRecordIndependent(cap, captures, principals) {
2410
2733
  module.exports = { createClaudeOriginProvider, createOpencodeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
2411
2734
  registeredInstalledCheck: resolveDocCheckInvocation, installedDocCheck,
2412
2735
  fileDigest, SHIPPED_DOC_CHECK_DIGEST, trustRisk, journalExecutors, reviewRecordIndependent,
2413
- captureOutlineDispatch, writeImmutableEvent, rebuildJournalView, readCaptureView, readOpencodeCaptureView, observedIdentityFor,
2414
- _internals: { captureJson, captureDigest, digestText, canonicalCapture, parseOutlineDispatchPrompt,
2736
+ captureOutlineDispatch, captureOutlineStartIdentity, writeImmutableEvent, rebuildJournalView, readCaptureView,
2737
+ readOpencodeCaptureView, observedIdentityFor,
2738
+ _internals: { validateOutlineIdentity, captureJson, captureDigest, digestText, canonicalCapture, parseOutlineDispatchPrompt,
2415
2739
  parseInventoryPinRows, validateOutlineInventoryPin, resolveInventoryRow, parseOutlineCitation, resolveOutlineAttempt,
2416
2740
  captureOutlineDispatch, resolveOutlineEvidence, verifyOutlineMergeCorrespondence, validateOutlineCaptureQualification,
2417
2741
  outlineCaptureMappingValid, correlateOutlineDispatch, OUTLINE_CAPTURE_MAPPING, OUTLINE_INVENTORY_PIN } };
@@ -148,6 +148,11 @@ function persistPilotCapture(payload, eventName, sessionId, root) {
148
148
  // Observation-time identity: the workflow and pin in force when the host event was captured.
149
149
  const observed = provider.observedIdentityFor(sessionDir);
150
150
  if (observed) entry.observedIdentity = observed;
151
+ // #341 step 1: O's identity is taken at launch records, so a reviewer's identity predates its review.
152
+ if (eventName === 'SubagentStart') {
153
+ const outlineIdentity = provider.captureOutlineStartIdentity({ sessionDir });
154
+ if (outlineIdentity) entry.outlineIdentity = outlineIdentity;
155
+ }
151
156
  if (eventName === 'PreToolUse' && (entry.tool_name === 'Agent' || entry.tool_name === 'Task')) {
152
157
  try {
153
158
  const existing = provider.readCaptureView(sessionDir);
@@ -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.48.0",
4
+ "version": "0.50.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",
package/sbom.cdx.json CHANGED
@@ -2,23 +2,23 @@
2
2
  "$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.7",
5
- "serialNumber": "urn:uuid:57b21a2b-5078-5819-9395-e415e041805a",
5
+ "serialNumber": "urn:uuid:d9117a7d-8bb7-597a-91d9-1bb5afbdcbb0",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-11-07T15:37:19.137Z",
8
+ "timestamp": "2026-11-09T00:33:16.486Z",
9
9
  "tools": [
10
10
  {
11
11
  "vendor": "Harness Everything",
12
12
  "name": "generate-sbom.js",
13
- "version": "0.48.0"
13
+ "version": "0.50.0"
14
14
  }
15
15
  ],
16
16
  "component": {
17
17
  "type": "application",
18
- "bom-ref": "pkg:npm/harness-everything@0.48.0",
18
+ "bom-ref": "pkg:npm/harness-everything@0.50.0",
19
19
  "name": "harness-everything",
20
- "version": "0.48.0",
21
- "purl": "pkg:npm/harness-everything@0.48.0"
20
+ "version": "0.50.0",
21
+ "purl": "pkg:npm/harness-everything@0.50.0"
22
22
  }
23
23
  },
24
24
  "components": [
@@ -2033,7 +2033,7 @@
2033
2033
  ]
2034
2034
  },
2035
2035
  {
2036
- "ref": "pkg:npm/harness-everything@0.48.0",
2036
+ "ref": "pkg:npm/harness-everything@0.50.0",
2037
2037
  "dependsOn": [
2038
2038
  "pkg:npm/%40commitlint/cli@19.8.1",
2039
2039
  "pkg:npm/%40commitlint/config-conventional@19.8.1",
@@ -17,15 +17,19 @@ const TRUST_SURFACE_PATHS = [
17
17
  'plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js',
18
18
  '.agents/harness-everything/project-docs.json',
19
19
  '.agents/harness-everything/document-inventory.json',
20
- '.github/workflows/ci.yml',
20
+ '.agents/harness-everything/errors/README.md',
21
+ '.github/workflows/doc-governance.yml',
21
22
  'package.json',
22
23
  'self-evolve/scripts/self-regression.js',
23
24
  'ci/mechanism-test.js',
24
25
  'ci/mutation-check.js',
25
26
  'ci/lib/isolated-test-process.js',
26
27
  'ci/test-helper.js',
28
+ 'scripts/errors-sync.js',
27
29
  'scripts/verify-all.js'
28
30
  ];
31
+ const MEMBERSHIP_ONLY_PATHS = ['AGENTS.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'PRIVACY.md', 'TERMS.md', 'docs/document-lifecycle.md'];
32
+ const MEMBERSHIP_ONLY_PREFIXES = ['docs/policy/', 'docs/prd/'];
29
33
  const TRUST_SURFACE_PREFIXES = ['ci/fixtures/doc-lifecycle/scenarios/'];
30
34
 
31
35
  function sha256(bytes) {
@@ -207,9 +211,25 @@ function sameIdentity(left, right) {
207
211
  return stable(identity(left)) === stable(identity(right));
208
212
  }
209
213
 
214
+ function sameMembership(left, right) {
215
+ if (!left || !right) return left === right;
216
+ return regularBlob(left) && regularBlob(right) && left.mode === right.mode;
217
+ }
218
+
210
219
  function trustSurfacePaths(baseTree, candidateTree) {
211
220
  const fixed = TRUST_SURFACE_PATHS.map(relativePath => ({ key: pathKey(relativePath), path: relativePath }));
212
- const fixedKeys = new Set(fixed.map(item => item.key));
221
+ const membershipPaths = new Map(MEMBERSHIP_ONLY_PATHS.map(relativePath => [pathKey(relativePath), relativePath]));
222
+ for (const sourceTree of [baseTree, candidateTree]) {
223
+ for (const [key, entry] of sourceTree) {
224
+ if (MEMBERSHIP_ONLY_PREFIXES.some(prefix => entry.pathBytes.subarray(0, Buffer.byteLength(prefix)).equals(Buffer.from(prefix)))) {
225
+ membershipPaths.set(key, pathText(entry.pathBytes));
226
+ }
227
+ }
228
+ }
229
+ const membership = [...membershipPaths]
230
+ .sort(([left], [right]) => Buffer.compare(Buffer.from(left, 'base64'), Buffer.from(right, 'base64')))
231
+ .map(([key, path]) => ({ key, path, membershipOnly: true }));
232
+ const fixedKeys = new Set([...fixed, ...membership].map(item => item.key));
213
233
  const fixtureEntries = new Map();
214
234
  for (const sourceTree of [baseTree, candidateTree]) {
215
235
  for (const [key, entry] of sourceTree) {
@@ -220,7 +240,7 @@ function trustSurfacePaths(baseTree, candidateTree) {
220
240
  const fixtures = [...fixtureEntries]
221
241
  .sort(([left], [right]) => Buffer.compare(Buffer.from(left, 'base64'), Buffer.from(right, 'base64')))
222
242
  .map(([key, path]) => ({ key, path }));
223
- return [...fixed, ...fixtures];
243
+ return [...fixed, ...membership, ...fixtures];
224
244
  }
225
245
 
226
246
  function documentPath(pathBytes) {
@@ -278,7 +298,7 @@ function checkConfiguredPaths(baseTree, candidateTree, config) {
278
298
  const after = candidateMembers.get(key) || null;
279
299
  const entry = before || after;
280
300
  const sameRegularMode = regularBlob(before) && regularBlob(after) && before.mode === after.mode;
281
- const revised = kind === 'governed' && sameRegularMode && before.oid !== after.oid;
301
+ const revised = sameRegularMode && before.oid !== after.oid;
282
302
  const identityPreserved = sameRegularMode && before.oid === after.oid;
283
303
  const memberPath = pathText(entry.pathBytes);
284
304
  details.push({
@@ -286,10 +306,12 @@ function checkConfiguredPaths(baseTree, candidateTree, config) {
286
306
  ...(glob ? { pattern: record.path } : {}),
287
307
  path: memberPath,
288
308
  ...(memberPath === null ? { pathBase64: key } : {}),
289
- state: revised ? 'revised' : identityPreserved ? 'pass' : 'review_required',
309
+ state: kind === 'derived' ? 'pass' : revised ? 'revised' : identityPreserved ? 'pass' : 'review_required',
290
310
  base: identity(before),
291
311
  candidate: identity(after),
292
- ...(revised ? { reason: 'Content revisions are tracked by Git identity but are not semantically validated by the guard.' } : {})
312
+ ...(kind === 'derived' && !sameIdentity(before, after)
313
+ ? { reason: 'Generated-view changes are left to ordinary regenerate-equals CI.' }
314
+ : revised ? { reason: 'Content revisions are tracked by Git identity but are not semantically validated by the guard.' } : {})
293
315
  });
294
316
  }
295
317
  }
@@ -373,10 +395,11 @@ function run(argv) {
373
395
  report.policyPreservation.state = [report.policyPreservation.governed, report.policyPreservation.derived, report.policyPreservation.configuredPaths]
374
396
  .some(item => item.state !== 'pass') ? 'review_required' : 'pass';
375
397
 
376
- report.trustSurface.paths = trustSurfacePaths(baseTree, candidateTree).map(({ key, path }) => {
398
+ report.trustSurface.paths = trustSurfacePaths(baseTree, candidateTree).map(({ key, path, membershipOnly }) => {
377
399
  const before = baseTree.get(key);
378
400
  const after = candidateTree.get(key);
379
- return { path, ...(path === null ? { pathBase64: key } : {}), base: identity(before), candidate: identity(after), changed: !sameIdentity(before, after) };
401
+ const changed = membershipOnly ? !sameMembership(before, after) : !sameIdentity(before, after);
402
+ return { path, ...(path === null ? { pathBase64: key } : {}), base: identity(before), candidate: identity(after), changed };
380
403
  });
381
404
  report.trustSurface.changed = report.trustSurface.paths.filter(item => item.changed);
382
405
  report.trustSurface.state = report.trustSurface.changed.length ? 'review_required' : 'pass';