diff --git a/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md b/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md index fcf3814b..87c4e464 100644 --- a/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md +++ b/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md @@ -56,6 +56,14 @@ raw transcripts. A knowledge unit must have source evidence, a non-sensitive privacy decision, and `downstreamEligibility.memoryPromotion !== false`. Sensitive/private or uncited units stay in the secondbrain sidecar layer. +Intellectual origin is a separate gate from privacy and search eligibility. A +knowledge unit marked `human` must also carry `human_evidence_eligible: true` +before it can establish a user fact, preference, decision, belief, or lesson in +durable Memory. `assistant`, `mixed`, and `unknown` units remain searchable and +usable as context, but are rejected by the human-memory promotion gate. Legacy +notes may use the constrained `legacy_author` fallback during the compatibility +period; new programmatic human records require a validated user-source receipt. + ### Transcript evidence -> reviewed memory Transcript evidence is source material, not durable memory. A bounded diff --git a/modules/jarvos-memory/lib/memory-config.js b/modules/jarvos-memory/lib/memory-config.js index 1509a0f0..2e5d8336 100644 --- a/modules/jarvos-memory/lib/memory-config.js +++ b/modules/jarvos-memory/lib/memory-config.js @@ -21,8 +21,16 @@ function getMemoryPaths() { }; } +function getHindsightConfig() { + return { + apiUrl: process.env.HINDSIGHT_API_URL || 'http://127.0.0.1:8888', + timeoutMs: Number(process.env.HINDSIGHT_TIMEOUT_MS || 1500), + }; +} + module.exports = { DEFAULT_CLAWD_ROOT, getClawdRoot, getMemoryPaths, + getHindsightConfig, }; diff --git a/modules/jarvos-memory/lib/memory-record.js b/modules/jarvos-memory/lib/memory-record.js index ca3cd92d..14646af7 100644 --- a/modules/jarvos-memory/lib/memory-record.js +++ b/modules/jarvos-memory/lib/memory-record.js @@ -16,6 +16,7 @@ const { getClawdRoot: getWorkspaceRoot } = require('./memory-config'); * @param {string} [params.noteRef] - Link to full note if one was created * @param {number} [params.confidence] - 0.0-1.0 * @param {string} [params.supersedes] - ID of prior memory this replaces + * @param {object} [params.provenance] - Intellectual-origin declaration from the source unit * @returns {{ record: object, written: boolean, path: string|null, error: string|null }} */ function createMemoryRecord(params = {}) { @@ -54,6 +55,9 @@ function createMemoryRecord(params = {}) { created: now.toISOString(), confidence: typeof params.confidence === 'number' ? params.confidence : undefined, supersedes: params.supersedes || undefined, + content_origin: params.provenance?.content_origin || undefined, + content_origin_basis: params.provenance?.content_origin_basis || undefined, + human_evidence_eligible: params.provenance?.human_evidence_eligible === true ? true : undefined, status: 'active', }; @@ -94,6 +98,9 @@ function createMemoryRecord(params = {}) { record.noteRef ? `note_ref: "${record.noteRef}"` : null, record.supersedes ? `supersedes: "${record.supersedes}"` : null, record.confidence != null ? `confidence: ${record.confidence}` : null, + record.content_origin ? `content_origin: ${record.content_origin}` : null, + record.content_origin_basis ? `content_origin_basis: ${record.content_origin_basis}` : null, + record.human_evidence_eligible === true ? 'human_evidence_eligible: true' : null, '---', ].filter(Boolean).join('\n'); diff --git a/modules/jarvos-memory/src/lib/hindsight-adapter.js b/modules/jarvos-memory/src/lib/hindsight-adapter.js new file mode 100644 index 00000000..fb790d91 --- /dev/null +++ b/modules/jarvos-memory/src/lib/hindsight-adapter.js @@ -0,0 +1,59 @@ +'use strict'; + +class HindsightAdapter { + constructor({ apiUrl = 'http://127.0.0.1:8888', timeoutMs = 1500 } = {}) { + this.apiUrl = String(apiUrl).replace(/\/$/, ''); + this.timeoutMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : 1500; + } + + async request(path, { method = 'GET', body } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(`${this.apiUrl}${path}`, { + method, + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Hindsight returned HTTP ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timer); + } + } + + async ping() { + try { + const response = await this.request('/health'); + return response?.status === 'ok' || response?.ok === true; + } catch { + return false; + } + } + + async recall(query) { + try { + const response = await this.request('/recall', { method: 'POST', body: { query: String(query || '') } }); + return { + results: Array.isArray(response?.results) + ? response.results.map((item) => typeof item === 'string' ? item : item?.text).filter(Boolean) + : [], + error: null, + }; + } catch (error) { + return { results: [], error: error.message }; + } + } + + async reflect(query) { + try { + const response = await this.request('/reflect', { method: 'POST', body: { query: String(query || '') } }); + return { text: typeof response?.text === 'string' ? response.text : null, error: null }; + } catch (error) { + return { text: null, error: error.message }; + } + } +} + +module.exports = { HindsightAdapter }; diff --git a/modules/jarvos-memory/src/lib/memory-promotion.js b/modules/jarvos-memory/src/lib/memory-promotion.js index 1cda9259..570b401a 100644 --- a/modules/jarvos-memory/src/lib/memory-promotion.js +++ b/modules/jarvos-memory/src/lib/memory-promotion.js @@ -31,9 +31,10 @@ const { CORE_MEMORY_CLASSES, } = require('./memory-schema'); -const { createMemoryRecord } = require('./memory-record'); +const { createMemoryRecord } = require('../../lib/memory-record'); const { HindsightAdapter } = require('./hindsight-adapter'); -const { getHindsightConfig } = require('./memory-config'); +const { getHindsightConfig } = require('../../lib/memory-config'); +const { contentOriginPairIsValid } = require('../../../jarvos-secondbrain/bridge/provenance/src/content-origin-contract'); function promotionContent(event = {}) { if (event.knowledgeUnit) return String(event.knowledgeUnit.text || '').trim(); @@ -45,6 +46,85 @@ function hasKnowledgeUnitEvidence(unit = {}) { && unit.evidence.some((entry) => entry?.sourcePath || entry?.quote || entry?.bodySha256 || entry?.ref); } +function hasUserSourceReceipt(receipt = {}) { + return Boolean(receipt + && typeof receipt === 'object' + && receipt.actor === 'user' + && typeof receipt.capture_event_id === 'string' + && receipt.capture_event_id.trim() + && /^[a-f0-9]{64}$/.test(String(receipt.source_digest || '')) + && /^[a-f0-9]{64}$/.test(String(receipt.content_digest || ''))); +} + +function contentOriginRecord(unit = {}, event = {}) { + const candidate = unit.provenance || unit; + const eventOrigin = event.content_origin ?? event.contentOrigin; + const eventBasis = event.content_origin_basis ?? event.contentOriginBasis; + const candidateHasDeclaration = candidate.content_origin !== undefined + || candidate.contentOrigin !== undefined + || candidate.content_origin_basis !== undefined + || candidate.contentOriginBasis !== undefined + || candidate.human_evidence_eligible !== undefined + || candidate.humanEvidenceEligible !== undefined; + const origin = candidateHasDeclaration + ? candidate.content_origin ?? candidate.contentOrigin + : eventOrigin; + const basis = candidateHasDeclaration + ? candidate.content_origin_basis ?? candidate.contentOriginBasis + : eventBasis; + const hasDeclaration = candidateHasDeclaration || eventOrigin !== undefined || eventBasis !== undefined + || event.human_evidence_eligible !== undefined || event.humanEvidenceEligible !== undefined; + if (!hasDeclaration) return null; + const normalizedOrigin = String(origin || 'unknown').trim().toLowerCase(); + const normalizedBasis = String(basis || 'unknown').trim().toLowerCase(); + const receipt = candidate.user_source + || candidate.userSource + || candidate.content_origin_source + || candidate.contentOriginSource + || event.user_source + || event.userSource; + const validPair = normalizedBasis === 'legacy_author' + ? ['human', 'assistant', 'mixed'].includes(normalizedOrigin) + : contentOriginPairIsValid(normalizedOrigin, normalizedBasis); + return { + content_origin: normalizedOrigin, + content_origin_basis: normalizedBasis, + human_evidence_eligible: candidateHasDeclaration + ? candidate.human_evidence_eligible === true || candidate.humanEvidenceEligible === true + : event.human_evidence_eligible === true || event.humanEvidenceEligible === true, + valid_pair: validPair, + receipt_bound: normalizedOrigin !== 'human' + || normalizedBasis === 'legacy_author' + || hasUserSourceReceipt(receipt), + }; +} + +function humanEvidenceGate(unit, event) { + const provenance = contentOriginRecord(unit, event); + if (!provenance) return null; + if (!provenance.valid_pair) { + return { + provenance, + reason: `content origin '${provenance.content_origin}' has an invalid basis '${provenance.content_origin_basis}'`, + }; + } + if (provenance.content_origin === 'human' + && provenance.content_origin_basis !== 'legacy_author' + && !provenance.receipt_bound) { + return { + provenance, + reason: 'human evidence requires a user-source receipt', + }; + } + if (provenance.content_origin !== 'human' || provenance.human_evidence_eligible !== true) { + return { + provenance, + reason: `content origin '${provenance.content_origin}' is context-only for human-memory promotion`, + }; + } + return { provenance, reason: null }; +} + function isRawTranscriptSource(source = {}) { if (typeof source === 'string') return source === 'transcript' || source === 'raw-transcript'; return source.type === 'transcript' || source.kind === 'transcript' || source.raw === true; @@ -72,6 +152,10 @@ function reviewKnowledgeUnitCandidate(event = {}) { if (!hasKnowledgeUnitEvidence(unit)) { return { shouldPromote: false, memoryClass: null, reason: 'knowledgeUnit promotion requires source evidence' }; } + const evidenceGate = humanEvidenceGate(unit, event); + if (evidenceGate?.reason) { + return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason }; + } if (unit.privacyDecision?.excludedFromPromotion || unit.privacyDecision?.tier === 'secret' || unit.privacyDecision?.tier === 'sensitive') { return { shouldPromote: false, memoryClass: null, reason: `knowledgeUnit privacy tier '${unit.privacyDecision?.tier || 'unknown'}' is not eligible for promotion` }; } @@ -124,6 +208,11 @@ function reviewCandidate(event = {}) { return { shouldPromote: false, memoryClass: null, reason: 'raw source-backed captures must promote through cited knowledgeUnit references' }; } + const evidenceGate = humanEvidenceGate({}, event); + if (evidenceGate?.reason) { + return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason }; + } + const text = promotionContent(event); if (!text) { return { shouldPromote: false, memoryClass: null, reason: 'no content to promote' }; @@ -208,6 +297,7 @@ function promoteCandidate(event = {}) { const content = promotionContent(event); const unit = event.knowledgeUnit || null; + const provenance = contentOriginRecord(unit || {}, event); const result = createMemoryRecord({ class: review.memoryClass, content, @@ -216,6 +306,7 @@ function promoteCandidate(event = {}) { noteRef: event.noteRef || unit?.source?.path, confidence: event.confidence || unit?.confidence, supersedes: event.supersedes, + provenance, }); if (result.error) { diff --git a/modules/jarvos-memory/test/memory-promotion.test.js b/modules/jarvos-memory/test/memory-promotion.test.js index d21f3b92..c8e0c02a 100644 --- a/modules/jarvos-memory/test/memory-promotion.test.js +++ b/modules/jarvos-memory/test/memory-promotion.test.js @@ -274,6 +274,9 @@ describe('knowledgeUnit promotion gates', () => { downstreamEligibility: { memoryPromotion: true, }, + content_origin: 'human', + content_origin_basis: 'legacy_author', + human_evidence_eligible: true, ...overrides, }; } @@ -362,6 +365,68 @@ describe('knowledgeUnit promotion gates', () => { assert.match(review.reason, /memoryPromotion is false/); }); + it('rejects assistant-generated knowledge units from human memory', () => { + const review = reviewCandidate({ + knowledgeUnit: knowledgeUnit({ + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }), + }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /context-only/); + }); + + it('rejects explicit human knowledge units that omit their source receipt', () => { + const review = reviewCandidate({ + knowledgeUnit: knowledgeUnit({ + content_origin: 'human', + content_origin_basis: 'user_derived', + human_evidence_eligible: true, + content_origin_source: undefined, + }), + }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /user-source receipt/); + }); + + it('rejects mixed and unknown knowledge units from human memory', () => { + for (const provenance of [ + { content_origin: 'mixed', content_origin_basis: 'mixed_composition', human_evidence_eligible: false }, + { content_origin: 'unknown', content_origin_basis: 'unknown', human_evidence_eligible: false }, + ]) { + const review = reviewCandidate({ knowledgeUnit: knowledgeUnit(provenance) }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /context-only/); + } + }); + + it('does not let an event envelope override an ineligible unit decision', () => { + const review = reviewCandidate({ + human_evidence_eligible: true, + knowledgeUnit: knowledgeUnit({ human_evidence_eligible: false }), + }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /context-only/); + }); + + it('rejects a human unit whose basis does not match its origin', () => { + const review = reviewCandidate({ + knowledgeUnit: knowledgeUnit({ + content_origin: 'human', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: true, + }), + }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /invalid basis/); + }); + + it('rejects an explicitly non-human direct event while preserving legacy undeclared events during compatibility', () => { + assert.equal(reviewCandidate({ text: 'Assistant preference', salienceClass: 'preference', content_origin: 'assistant', content_origin_basis: 'assistant_generated' }).shouldPromote, false); + assert.equal(reviewCandidate({ text: 'Legacy preference', salienceClass: 'preference' }).shouldPromote, true); + }); + it('promotes cited knowledge units through the local file path', () => { const result = promoteCandidate({ knowledgeUnit: knowledgeUnit({ diff --git a/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js b/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js index cf94fe8a..707dcf27 100644 --- a/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js +++ b/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js @@ -30,6 +30,18 @@ function rawInvariantTransformProgram() { return `const p = input.replayPayload || {}; const hasNoteIdentity = (current, id) => { const match = current.match(/^---\\r?\\n[\\s\\S]*?^jarvos_note_id:\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s#]+))[\\s\\S]*?^---/m); return (match?.[1] || match?.[2] || match?.[3] || '') === id; }; const heading = raw => { const name = String(raw || '').trim().replace(/^##\\s*/, '').trim(); return name ? '## ' + (name === '🗂️ Notes Created' ? '📝 Notes' : name) : null; }; const range = (lines, h) => { const start = lines.findIndex(line => line.trim() === h); let end = lines.length; for (let i = start + 1; start !== -1 && i < lines.length; i += 1) { if (/^##\\s/.test(lines[i])) { end = i; break; } } return { start, end }; }; const escape = value => String(value).replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&'); const linkRe = target => new RegExp('^\\\\s*-\\\\s*\\\\[\\\\[' + escape(target) + '(?:\\\\|[^\\\\]]+)?\\\\]\\\\]\\\\s*$'); const transform = (() => { if (input.transformName === 'append-line' && input.transformVersion === 1 && typeof p.line === 'string') { const line = p.line.trim(); return current => current.includes(line); } if (input.transformName === 'note-append-body' && input.transformVersion === 1 && typeof p.noteId === 'string' && typeof p.body === 'string') { const id = p.noteId.trim(); const body = p.body.trim(); return current => hasNoteIdentity(current, id) && current.includes(body); } if (input.transformName === 'session-thread-append' && input.transformVersion === 1 && typeof p.noteId === 'string' && typeof p.entry === 'string') { const id = p.noteId.trim(); const entry = p.entry.trim(); return current => hasNoteIdentity(current, id) && current.includes(entry); } if (input.transformName === 'journal-section-line' && input.transformVersion === 1 && typeof p.heading === 'string' && typeof p.line === 'string') { const h = heading(p.heading); const line = p.line.trim(); return current => { const lines = String(current).split('\\n'); const r = range(lines, h); return Boolean(h && r.start !== -1 && lines.slice(r.start + 1, r.end).some(entry => entry.trim() === line)); }; } if (input.transformName === 'journal-backlink' && input.transformVersion === 1 && typeof p.linkTarget === 'string') { const h = heading(p.section || '📝 Notes'); const re = linkRe(p.linkTarget.trim()); return current => { const lines = String(current).split('\\n'); const r = range(lines, h); return Boolean(h && r.start !== -1 && lines.slice(r.start + 1, r.end).filter(entry => re.test(entry)).length === 1 && lines.filter(entry => re.test(entry)).length === 1); }; } return null; })();`; } +function journalOriginTransformHelpersProgram() { + return "const markerPayload = origin => { const o = origin || {}; const json = JSON.stringify({ schema_version: 'jarvos-content-origin/v1', content_origin: o.content_origin, content_origin_basis: o.content_origin_basis, clean_text_digest: o.clean_text_digest, human_evidence_eligible: o.human_evidence_eligible === true && o.content_origin === 'human', ...(o.source_ref ? { source_ref: String(o.source_ref) } : {}), ...(o.user_source && typeof o.user_source === 'object' ? { user_source: { capture_event_id: o.user_source.capture_event_id, actor: o.user_source.actor, source_digest: o.user_source.source_digest, content_digest: o.clean_text_digest || o.user_source.content_digest } } : {}) }); const encoded = btoa(unescape(encodeURIComponent(json))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, ''); return ''; }; const cleanMarker = value => String(value || '').replace(/\\s*/gi, '').trim(); const markerObject = value => { const match = String(value || '').match(/^$/); if (!match) return null; try { const token = match[1]; const padded = token.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((token.length + 3) % 4); const decoded = token.startsWith('%7B') || token.startsWith('%7b') ? decodeURIComponent(token) : new TextDecoder().decode(Uint8Array.from(atob(padded), c => c.charCodeAt(0))); return JSON.parse(decoded); } catch (_) { return null; } }; const sha256 = value => /^[a-f0-9]{64}$/.test(String(value || '')); const verifiedHumanMarker = value => { const marker = markerObject(value); const receipt = marker && marker.user_source; return Boolean(marker && marker.schema_version === 'jarvos-content-origin/v1' && marker.content_origin === 'human' && (marker.content_origin_basis === 'verbatim_user' || marker.content_origin_basis === 'user_derived') && marker.human_evidence_eligible === true && sha256(marker.clean_text_digest) && receipt && receipt.actor === 'user' && typeof receipt.capture_event_id === 'string' && receipt.capture_event_id.trim() && sha256(receipt.source_digest) && receipt.content_digest === marker.clean_text_digest && marker.source_ref === receipt.capture_event_id); }; const sameVerifiedHumanOrigin = (left, right) => { const a = markerObject(left); const b = markerObject(right); return Boolean(verifiedHumanMarker(left) && verifiedHumanMarker(right) && a.content_origin === b.content_origin && a.content_origin_basis === b.content_origin_basis && a.human_evidence_eligible === b.human_evidence_eligible); }; const cleanLine = value => { const clean = cleanMarker(value); return clean.startsWith('- ') ? clean : '- ' + clean.replace(/^[-\\s]+/, ''); }; "; +} + +function journalOriginMutationBranchProgram() { + return "if (input.transformName === 'journal-section-line' && input.transformVersion === 2 && typeof p.heading === 'string' && typeof p.line === 'string' && p.contentOrigin) { const h = heading(p.heading); const line = cleanLine(p.line); const marker = markerPayload(p.contentOrigin); return { apply: current => { if (!h || !line.startsWith('- ')) return current; const lines = String(current).split('\\n'); const r = range(lines, h); if (r.start === -1) { const trimmed = String(current).trimEnd(); return trimmed + (trimmed ? '\\n\\n' : '') + h + '\\n' + line + '\\n' + marker + '\\n'; } let match = -1; for (let i = r.start + 1; i < r.end; i += 1) { if (lines[i].trim().startsWith('- ') && cleanMarker(lines[i]) === line) { match = i; break; } } if (match !== -1) { const firstMarker = String(lines[match + 1] || '').trim(); if (!firstMarker.startsWith('$/; + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function cleanText(value) { + return String(value ?? '').replace(/\r\n/g, '\n').trim(); +} + +function cleanNoteContent(value, title) { + const text = cleanText(value); + const heading = String(title || '').trim(); + if (!heading || !text.startsWith(`# ${heading}\n`)) return text; + return text.slice(heading.length + 3).trim(); +} + +function digestText(value) { + return crypto.createHash('sha256').update(cleanText(value)).digest('hex'); +} + +function normalizedActorType(actor) { + if (typeof actor === 'string') return actor; + if (isPlainObject(actor)) return actor.type || actor.role || null; + return null; +} + +function normalizedCaptureEventId(event) { + if (!isPlainObject(event)) return null; + return event.capture_event_id || event.captureEventId || event.id || null; +} + +function sourceReceipt(input) { + const receipt = input?.user_source + || input?.userSource + || input?.content_origin_source + || input?.source_reference + || input?.sourceReference; + return isPlainObject(receipt) ? receipt : null; +} + +function invalidReceipt(reason) { + return { ok: false, reason }; +} + +/** + * Validate a user-source receipt against a resolver-owned capture record. + * The resolver is deliberately injected so the public contract does not know + * whether a harness stores source turns in a transcript, event store, or API. + */ +function validateUserSourceReceipt(receipt, options = {}) { + if (!isPlainObject(receipt)) return invalidReceipt('missing'); + + const required = ['capture_event_id', 'actor', 'source_digest', 'content_digest']; + if (required.some((field) => typeof receipt[field] !== 'string' || !receipt[field].trim())) { + return invalidReceipt('malformed'); + } + if (receipt.actor !== 'user') return invalidReceipt('non_user_actor'); + if (!SHA256_RE.test(receipt.source_digest) || !SHA256_RE.test(receipt.content_digest)) { + return invalidReceipt('malformed_digest'); + } + if (options.captureEventId && receipt.capture_event_id !== options.captureEventId) { + return invalidReceipt('capture_event_mismatch'); + } + + const content = cleanText(options.content); + if (!content || digestText(content) !== receipt.content_digest) { + return invalidReceipt('content_mismatch'); + } + + if (typeof options.resolveUserSource !== 'function') { + return invalidReceipt('unresolved'); + } + + let source; + try { + source = options.resolveUserSource(receipt.capture_event_id); + } catch (_error) { + return invalidReceipt('unresolved'); + } + if (!isPlainObject(source)) return invalidReceipt('unresolved'); + + const sourceId = normalizedCaptureEventId(source); + const sourceActor = normalizedActorType(source.actor ?? source); + const sourceText = source.text ?? source.content ?? source.body; + if (sourceId !== receipt.capture_event_id || sourceActor !== 'user' || typeof sourceText !== 'string') { + return invalidReceipt('source_mismatch'); + } + if (digestText(sourceText) !== receipt.source_digest) return invalidReceipt('source_digest_mismatch'); + + return { ok: true, reason: null, source }; +} + +function unknownRecord(reason = 'unknown') { + return { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + ...(reason ? { normalization_reason: reason } : {}), + }; +} + +function normalizeContentOrigin(input = {}, options = {}) { + const source = isPlainObject(input) ? input : {}; + const origin = String(source.content_origin ?? source.contentOrigin ?? '').trim().toLowerCase(); + const basis = String(source.content_origin_basis ?? source.contentOriginBasis ?? '').trim().toLowerCase(); + + if (!origin && !basis) return unknownRecord('missing_declaration'); + if (!CONTENT_ORIGINS.includes(origin) || !CONTENT_ORIGIN_BASES.includes(basis)) { + return unknownRecord('invalid_enum'); + } + if (basis === 'legacy_author') return unknownRecord('legacy_basis_requires_read_time_resolution'); + if (BASIS_ORIGIN[basis] !== origin) return unknownRecord('origin_basis_mismatch'); + if (origin === 'human') { + const validation = validateUserSourceReceipt(sourceReceipt(source), options); + if (!validation.ok) return unknownRecord(`invalid_user_source:${validation.reason}`); + } + + const result = { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin, + content_origin_basis: basis, + human_evidence_eligible: origin === 'human', + }; + const receipt = sourceReceipt(source); + if (receipt && origin === 'human') result.user_source = { ...receipt }; + return result; +} + +function frontmatterForContentOrigin(input = {}, options = {}) { + const normalized = normalizeContentOrigin(input, options); + return { + content_origin_schema: normalized.schema_version, + content_origin: normalized.content_origin, + content_origin_basis: normalized.content_origin_basis, + ...(normalized.user_source ? { content_origin_source: { ...normalized.user_source } } : {}), + human_evidence_eligible: normalized.human_evidence_eligible, + }; +} + +function contentOriginPairIsValid(contentOrigin, contentOriginBasis) { + return CONTENT_ORIGINS.includes(contentOrigin) + && CONTENT_ORIGIN_BASES.includes(contentOriginBasis) + && contentOriginBasis !== 'legacy_author' + && BASIS_ORIGIN[contentOriginBasis] === contentOrigin; +} + +function encodeMarkerPayload(payload) { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +function decodeMarkerPayload(encoded) { + try { + const raw = String(encoded); + const decoded = raw.startsWith('%7B') || raw.startsWith('%7b') + ? JSON.parse(decodeURIComponent(raw)) + : JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); + return isPlainObject(decoded) ? decoded : null; + } catch { + return null; + } +} + +/** + * Render an invisible, line-adjacent journal marker. The marker contains no + * source text; it binds the clean bullet digest to the bounded origin + * declaration and, for human evidence, a compact user-source receipt. + */ +function renderJournalOriginMarker({ cleanText, clean_text_digest, content_origin, content_origin_basis, source_ref, user_source, human_evidence_eligible = false } = {}) { + const origin = String(content_origin || '').trim().toLowerCase(); + const basis = String(content_origin_basis || '').trim().toLowerCase(); + if (!contentOriginPairIsValid(origin, basis)) throw new Error('Invalid journal content-origin declaration'); + const receipt = sourceReceipt({ user_source }); + if (origin === 'human' && !receipt) throw new Error('Human journal origin requires a user-source receipt'); + const markerReceipt = receipt + ? { + capture_event_id: String(receipt.capture_event_id || '').trim(), + actor: receipt.actor, + source_digest: String(receipt.source_digest || '').trim(), + content_digest: digestText(cleanText), + } + : null; + if (markerReceipt) { + const validation = validateUserSourceReceipt(markerReceipt, { content: cleanText }); + if (validation.reason !== 'unresolved') throw new Error(`Invalid journal user-source receipt: ${validation.reason}`); + } + const markerSourceRef = source_ref ? String(source_ref).trim() : markerReceipt?.capture_event_id; + if (markerSourceRef && markerReceipt && markerSourceRef !== markerReceipt.capture_event_id) { + throw new Error('Journal source reference must match the user-source capture event'); + } + const payload = { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin, + content_origin_basis: basis, + clean_text_digest: clean_text_digest || digestText(cleanText), + human_evidence_eligible: origin === 'human' && human_evidence_eligible === true, + ...(markerSourceRef ? { source_ref: markerSourceRef } : {}), + ...(markerReceipt ? { user_source: markerReceipt } : {}), + }; + return `${JOURNAL_MARKER_PREFIX}${encodeMarkerPayload(payload)} -->`; +} + +function unknownJournalOrigin(reason = 'unknown') { + return { + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + ...(reason ? { normalization_reason: reason } : {}), + }; +} + +function parseJournalOriginMarker(marker, cleanText) { + const match = String(marker || '').trim().match(JOURNAL_MARKER_RE); + if (!match) return unknownJournalOrigin('missing_or_malformed_marker'); + const payload = decodeMarkerPayload(match[1]); + if (!payload || payload.schema_version !== CONTENT_ORIGIN_SCHEMA_VERSION) return unknownJournalOrigin('invalid_marker_payload'); + if (!contentOriginPairIsValid(payload.content_origin, payload.content_origin_basis)) return unknownJournalOrigin('invalid_marker_origin'); + if (!SHA256_RE.test(String(payload.clean_text_digest || '')) || digestText(cleanText) !== payload.clean_text_digest) { + return unknownJournalOrigin('marker_digest_mismatch'); + } + if (payload.source_ref !== undefined && (typeof payload.source_ref !== 'string' || !payload.source_ref.trim())) { + return unknownJournalOrigin('invalid_marker_source_ref'); + } + if (payload.content_origin === 'human') { + const validation = validateUserSourceReceipt(payload.user_source, { content: cleanText }); + if (validation.reason !== 'unresolved') return unknownJournalOrigin(`invalid_marker_user_source:${validation.reason}`); + if (payload.source_ref !== payload.user_source.capture_event_id) return unknownJournalOrigin('marker_source_ref_mismatch'); + } + return { + schema_version: payload.schema_version, + content_origin: payload.content_origin, + content_origin_basis: payload.content_origin_basis, + clean_text_digest: payload.clean_text_digest, + human_evidence_eligible: payload.human_evidence_eligible === true && payload.content_origin === 'human', + ...(payload.source_ref ? { source_ref: payload.source_ref } : {}), + ...(payload.user_source ? { user_source: { ...payload.user_source } } : {}), + }; +} + +function stripJournalOriginMarkers(text) { + return String(text || '') + .replace(/\s*/gi, '') + .replace(/\n{3,}/g, '\n\n'); +} + +function cleanJournalEntryText(line) { + return stripJournalOriginMarkers(String(line || '')).trim(); +} + +function parseJournalEntry(lines, index) { + const source = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/); + const line = String(source[index] || '').trim(); + if (!line.startsWith('- ')) return null; + const cleanText = cleanJournalEntryText(line); + const markerLines = []; + for (let markerIndex = index + 1; markerIndex < source.length; markerIndex += 1) { + const candidate = String(source[markerIndex] || '').trim(); + if (!candidate.startsWith('', + }); + const batch = projectEvidenceBatch([{ ...record, clean_text: content }]); + const read = readEvidenceProjection(projection); + + assert.equal(projection.projection_version, EVIDENCE_PROJECTION_VERSION); + assert.equal(projection.clean_text, content); + assert.equal(projection.human_evidence_eligible, false); + assert.equal('user_source' in projection, false); + assert.equal(JSON.stringify(projection).includes('jarvos-content-origin secret'), false); + assert.equal(batch.length, 1); + assert.equal(read.ok, true); + assert.equal(read.record.clean_text, content); +}); + +test('journal origin markers are invisible, digest-bound, and round-trip clean text', () => { + const cleanText = 'A generated journal idea'; + const marker = renderJournalOriginMarker({ + cleanText, + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + source_ref: 'capture:codex:123', + }); + const parsed = parseJournalOriginMarker(marker, cleanText); + assert.equal(parsed.content_origin, 'assistant'); + assert.equal(parsed.content_origin_basis, 'assistant_generated'); + assert.equal(parsed.source_ref, 'capture:codex:123'); + assert.equal(parseJournalOriginMarker(marker, 'tampered text').content_origin, 'unknown'); + assert.equal(stripJournalOriginMarkers(`- ${cleanText}\n${marker}`), `- ${cleanText}\n`); +}); + +test('human journal markers require and carry a clean-content-bound receipt', () => { + const cleanText = 'A user-supplied journal idea'; + const captureEventId = 'capture-human-journal-1'; + const marker = renderJournalOriginMarker({ + cleanText, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + source_ref: captureEventId, + user_source: receipt('The user supplied this idea.', cleanText, captureEventId), + human_evidence_eligible: true, + }); + const parsed = parseJournalOriginMarker(marker, cleanText); + assert.equal(parsed.content_origin, 'human'); + assert.equal(parsed.human_evidence_eligible, true); + assert.equal(parsed.user_source.content_digest, digest(cleanText)); + assert.throws( + () => renderJournalOriginMarker({ + cleanText, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + source_ref: captureEventId, + human_evidence_eligible: true, + }), + /user-source receipt/, + ); +}); + +test('journal entry parsing treats unmarked bullets as manual human evidence and malformed markers as unknown', () => { + const manual = parseJournalEntry(['- A manually typed thought'], 0); + assert.equal(manual.origin.content_origin, 'human'); + assert.equal(manual.origin.human_evidence_eligible, true); + + const malformed = parseJournalEntry(['- Agent thought', ''], 0); + assert.equal(malformed.origin.content_origin, 'unknown'); + assert.equal(malformed.clean_text, 'Agent thought'); + + const duplicate = parseJournalEntry([ + '- Duplicate markers', + malformed.marker_line, + malformed.marker_line, + ], 0); + assert.equal(duplicate.origin.content_origin, 'unknown'); + assert.equal(duplicate.origin.normalization_reason, 'duplicate_marker'); +}); + +test('public projections expose clean journal and note evidence without marker parsing in consumers', () => { + const marker = renderJournalOriginMarker({ + cleanText: 'Assistant-generated context', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + }); + const journal = projectJournalEntriesFromMarkdown(`## 💡 Ideas\n- User thought\n- Assistant-generated context\n${marker}\n`, { date: '2026-08-14' }); + assert.equal(journal.length, 2); + assert.equal(journal[0].content_origin, 'human'); + assert.equal(journal[0].human_evidence_eligible, true); + assert.equal(journal[1].content_origin, 'assistant'); + assert.equal(journal[1].human_evidence_eligible, false); + assert.equal(journal[1].clean_text, 'Assistant-generated context'); + assert.doesNotMatch(JSON.stringify(journal), /\n`, + { date: '2026-08-14' }, + ); + assert.equal(journal[0].content_origin, 'unknown'); + assert.equal(journal[0].human_evidence_eligible, false); +}); + +test('note projections downgrade a receipt when the clean body digest is stale', () => { + const original = 'The user-supplied note body.'; + const markdown = `---\ncontent_origin: human\ncontent_origin_basis: verbatim_user\nhuman_evidence_eligible: true\ncontent_origin_source: ${JSON.stringify(receipt(original, original))}\n---\n\n# Digest note\n\nA later assistant rewrite.`; + const projected = projectNoteMarkdown(markdown, { title: 'Digest note', sourcePath: 'Notes/Digest note.md' }); + assert.equal(projected.content_origin, 'unknown'); + assert.equal(projected.human_evidence_eligible, false); +}); + +test('evidence projection rejects malformed records before private consumers see them', () => { + const valid = { + projection_version: EVIDENCE_PROJECTION_VERSION, + clean_text: 'Valid context', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }; + const cases = [ + ['unknown_projection_version', { ...valid, projection_version: 'jarvos-content-origin-evidence/v0' }], + ['missing_clean_text', { ...valid, clean_text: '' }], + ['invalid_origin', { ...valid, content_origin: 'not-an-origin' }], + ['missing_eligibility', { ...valid, human_evidence_eligible: undefined }], + ['ineligible_origin_marked_eligible', { ...valid, human_evidence_eligible: true }], + ]; + for (const [reason, input] of cases) { + const result = readEvidenceProjection(input); + assert.equal(result.ok, false, reason); + assert.equal(result.reason, reason); + assert.equal(result.record.human_evidence_eligible, false); + } +}); diff --git a/modules/jarvos-secondbrain/tests/journal-maintenance.test.js b/modules/jarvos-secondbrain/tests/journal-maintenance.test.js index 86909680..fe0634a4 100644 --- a/modules/jarvos-secondbrain/tests/journal-maintenance.test.js +++ b/modules/jarvos-secondbrain/tests/journal-maintenance.test.js @@ -15,9 +15,44 @@ const { stripLeadingRecoveryScaffold, syncOneDate: rawSyncOneDate, } = require('../packages/jarvos-secondbrain-journal/src/journal-maintenance.js'); +const { renderJournalOriginMarker } = require('../bridge/provenance/src/content-origin-contract'); const TEST_DATE = '2026-01-02'; +test('ordinary maintenance removes legacy signatures without adding a replacement and preserves hidden markers', () => { + const marker = renderJournalOriginMarker({ + cleanText: 'assistant thought', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + source_ref: 'capture:codex:maintenance', + }); + const original = [ + '---', + 'journal: Journal', + `journal-date: ${TEST_DATE}`, + '---', + '', + '## 📝 Notes', + '-', + '', + '## 💡 Ideas', + '- assistant thought', + marker, + '', + '## 📓 Journal Entry', + '-', + '', + '— Edited by Jarvis', + '', + ].join('\n'); + + const config = loadConfig(); + const normalized = normalizeSections(original, TEST_DATE, config, { fetchers: { projects: () => '-' } }); + const output = renderJournal(TEST_DATE, config, normalized); + assert.doesNotMatch(output, /Written by Jarvis|Edited by Jarvis/); + assert.match(output, /- assistant thought\n`; + const v2Cases = [ + ['## 💡 Ideas\n- Existing manual thought\n', '- Existing manual thought', { heading: '## 💡 Ideas', line: '- Existing manual thought', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' } }], + ['## 💡 Ideas\n- Assistant evidence\n\n', '- Assistant evidence', { heading: '## 💡 Ideas', line: '- Assistant evidence', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Assistant evidence') } }], + [humanInitial, humanLine, { heading: '## 💡 Ideas', line: humanLine, contentOrigin: humanOriginSameClassification }], + [humanInitial, humanLine, { heading: '## 💡 Ideas', line: humanLine, contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText(humanSource) } }], + [`## 💡 Ideas\n- Malformed human\n${malformedHumanMarker}\n`, '- Malformed human', { heading: '## 💡 Ideas', line: '- Malformed human', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Malformed human') } }], + ['## 💡 Ideas\n- Duplicate marker\n\n\n', '- Duplicate marker', { heading: '## 💡 Ideas', line: '- Duplicate marker', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Duplicate marker') } }], + ]; + for (const [initial, line, contentOrigin] of v2Cases) { + const input = { ...operation(), operationKind: 'transform', transformName: 'journal-section-line', transformVersion: 2, replayPayload: { ...contentOrigin } }; + const expected = transforms.applyNode(initial, input); + const actual = runInFakeObsidian(input, { initial }); + const expectedSatisfied = transforms.isSatisfied(expected, input); + assert.equal(actual.result.status, expectedSatisfied ? 'done' : 'error', `v2 ${line}`); + assert.equal(actual.content, expected, `v2 ${line}`); + assert.equal(transforms.isSatisfied(actual.content, input), expectedSatisfied, `v2 ${line}`); + } }); test('fixed program preserves quoted note identity for identity-safe note transforms', () => { @@ -239,6 +296,32 @@ test('fixed program appends when the requested block exists only as a prose subs assert.match(updated.content, /the next thing\n\nnext\n$/); }); +test('fixed Obsidian evaluator accepts the versioned journal origin transform', () => { + const line = '- Fixed evaluator provenance'; + const journalOperation = { + ...operation(), + operationKind: 'transform', + transformName: 'journal-section-line', + transformVersion: 2, + replayPayload: { + heading: '## 💡 Ideas', + line, + contentOrigin: { + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + clean_text_digest: digestText(line.slice(2)), + source_ref: 'capture:codex:fixed-evaluator', + }, + }, + }; + const actual = runInFakeObsidian(journalOperation, { initial: '## 💡 Ideas\n-\n' }); + assert.equal(actual.result.status, 'done'); + const lines = actual.content.split('\n'); + const parsed = parseJournalEntry(lines, lines.indexOf(line)); + assert.equal(parsed.origin.content_origin, 'assistant'); + assert.equal(parsed.origin.source_ref, 'capture:codex:fixed-evaluator'); +}); + test('fixed program appends one session checkpoint without replacing concurrent prose', () => { const checkpoint = { ...operation(), diff --git a/modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js b/modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js index bd3d1ae9..36024f90 100644 --- a/modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js +++ b/modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const test = require('node:test'); const { createVaultStorageAdapter } = require('../adapters/obsidian/src/vault-storage-adapter'); const { createJarvosVaultTransforms } = require('../src/vault-transform-registry'); +const { parseJournalEntry } = require('../bridge/provenance/src/content-origin-contract'); function withVault(run) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-storage-adapter-')); @@ -97,3 +98,79 @@ test('unavailable service never falls back to a raw journal write', () => { assert.equal(fs.existsSync(result.journalPath), false); }); }); + +test('versioned journal transform stores hidden origin metadata adjacent to the exact bullet', () => { + const transforms = createJarvosVaultTransforms(); + const operation = { + transformName: 'journal-section-line', + transformVersion: 2, + replayPayload: { + heading: '## 💡 Ideas', + line: '- Generated architecture idea', + contentOrigin: { + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + source_ref: 'capture:codex:idea-1', + }, + }, + }; + const first = transforms.applyNode('## 💡 Ideas\n-\n', operation); + const lines = first.split('\n'); + const entry = parseJournalEntry(lines, lines.indexOf('- Generated architecture idea')); + assert.equal(entry.origin.content_origin, 'assistant'); + assert.equal(entry.origin.source_ref, 'capture:codex:idea-1'); + assert.doesNotMatch(first, /Edited by Jarvis/); + assert.equal(transforms.isSatisfied(first, operation), true); + assert.equal(transforms.applyNode(first, operation), first); +}); + +test('storage adapter routes declared provenance through the v2 journal transform', () => { + withVault(({ root, journalDir }) => { + const service = fakeService(root); + const adapter = createVaultStorageAdapter({ mutationService: service, vaultRoot: root, journalDir }); + const result = adapter.appendLineToJournalSection({ + date: '2030-02-04', + heading: '## 💡 Ideas', + line: '- Routed assistant idea', + contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', source_ref: 'capture:openclaw:routed' }, + intentId: 'routed-origin', + }); + assert.equal(result.acknowledged, true); + assert.equal(service.operations[1].transformVersion, 2); + assert.match(fs.readFileSync(result.journalPath, 'utf8'), /- Routed assistant idea\n', + contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + }, + }; + const manual = '## 💡 Ideas\n- Same thought\n'; + const result = transforms.applyNode(manual, operation); + assert.equal(result, manual); + assert.doesNotMatch(result, /jarvos-content-origin/); +}); + +test('journal transform replaces malformed or duplicate markers instead of treating them as human evidence', () => { + const transforms = createJarvosVaultTransforms(); + const operation = { + transformName: 'journal-section-line', + transformVersion: 2, + replayPayload: { + heading: '## 💡 Ideas', + line: '- Recover marker', + contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + }, + }; + const malformed = '## 💡 Ideas\n- Recover marker\n\n\n'; + const result = transforms.applyNode(malformed, operation); + assert.equal(transforms.isSatisfied(result, operation), true); + assert.equal(result.split('jarvos-content-origin/v1').length - 1, 1); +}); diff --git a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js index 9fcf93ab..d2506b0a 100644 --- a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js +++ b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js @@ -71,6 +71,97 @@ test('injected writer reports the identity carried by the submitted operation', }); }); +test('canonical note writes persist origin metadata, default missing declarations to unknown, and strip adoption state', () => { + withVault(({ root }) => { + const result = writeNoteFile({ + title: 'Provenance note', + content: 'Generated context stays searchable but is not user evidence.', + frontmatter: { + status: 'draft', + type: 'reference', + project: 'PROVENANCE', + author: 'jarvis', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + content_adoption: { state: 'accepted' }, + }, + operationId: 'note-provenance-0001', + vaultId: 'vault-provenance', + vaultRoot: root, + mutationExecutor(operation) { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, operation.content, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }, + }); + + const content = fs.readFileSync(result.path, 'utf8'); + assert.match(content, /content_origin_schema: jarvos-content-origin\/v1/); + assert.match(content, /content_origin: assistant/); + assert.match(content, /content_origin_basis: assistant_generated/); + assert.doesNotMatch(content, /content_adoption/); + }); +}); + +test('canonical note writes make an omitted origin explicit unknown', () => { + withVault(({ root }) => { + const result = writeNoteFile({ + title: 'Undeclared note', + content: 'A note without a provenance declaration remains context-only.', + operationId: 'note-provenance-unknown-0001', + vaultId: 'vault-provenance', + vaultRoot: root, + mutationExecutor(operation) { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, operation.content, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }, + }); + + const content = fs.readFileSync(result.path, 'utf8'); + assert.match(content, /content_origin_schema: jarvos-content-origin\/v1/); + assert.match(content, /content_origin: unknown/); + assert.match(content, /content_origin_basis: unknown/); + assert.match(content, /human_evidence_eligible: false/); + }); +}); + +test('material note updates without a declaration downgrade inherited provenance to unknown', () => { + withVault(({ root }) => { + const execute = (operation) => { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (operation.operationKind === 'create') fs.writeFileSync(target, operation.content, 'utf8'); + else if (operation.operationKind === 'replace') fs.writeFileSync(target, operation.content, 'utf8'); + else fs.writeFileSync(target, `${fs.readFileSync(target, 'utf8').trimEnd()}\n\n${operation.replayPayload.body}\n`, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }; + const context = (operationId) => ({ + operationId, + vaultId: 'vault-provenance-update', + vaultRoot: root, + mutationExecutor: execute, + }); + const first = writeNoteFile({ + title: 'Stable provenance', + content: 'Assistant draft.', + frontmatter: { status: 'draft', type: 'reference', project: 'PROVENANCE', author: 'jarvis', content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + ...context('note-provenance-0002'), + }); + writeNoteFile({ + title: 'Stable provenance', + content: 'A later maintenance update.', + ...context('note-provenance-0003'), + }); + const content = fs.readFileSync(first.path, 'utf8'); + assert.match(content, /content_origin: unknown/); + assert.match(content, /content_origin_basis: unknown/); + assert.doesNotMatch(content, /content_origin: assistant/); + }); +}); + function settled(value) { return { then(fn) { try { fn(value); return this; } catch (error) { this.error = error; return this; } }, diff --git a/modules/jarvos/templates/AGENTS-template.md b/modules/jarvos/templates/AGENTS-template.md index 6a8e3dc5..f7692b7b 100644 --- a/modules/jarvos/templates/AGENTS-template.md +++ b/modules/jarvos/templates/AGENTS-template.md @@ -398,7 +398,7 @@ When you discover useful patterns or want to remember something, add it to the a ## Authorship -**Always sign your work.** Any note, draft, or article you create in the vault gets `- Written by {{ASSISTANT_NAME}}` at the bottom. If you edit an existing note, append `- Edited by {{ASSISTANT_NAME}}`. No exceptions. {{USER_NAME}} needs to know what's theirs and what's yours. +**Do not append a visible author or edit signature to vault notes or journals.** Canonical capture paths record intellectual origin as structured metadata: use `human` only for {{USER_NAME}}'s supplied words or faithful user-derived copy, `assistant` for generated copy, `mixed` for material contributions from both, and `unknown` when the evidence is insufficient. The writer carries that declaration into note frontmatter or an invisible journal marker so downstream ripeness and retrieval can distinguish the user's evidence from assistant context without cluttering the reading surface. If you are only transcribing or lightly arranging {{USER_NAME}}'s words, preserve the human origin; do not claim human origin for newly generated ideas. ## 🗂️ Vault & Document Location diff --git a/templates/AGENTS-template.md b/templates/AGENTS-template.md index cbfcdbe6..392061b7 100644 --- a/templates/AGENTS-template.md +++ b/templates/AGENTS-template.md @@ -387,7 +387,7 @@ When you discover useful patterns or want to remember something, add it to the a ## Authorship -**Always sign your work.** Any note, draft, or article you create in the vault gets `- Written by {{ASSISTANT_NAME}}` at the bottom. If you edit an existing note, append `- Edited by {{ASSISTANT_NAME}}`. No exceptions. {{USER_NAME}} needs to know what's theirs and what's yours. +**Do not append a visible author or edit signature to vault notes or journals.** Canonical capture paths record intellectual origin as structured metadata: use `human` only for {{USER_NAME}}'s supplied words or faithful user-derived copy, `assistant` for generated copy, `mixed` for material contributions from both, and `unknown` when the evidence is insufficient. The writer carries that declaration into note frontmatter or an invisible journal marker so downstream ripeness and retrieval can distinguish the user's evidence from assistant context without cluttering the reading surface. If you are only transcribing or lightly arranging {{USER_NAME}}'s words, preserve the human origin; do not claim human origin for newly generated ideas. ## 🗂️ Vault & Document Location diff --git a/tests/ripeness-artifact-contract-test.js b/tests/ripeness-artifact-contract-test.js new file mode 100644 index 00000000..a567adce --- /dev/null +++ b/tests/ripeness-artifact-contract-test.js @@ -0,0 +1,119 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const test = require('node:test'); + +const { + RIPENESS_ARTIFACT_SCHEMA_VERSION, + computeRipenessArtifactDigest, + localDateFor, + validateRipenessArtifact, +} = require('../modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract'); + +function artifact(overrides = {}) { + const value = { + schemaVersion: RIPENESS_ARTIFACT_SCHEMA_VERSION, + asOf: '2026-08-10', + effectiveAt: '2026-08-10T05:00:00.000Z', + timeZone: 'America/New_York', + producer: { + engine: 'ripeness-nudge', + version: 'test-engine-v1', + runId: 'run_test_123', + configDigest: crypto.createHash('sha256').update('config').digest('hex'), + }, + publication: { state: 'published' }, + themes: [{ + days: 3, + spanDays: 14, + firstSeen: '2026-07-27', + lastSeen: '2026-08-09', + qualifyingHumanDays: 2, + originCounts: { human: 2, assistant: 1, mixed: 0, unknown: 0 }, + fragments: [{ + date: '2026-08-09', + text: 'Synthetic recurring thought.', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + human_evidence_eligible: true, + }], + qualifyingHumanSupport: [{ + id: 'human-support-1', + date: '2026-08-09', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + human_evidence_eligible: true, + }], + contextSupport: [{ + id: 'assistant-support-1', + date: '2026-08-08', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }], + support: ['Synthetic support'], + }], + ...overrides, + }; + value.outputDigest = computeRipenessArtifactDigest(value); + return value; +} + +const now = new Date('2026-08-10T12:00:00.000Z'); + +test('validates a fresh, published artifact and accepts fresh empty output', () => { + assert.equal(localDateFor(now, 'America/New_York'), '2026-08-10'); + assert.deepEqual(validateRipenessArtifact(artifact(), { now }), { ok: true, status: 'fresh', artifact: artifact() }); + assert.equal(validateRipenessArtifact(artifact({ themes: [] }), { now }).status, 'fresh_empty'); +}); + +test('fails closed on future, stale, unknown schema, digest, provenance, and bounded-row defects', () => { + assert.equal(validateRipenessArtifact(artifact({ asOf: '2026-08-11' }), { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ asOf: '2026-08-09' }), { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ schemaVersion: 'unknown/v9' }), { now }).ok, false); + const digestMismatch = artifact(); digestMismatch.outputDigest = '0'.repeat(64); + assert.equal(validateRipenessArtifact(digestMismatch, { now }).ok, false); + const noRun = artifact(); delete noRun.producer.runId; noRun.outputDigest = computeRipenessArtifactDigest(noRun); + assert.equal(validateRipenessArtifact(noRun, { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ themes: Array.from({ length: 4 }, () => artifact().themes[0]) }), { now }).ok, false); +}); + +test('assistant-only themes cannot validate and legacy artifacts are explicitly non-qualifying', () => { + const assistantOnly = artifact({ + themes: [{ + ...artifact().themes[0], + originCounts: { human: 0, assistant: 3, mixed: 0, unknown: 0 }, + qualifyingHumanDays: 0, + qualifyingHumanSupport: [], + fragments: [], + contextSupport: [{ + id: 'assistant-only', + date: '2026-08-09', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }], + }], + }); + assert.equal(validateRipenessArtifact(assistantOnly, { now }).ok, false); + + const legacy = artifact({ schemaVersion: 'jarvos-ripeness-artifact/v1' }); + assert.deepEqual(validateRipenessArtifact(legacy, { now }), { + ok: false, + status: 'legacy_non_qualifying', + artifact: null, + legacy: true, + }); +}); + +test('artifact digest covers origin composition and eligibility fields', () => { + const changed = artifact(); + changed.themes[0].contextSupport[0].content_origin = 'mixed'; + changed.themes[0].contextSupport[0].content_origin_basis = 'mixed_composition'; + changed.outputDigest = computeRipenessArtifactDigest(changed); + assert.equal(validateRipenessArtifact(changed, { now }).ok, true); + + changed.themes[0].qualifyingHumanSupport[0].human_evidence_eligible = false; + assert.equal(validateRipenessArtifact(changed, { now }).ok, false); +});