diff --git a/README.md b/README.md
index 50e2e3d..04ab4e0 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,9 @@ summary: |
Optional opening, shown above the review map.
+
+metadata:
+ explainedBy: Claude Code
sections:
- title: Keep the greeting concise
steps:
@@ -114,7 +117,8 @@ sections:
```
`title` is required: it becomes the review heading and the browser tab, which is how two
-shared links tell themselves apart. `summary` is optional.
+shared links tell themselves apart. `summary` is optional. `metadata.explainedBy` is an
+optional, self-reported author shown in every local view and export.
Every change must be shown at least once. Showing one in more than one step is allowed
and reported, because re-showing a hunk is how an argument gets built. Validate, then
@@ -216,6 +220,12 @@ Publishing is anonymous and unlisted, not private. The link cannot be guessed, b
anyone holding it can read the review without signing in. Treat the link as the secret,
and do not publish a document you would not hand to everyone who might receive it.
+A hosted report can show attribution: `explainedBy` from the explanations, `publishedBy`
+from `git config user.name` when one is configured, and a `publishedAt` timestamp the
+service stamps when it accepts the upload. These names are self-reported attribution, not
+verified identity. Publishing adds them to the uploaded document only; it never rewrites
+`explanations.yaml` or `capture.json`, and a missing value is simply omitted.
+
Publishing retains the review ID, link, service, and revocation token in the current
walk's `published.json` (or next to an explicit `--input`/`--explanations` pair). The
file stays local and is never uploaded, but it holds the revocation token, so keep it
@@ -261,8 +271,10 @@ the CLI at another deployment with `--service` or `DIFFWALK_SERVICE_URL`.
`diffwalk export json` materializes capture plus explanations and writes `diffwalk.json`
inside the current walk by default. It is the portable ExplainDocument JSON (format
-version 1) for integrations or archiving. View, HTML export, and publish do not require
-it; they validate and materialize directly from the authoring files.
+version 1) for integrations or archiving. An authored `metadata.explainedBy` is
+preserved; a local export never claims a publisher or publication time. View, HTML
+export, and publish do not require it; they validate and materialize directly from the
+authoring files.
## Captured data sensitivity
diff --git a/docs/architecture.md b/docs/architecture.md
index 531f348..9c3b74d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -11,7 +11,8 @@ exact corresponding diffs in a deliberate order.
never contains authored sections. `explanations.yaml` is the only author-edited file: it
names the `captureId` it was authored against, carries a required `title` and an
optional `summary`, and holds ordered sections of `{ title, steps[] }` where a step is
- `{ text?, changes[]? }` with at least one of the two.
+ `{ text?, changes[]? }` with at least one of the two. An optional
+ `metadata.explainedBy` records who wrote the walkthrough.
- `captureId` identifies captured code contents, not the capture timestamp. It is a
SHA-256 over a canonical serialization of the captured file snapshots (status, path,
old path, old/new modes, old content, new content), so identical captures pair
@@ -37,6 +38,13 @@ exact corresponding diffs in a deliberate order.
builds an argument, so `check` names the repeats and still succeeds; only an
unexplained change fails. Completeness is the guarantee a reader relies on, not
uniqueness.
+- A version 1 ExplainDocument may carry an optional, strict top-level `metadata` object.
+ `explainedBy` is authored in `explanations.yaml` and survives materialization;
+ `publishedBy` is added at publish time from `git config user.name`; `publishedAt` is
+ stamped by the review service when it accepts the upload. The names are self-reported
+ attribution, not verified identity. Local views and exports show only `explainedBy`;
+ the hosted report shows all three. Absent values are omitted and never block viewing,
+ exporting, or publishing, and unknown metadata keys are rejected.
- `check`, `view`, `export`, and `publish` read capture plus explanations, validate the
pairing and assignments, and materialize exact patches in memory. No `document.json`
is required at runtime; `export json` writes the portable ExplainDocument (format
@@ -93,7 +101,7 @@ exact corresponding diffs in a deliberate order.
## Source map
- `src/format.ts`: Zod schemas for the machine-owned capture and the author-edited
- explanations, plus the version 1 ExplainDocument.
+ explanations, plus the version 1 ExplainDocument and its optional attribution metadata.
- `src/authoring/git.ts`: captures staged, unstaged, deleted, renamed, and untracked UTF-8
files from an immutable Git base commit, optionally reading the index or limiting the
capture to named paths.
@@ -115,8 +123,9 @@ exact corresponding diffs in a deliberate order.
- `src/report.ts`: atomic report writes and client-bundle loading.
- `src/report/shell.ts`: the one report shell, embedded-data escaping, and shell styles,
rendered with inlined assets for the offline file or linked assets for the hosted page.
-- `src/publish.ts`: review service origin checks, publish credential lookup, and the
- publish, update, and unpublish requests.
+- `src/publish.ts`: review service origin checks, publish credential lookup, the
+ publish, update, and unpublish requests, and adding the Git user name as
+ `metadata.publishedBy` without mutating the authoring files.
- `src/report/client.ts`: browser entry that mounts a `FileDiff` per file and switches
unified/split through `setOptions`.
- `test/*.test.ts`: focused tests for schemas, capture identity, strict YAML parsing,
@@ -127,8 +136,9 @@ exact corresponding diffs in a deliberate order.
inspect file behavior (including preservation of authored explanations and stale
pairing), inspection commands, validation, HTML/JSON exports, and rejection of the
removed `build`, `report`, and draft workflows.
-- `worker/index.ts`: the Cloudflare Worker that stores, renders, and revokes reports and
- sets the review origin's Content Security Policy, security headers, and caching.
+- `worker/index.ts`: the Cloudflare Worker that stores, renders, and revokes reports,
+ stamps `metadata.publishedAt` when it accepts an upload, and sets the review origin's
+ Content Security Policy, security headers, and caching.
- `worker/reports.ts`: report IDs, revocation tokens, token digests, constant-time secret
comparison, and the bounded document size.
- `worker/build-assets.ts`: writes the shared stylesheet and client bundle into the Static
diff --git a/skills/diffwalk/SKILL.md b/skills/diffwalk/SKILL.md
index b7516ac..3afea50 100644
--- a/skills/diffwalk/SKILL.md
+++ b/skills/diffwalk/SKILL.md
@@ -39,6 +39,8 @@ explained.
title: What this whole change set does
summary: |
Optional opening for someone deciding whether to read.
+ metadata:
+ explainedBy: Claude Code
sections:
- title: A concise change title
steps:
@@ -53,7 +55,11 @@ explained.
```
`title` is required: it becomes the review heading and the browser tab, which is how
- two shared links tell themselves apart.
+ two shared links tell themselves apart. `metadata.explainedBy` optionally names the
+ person or agent who wrote the walkthrough. It is self-reported attribution, not a
+ verified identity, and it shows in local views and exports. Publishing fills
+ `metadata.publishedBy` from `git config user.name` and the service stamps
+ `metadata.publishedAt`; do not write either one in `explanations.yaml`.
A step carries `text`, `changes`, or both, so prose and diffs interleave in the order
you write them. Prefer several short steps over one long one: the point of a step is
diff --git a/src/authoring/capture.ts b/src/authoring/capture.ts
index 48bd0f8..99fd2ec 100644
--- a/src/authoring/capture.ts
+++ b/src/authoring/capture.ts
@@ -194,6 +194,7 @@ export function materializeExplainDocument(
title: explanations.title,
summary: explanations.summary,
source: capture.source,
+ ...(explanations.metadata === undefined ? {} : { metadata: explanations.metadata }),
sections,
})
}
diff --git a/src/authoring/git.ts b/src/authoring/git.ts
index 6673843..af9597d 100644
--- a/src/authoring/git.ts
+++ b/src/authoring/git.ts
@@ -202,6 +202,17 @@ export async function commitForRevision(revision: string, root = process.cwd()):
return (await gitText(['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`], root)).trim()
}
+// Reflects whatever Git would stamp on the next commit. A missing or empty name is the
+// only reason to omit it, so every other failure also means "unavailable".
+export async function gitUserName(root = process.cwd()): Promise {
+ try {
+ const name = (await gitText(['config', 'user.name'], root)).trim()
+ return name === '' ? undefined : name
+ } catch {
+ return undefined
+ }
+}
+
async function workingTreeFile(path: string, root: string): Promise {
const absolutePath = resolve(root, path)
const pathWithinRoot = relative(root, absolutePath)
diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts
index 6e0daa0..f01ae36 100644
--- a/src/cli/commands/publish.ts
+++ b/src/cli/commands/publish.ts
@@ -1,7 +1,8 @@
import { z } from 'zod'
+import { gitUserName } from '../../authoring/git'
import { authoringOptionsSchema, materialize } from '../../authoring/input'
import { readPublishedReview, writePublishedReview } from '../../authoring/published'
-import { publishDocument, reportService, updateDocument } from '../../publish'
+import { publishDocument, reportService, updateDocument, withPublisher } from '../../publish'
import type { ExplainDocument } from '../../format'
import { UsageError } from '../usage'
@@ -13,16 +14,17 @@ type PublishOptions = z.infer
export async function publishCommand(options: PublishOptions): Promise {
const { document, paths } = await materialize(options)
+ const outgoing = withPublisher(document, await gitUserName())
if (options.update === true) {
- await updatePublishedReview(document, paths.published, options.service)
+ await updatePublishedReview(outgoing, paths.published, options.service)
return
}
const service = reportService(options.service)
- const published = await publishDocument(document, service)
+ const published = await publishDocument(outgoing, service)
// The token is shown before the retention write so a failed write can never leave a
// live review whose only credential was never surfaced.
- console.log(`Published ${document.sections.length} explanation sections to ${published.url}`)
+ console.log(`Published ${outgoing.sections.length} explanation sections to ${published.url}`)
console.log(`Revocation token: ${published.revocationToken}`)
await writePublishedReview(paths.published, {
id: published.id,
diff --git a/src/format.ts b/src/format.ts
index eef01fc..59076f1 100644
--- a/src/format.ts
+++ b/src/format.ts
@@ -111,11 +111,18 @@ export const explanationSectionSchema = z
})
.strict()
+export const explanationMetadataSchema = z
+ .object({
+ explainedBy: z.string().min(1).optional(),
+ })
+ .strict()
+
export const explanationsSchema = z
.object({
captureId: z.string().min(1),
title: z.string().min(1),
summary: z.preprocess((value) => value ?? '', z.string()).default(''),
+ metadata: explanationMetadataSchema.optional(),
sections: z.array(explanationSectionSchema),
})
.strict()
@@ -134,12 +141,21 @@ export const documentStepSchema = z
message: 'captured change IDs require a diff',
})
+export const documentMetadataSchema = z
+ .object({
+ explainedBy: z.string().min(1).optional(),
+ publishedBy: z.string().min(1).optional(),
+ publishedAt: z.string().datetime().optional(),
+ })
+ .strict()
+
export const explainDocumentSchema = z
.object({
formatVersion: z.literal(1),
title: z.string().min(1),
summary: z.string().default(''),
source: documentSourceSchema,
+ metadata: documentMetadataSchema.optional(),
sections: z
.array(
z
diff --git a/src/publish.ts b/src/publish.ts
index 1fb2ef9..223e875 100644
--- a/src/publish.ts
+++ b/src/publish.ts
@@ -6,6 +6,16 @@ export interface PublishedReport {
revocationToken: string
}
+// The publisher is self-reported attribution, not a credential. It is read at publish time
+// so the authoring files never carry it, and omitted cleanly when Git has no user name.
+export function withPublisher(
+ document: ExplainDocument,
+ publishedBy: string | undefined,
+): ExplainDocument {
+ if (publishedBy === undefined) return document
+ return { ...document, metadata: { ...document.metadata, publishedBy } }
+}
+
const defaultService = 'https://review.diffwalk.dev'
export function reportService(explicit: string | undefined): string {
diff --git a/src/report/shell.ts b/src/report/shell.ts
index 7fd021b..53625c9 100644
--- a/src/report/shell.ts
+++ b/src/report/shell.ts
@@ -37,6 +37,7 @@ interface ReportBody {
function renderReportBody(
document: ExplainDocument,
options: ReportOptions = {},
+ hosted = false,
): ReportBody {
const title = options.title ?? document.title
const layout = options.layout ?? 'split'
@@ -64,9 +65,9 @@ function renderReportBody(
document.summary.trim() === ''
? ''
: `\n
${renderMarkdown(document.summary)}
`
- // Title, provenance, and summary are one opening, so they share one card. The layout
- // toggle lives in the sticky map instead: it is a reading control, wanted while
- // scrolled into a diff, and the card scrolls away.
+ // Title, provenance, attribution, and summary are one opening, so they share one card.
+ // The layout toggle lives in the sticky map instead: it is a reading control, wanted
+ // while scrolled into a diff, and the card scrolls away.
const markup = `
`)
+ }
+ // A local preview or export must not claim a publisher or a publication time, even if
+ // a document somehow carries one. Only the review service renders those rows.
+ if (hosted && metadata.publishedBy !== undefined) {
+ rows.push(`