@gitdocket/core 0.0.0 → 0.1.1

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.
@@ -0,0 +1,370 @@
1
+ // Agent-authored product context. The engine parses and
2
+ // presents committed judgment; it never authors it. overview.md is a reserved
3
+ // bundle-root file so it stays out of the concept graph and generated index.
4
+
5
+ import { parse as parseYaml } from "yaml";
6
+ import type { Diagnostic } from "./parse";
7
+
8
+ export const STATE_OF_PLAY_PATH = "overview.md";
9
+ /** Evidence movement that makes an authored re-entry note worth revisiting. */
10
+ export const STATE_OF_PLAY_STALE_COMMITS = 5;
11
+ /** A quiet project must still review its last-known context occasionally. */
12
+ export const STATE_OF_PLAY_REVIEW_MAX_DAYS = 14;
13
+ export const REENTRY_CONTEXT_FORMAT = "re-entry/v2" as const;
14
+ export const REENTRY_CONTEXT_V1_FORMAT = "re-entry/v1" as const;
15
+
16
+ interface StateOfPlayBase {
17
+ /** Commit the authored judgment was written against. */
18
+ asOf: string;
19
+ /** Markdown after the machine-readable frontmatter. */
20
+ body: string;
21
+ }
22
+
23
+ /** The legacy prose format. It remains readable but is never current. */
24
+ export interface LegacyStateOfPlayNote extends StateOfPlayBase {
25
+ format: "legacy";
26
+ updatedAt?: string;
27
+ }
28
+
29
+ /** The superseded assessment shape, retained for read compatibility. */
30
+ export interface ReentryAssessment {
31
+ outcome: string;
32
+ bet: string;
33
+ evidence: string;
34
+ risk: string;
35
+ nextDecision: string;
36
+ decisions: string;
37
+ decisionLinks: string[];
38
+ }
39
+
40
+ export interface ReentryV1ContextNote extends StateOfPlayBase {
41
+ format: typeof REENTRY_CONTEXT_V1_FORMAT;
42
+ reviewedAt: string;
43
+ orientation: string;
44
+ assessment: ReentryAssessment;
45
+ }
46
+
47
+ /** A small linked note: recent outcomes, the frontier, and optional context. */
48
+ export interface ReentryContextNote extends StateOfPlayBase {
49
+ format: typeof REENTRY_CONTEXT_FORMAT;
50
+ reviewedAt: string;
51
+ recent: string;
52
+ next: string;
53
+ worthKnowing?: string;
54
+ links: string[];
55
+ decisionLinks: string[];
56
+ }
57
+
58
+ export type StateOfPlayNote =
59
+ | LegacyStateOfPlayNote
60
+ | ReentryV1ContextNote
61
+ | ReentryContextNote;
62
+
63
+ export type StateOfPlayReviewReason =
64
+ | "legacy-format"
65
+ | "superseded-format"
66
+ | "evidence-moved"
67
+ | "review-expired"
68
+ | "git-age-unavailable";
69
+
70
+ export interface StateOfPlayReview {
71
+ /** Whether the last-known authored context should be revisited. */
72
+ status: "current" | "needs-review" | "age-unavailable";
73
+ reasons: StateOfPlayReviewReason[];
74
+ reviewedDaysAgo: number | null;
75
+ maxDays: number;
76
+ }
77
+
78
+ export type StateOfPlayView = StateOfPlayNote & {
79
+ /** Null when Git cannot resolve the watermark (for example outside a repo). */
80
+ taskCommitsAgo: number | null;
81
+ review: StateOfPlayReview;
82
+ };
83
+
84
+ export interface StateOfPlayParseResult {
85
+ note?: StateOfPlayNote;
86
+ diagnostics: Diagnostic[];
87
+ }
88
+
89
+ export interface StateOfPlayPresentationOptions {
90
+ now?: Date;
91
+ }
92
+
93
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
94
+ const COMMIT_SHA = /^[0-9a-f]{7,40}$/i;
95
+ const DAY_MS = 24 * 60 * 60 * 1000;
96
+
97
+ const V1_SECTION_NAMES = {
98
+ "product orientation": "orientation",
99
+ "current outcome": "outcome",
100
+ "current bet": "bet",
101
+ "evidence and learning": "evidence",
102
+ "principal risk": "risk",
103
+ "next decision": "nextDecision",
104
+ "material decisions": "decisions",
105
+ } as const;
106
+
107
+ const SECTION_NAMES = {
108
+ "what we've done recently": "recent",
109
+ "what's up next": "next",
110
+ "worth knowing": "worthKnowing",
111
+ } as const;
112
+
113
+ type V1SectionKey = (typeof V1_SECTION_NAMES)[keyof typeof V1_SECTION_NAMES];
114
+ type SectionKey = (typeof SECTION_NAMES)[keyof typeof SECTION_NAMES];
115
+
116
+ function structuredSections(
117
+ body: string,
118
+ diagnostics: Diagnostic[],
119
+ names: Record<string, string>,
120
+ required: string[],
121
+ ): Record<string, string> {
122
+ const sections: Record<string, string> = {};
123
+ const headings = [...body.matchAll(/^##[ \t]+(.+?)[ \t]*$/gm)];
124
+ for (const [index, heading] of headings.entries()) {
125
+ const name = heading[1]?.trim().toLowerCase() ?? "";
126
+ const key = names[name];
127
+ if (!key) continue;
128
+ const start = (heading.index ?? 0) + heading[0].length;
129
+ const end = headings[index + 1]?.index ?? body.length;
130
+ const value = body.slice(start, end).trim();
131
+ if (sections[key] !== undefined) {
132
+ diagnostics.push({
133
+ path: STATE_OF_PLAY_PATH,
134
+ message: `duplicate \`## ${heading[1]?.trim()}\` section`,
135
+ severity: "error",
136
+ });
137
+ } else {
138
+ sections[key] = value;
139
+ }
140
+ }
141
+
142
+ for (const heading of required) {
143
+ const key = names[heading];
144
+ if (!key || !sections[key]) {
145
+ diagnostics.push({
146
+ path: STATE_OF_PLAY_PATH,
147
+ message: `structured product context requires a non-empty \`## ${heading}\` section`,
148
+ severity: "error",
149
+ });
150
+ }
151
+ }
152
+ return sections;
153
+ }
154
+
155
+ const markdownLinks = (markdown: string): string[] =>
156
+ [...markdown.matchAll(/\]\(([^)]+)\)/g)].map((link) => link[1] ?? "");
157
+
158
+ const validIsoDate = (value: unknown): value is string =>
159
+ typeof value === "string" && !Number.isNaN(Date.parse(value));
160
+
161
+ /** Parse overview.md without treating it as an OKF concept. */
162
+ export function parseStateOfPlay(source: string): StateOfPlayParseResult {
163
+ const diagnostics: Diagnostic[] = [];
164
+ const match = source.match(FRONTMATTER);
165
+ if (!match) {
166
+ return {
167
+ diagnostics: [
168
+ {
169
+ path: STATE_OF_PLAY_PATH,
170
+ message: "missing YAML frontmatter with an `as_of` commit SHA",
171
+ severity: "error",
172
+ },
173
+ ],
174
+ };
175
+ }
176
+
177
+ let raw: unknown;
178
+ try {
179
+ raw = parseYaml(match[1] ?? "");
180
+ } catch (error) {
181
+ return {
182
+ diagnostics: [
183
+ {
184
+ path: STATE_OF_PLAY_PATH,
185
+ message: `invalid YAML: ${String(error)}`,
186
+ severity: "error",
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ const fm =
192
+ typeof raw === "object" && raw !== null
193
+ ? (raw as Record<string, unknown>)
194
+ : {};
195
+ const asOf = fm.as_of;
196
+ if (typeof asOf !== "string" || !COMMIT_SHA.test(asOf)) {
197
+ diagnostics.push({
198
+ path: STATE_OF_PLAY_PATH,
199
+ message: "`as_of` must be a 7–40 character hexadecimal commit SHA",
200
+ severity: "error",
201
+ });
202
+ }
203
+
204
+ const body = source.slice(match[0].length).trim();
205
+ if (!body) {
206
+ diagnostics.push({
207
+ path: STATE_OF_PLAY_PATH,
208
+ message: "product-context body is empty",
209
+ severity: "error",
210
+ });
211
+ }
212
+
213
+ const format = fm.format;
214
+ if (format === undefined) {
215
+ const updatedAt = fm.updated_at;
216
+ if (updatedAt !== undefined && !validIsoDate(updatedAt)) {
217
+ diagnostics.push({
218
+ path: STATE_OF_PLAY_PATH,
219
+ message: "`updated_at` must be an ISO-8601 string when present",
220
+ severity: "error",
221
+ });
222
+ }
223
+ if (diagnostics.length > 0 || typeof asOf !== "string")
224
+ return { diagnostics };
225
+ return {
226
+ note: {
227
+ format: "legacy",
228
+ asOf,
229
+ ...(typeof updatedAt === "string" ? { updatedAt } : {}),
230
+ body,
231
+ },
232
+ diagnostics,
233
+ };
234
+ }
235
+
236
+ if (
237
+ format !== REENTRY_CONTEXT_FORMAT &&
238
+ format !== REENTRY_CONTEXT_V1_FORMAT
239
+ ) {
240
+ diagnostics.push({
241
+ path: STATE_OF_PLAY_PATH,
242
+ message: `unsupported product-context format: ${String(format)}`,
243
+ severity: "error",
244
+ });
245
+ return { diagnostics };
246
+ }
247
+
248
+ const reviewedAt = fm.reviewed_at;
249
+ if (!validIsoDate(reviewedAt)) {
250
+ diagnostics.push({
251
+ path: STATE_OF_PLAY_PATH,
252
+ message: "`reviewed_at` must be an ISO-8601 string",
253
+ severity: "error",
254
+ });
255
+ }
256
+ const sections =
257
+ format === REENTRY_CONTEXT_V1_FORMAT
258
+ ? structuredSections(
259
+ body,
260
+ diagnostics,
261
+ V1_SECTION_NAMES,
262
+ Object.keys(V1_SECTION_NAMES),
263
+ )
264
+ : structuredSections(body, diagnostics, SECTION_NAMES, [
265
+ "what we've done recently",
266
+ "what's up next",
267
+ ]);
268
+ if (
269
+ diagnostics.length > 0 ||
270
+ typeof asOf !== "string" ||
271
+ typeof reviewedAt !== "string"
272
+ )
273
+ return { diagnostics };
274
+
275
+ if (format === REENTRY_CONTEXT_V1_FORMAT) {
276
+ const legacySections = sections as Partial<Record<V1SectionKey, string>>;
277
+ const decisions = legacySections.decisions ?? "";
278
+ return {
279
+ note: {
280
+ format: REENTRY_CONTEXT_V1_FORMAT,
281
+ asOf,
282
+ reviewedAt,
283
+ body,
284
+ orientation: legacySections.orientation ?? "",
285
+ assessment: {
286
+ outcome: legacySections.outcome ?? "",
287
+ bet: legacySections.bet ?? "",
288
+ evidence: legacySections.evidence ?? "",
289
+ risk: legacySections.risk ?? "",
290
+ nextDecision: legacySections.nextDecision ?? "",
291
+ decisions,
292
+ decisionLinks: markdownLinks(decisions),
293
+ },
294
+ },
295
+ diagnostics,
296
+ };
297
+ }
298
+
299
+ const currentSections = sections as Partial<Record<SectionKey, string>>;
300
+ const recent = currentSections.recent ?? "";
301
+ const next = currentSections.next ?? "";
302
+ const worthKnowing = currentSections.worthKnowing;
303
+ const links = markdownLinks(
304
+ [recent, next, worthKnowing].filter(Boolean).join("\n"),
305
+ );
306
+ return {
307
+ note: {
308
+ format: REENTRY_CONTEXT_FORMAT,
309
+ asOf,
310
+ reviewedAt,
311
+ body,
312
+ recent,
313
+ next,
314
+ ...(worthKnowing ? { worthKnowing } : {}),
315
+ links,
316
+ decisionLinks: links.filter((link) =>
317
+ link.replace(/^\//, "").startsWith("decisions/"),
318
+ ),
319
+ },
320
+ diagnostics,
321
+ };
322
+ }
323
+
324
+ export function presentStateOfPlay(
325
+ note: StateOfPlayNote,
326
+ taskCommitsAgo: number | undefined,
327
+ options: StateOfPlayPresentationOptions = {},
328
+ ): StateOfPlayView {
329
+ const reviewedAt =
330
+ note.format === "legacy" ? note.updatedAt : note.reviewedAt;
331
+ const reviewedTime = reviewedAt ? Date.parse(reviewedAt) : Number.NaN;
332
+ const reviewedDaysAgo = Number.isNaN(reviewedTime)
333
+ ? null
334
+ : Math.max(
335
+ 0,
336
+ Math.floor(
337
+ ((options.now ?? new Date()).getTime() - reviewedTime) / DAY_MS,
338
+ ),
339
+ );
340
+ const reasons: StateOfPlayReviewReason[] = [];
341
+ if (note.format === "legacy") reasons.push("legacy-format");
342
+ if (note.format === REENTRY_CONTEXT_V1_FORMAT)
343
+ reasons.push("superseded-format");
344
+ if ((taskCommitsAgo ?? 0) >= STATE_OF_PLAY_STALE_COMMITS)
345
+ reasons.push("evidence-moved");
346
+ if (
347
+ reviewedDaysAgo !== null &&
348
+ reviewedDaysAgo >= STATE_OF_PLAY_REVIEW_MAX_DAYS
349
+ )
350
+ reasons.push("review-expired");
351
+ if (taskCommitsAgo === undefined) reasons.push("git-age-unavailable");
352
+
353
+ const needsReview = reasons.some(
354
+ (reason) => reason !== "git-age-unavailable",
355
+ );
356
+ return {
357
+ ...note,
358
+ taskCommitsAgo: taskCommitsAgo ?? null,
359
+ review: {
360
+ status: needsReview
361
+ ? "needs-review"
362
+ : taskCommitsAgo === undefined
363
+ ? "age-unavailable"
364
+ : "current",
365
+ reasons,
366
+ reviewedDaysAgo,
367
+ maxDays: STATE_OF_PLAY_REVIEW_MAX_DAYS,
368
+ },
369
+ };
370
+ }
package/src/states.ts ADDED
@@ -0,0 +1,85 @@
1
+ // The task-profile state machine (docs/specs/okf-task-profile.md).
2
+
3
+ export const STATES = [
4
+ "todo",
5
+ "in-progress",
6
+ "blocked",
7
+ "in-review",
8
+ "done",
9
+ "closed",
10
+ ] as const;
11
+ export type Status = (typeof STATES)[number];
12
+
13
+ export const TERMINAL_STATES = ["done", "closed"] as const;
14
+
15
+ export const WORK_ITEM_TYPES = ["Epic", "Task"] as const;
16
+ export type WorkItemType = (typeof WORK_ITEM_TYPES)[number];
17
+
18
+ export const DECISION_STATES = ["proposed", "accepted", "superseded"] as const;
19
+ export type DecisionStatus = (typeof DECISION_STATES)[number];
20
+
21
+ export const PRIORITIES = ["p0", "p1", "p2", "p3"] as const;
22
+ export type Priority = (typeof PRIORITIES)[number];
23
+
24
+ export function isStatus(value: string): value is Status {
25
+ return (STATES as readonly string[]).includes(value);
26
+ }
27
+
28
+ export function isTerminalStatus(value: string): value is Status {
29
+ return (TERMINAL_STATES as readonly string[]).includes(value);
30
+ }
31
+
32
+ export function isPriority(value: string): value is Priority {
33
+ return (PRIORITIES as readonly string[]).includes(value);
34
+ }
35
+
36
+ /**
37
+ * Allowed transitions (normative — mirrored in the task profile spec):
38
+ * `done` is reachable from any non-blocked state, `closed` from every
39
+ * non-terminal state, `blocked` from every non-terminal state, and both
40
+ * `done` and `closed` are terminal.
41
+ */
42
+ export const TRANSITIONS: Record<Status, readonly Status[]> = {
43
+ todo: ["in-progress", "blocked", "done", "closed"],
44
+ "in-progress": ["in-review", "done", "closed", "blocked", "todo"],
45
+ "in-review": ["done", "closed", "in-progress", "blocked"],
46
+ blocked: ["todo", "in-progress", "closed"],
47
+ done: [],
48
+ closed: [],
49
+ };
50
+
51
+ export function canTransition(from: Status, to: Status): boolean {
52
+ return TRANSITIONS[from].includes(to);
53
+ }
54
+
55
+ /**
56
+ * Manual order: ranked items first, lower `rank` first, priority as
57
+ * the tiebreak and the order for the unranked tail. The ready lists (CLI and
58
+ * MCP) sort with this so "next task" honors hand-set order.
59
+ */
60
+ export function byManualOrder(
61
+ a: { id?: string; rank?: number; priority?: Priority },
62
+ z: { id?: string; rank?: number; priority?: Priority },
63
+ ): number {
64
+ const ar = typeof a.rank === "number" ? a.rank : null;
65
+ const zr = typeof z.rank === "number" ? z.rank : null;
66
+ if (ar !== null && zr !== null && ar !== zr) return ar - zr;
67
+ if ((ar !== null) !== (zr !== null)) return ar !== null ? -1 : 1;
68
+ const priority = (a.priority ?? "p2").localeCompare(z.priority ?? "p2");
69
+ if (priority !== 0) return priority;
70
+ return (a.id ?? "").localeCompare(z.id ?? "", undefined, { numeric: true });
71
+ }
72
+
73
+ /**
74
+ * `ready` is derived, never written: a task is ready iff it is `todo` and
75
+ * every dependency is `done`. Unknown dependency IDs make a task not-ready
76
+ * (lint flags them separately).
77
+ */
78
+ export function isReady(
79
+ status: Status,
80
+ dependsOn: readonly string[],
81
+ statusById: ReadonlyMap<string, Status>,
82
+ ): boolean {
83
+ if (status !== "todo") return false;
84
+ return dependsOn.every((id) => statusById.get(id) === "done");
85
+ }
package/src/upgrade.ts ADDED
@@ -0,0 +1,177 @@
1
+ // docket upgrade — pure per-file transforms. Every vendored item retains a
2
+ // provenance story, and each category has an explicit upgrade
3
+ // story: adapters regenerate when marked (never merged), repo-owned workflows
4
+ // 3-way merge against the shipped text at their origin version. Filesystem
5
+ // and git orchestration live in the CLI; the merge itself is injected so this
6
+ // module stays runtime-portable.
7
+
8
+ import {
9
+ formatOrigin,
10
+ parseOrigin,
11
+ recoverOrigin,
12
+ type ShippedHistory,
13
+ shippedHistory,
14
+ shippedWorkflow,
15
+ } from "./shipped";
16
+ import { DOCKET_VERSION } from "./version";
17
+ import { hasAdapterMarker } from "./workflows";
18
+
19
+ export type UpgradeAction =
20
+ | "regenerated"
21
+ | "replaced"
22
+ | "merged"
23
+ | "conflict"
24
+ | "up-to-date"
25
+ | "skipped";
26
+
27
+ export interface UpgradeResult {
28
+ action: UpgradeAction;
29
+ /** File content after the upgrade (unchanged for up-to-date/skipped). */
30
+ content: string;
31
+ /** Provenance version the file carried before, when determinable. */
32
+ from?: string;
33
+ reason?: string;
34
+ }
35
+
36
+ /**
37
+ * Three-way merge: base = shipped text at origin, ours = the repo's copy,
38
+ * theirs = the new shipped text. Returns merged content and whether conflict
39
+ * markers were left behind. Injected by the caller (`git merge-file` in the
40
+ * CLI) — core doesn't carry a diff3 implementation.
41
+ */
42
+ export type Merge3 = (
43
+ base: string,
44
+ ours: string,
45
+ theirs: string,
46
+ ) => { content: string; conflict: boolean };
47
+
48
+ /**
49
+ * Version carried by a docket marker line (skill stub, section fence, hook
50
+ * block). Undefined for legacy unversioned markers — report those as
51
+ * upgradable from an unknown version, never a blocker.
52
+ */
53
+ export function markerVersion(text: string): string | undefined {
54
+ return text.match(
55
+ /(?:docket init|docket prepare-commit-msg|>>> docket(?: mcp)?)@(\d+\.\d+\.\d+)/,
56
+ )?.[1];
57
+ }
58
+
59
+ /**
60
+ * Upgrade a regenerable adapter: marked files are overwritten with the
61
+ * current generation, hand-authored files are never touched (same rule as
62
+ * init). `generated` is today's render of the same adapter.
63
+ */
64
+ export function upgradeAdapter(
65
+ existing: string,
66
+ generated: string,
67
+ ): UpgradeResult {
68
+ const from = markerVersion(existing);
69
+ if (existing === generated)
70
+ return { action: "up-to-date", content: existing, from };
71
+ if (hasAdapterMarker(existing))
72
+ return { action: "regenerated", content: generated, from };
73
+ return {
74
+ action: "skipped",
75
+ content: existing,
76
+ reason: "hand-authored — remove to regenerate",
77
+ };
78
+ }
79
+
80
+ /** Set (or insert) the `origin:` line inside a frontmatter block. */
81
+ function stampOrigin(fm: string, value: string): string {
82
+ if (/^origin:.*$/m.test(fm))
83
+ return fm.replace(/^origin:.*$/m, `origin: ${value}`);
84
+ if (/^tags:/m.test(fm))
85
+ return fm.replace(/^tags:/m, `origin: ${value}\ntags:`);
86
+ return fm.replace(/\n---\n$/, `\norigin: ${value}\n---\n`);
87
+ }
88
+
89
+ /**
90
+ * Upgrade a repo-owned workflow file. Origin comes from the `origin:`
91
+ * frontmatter stamp, falling back to text-match recovery for pre-stamp
92
+ * copies. Unmodified from origin → replace with the new shipped body;
93
+ * diverged → 3-way merge. Only the body and the origin line change — the
94
+ * rest of the frontmatter (timestamp, any customized fields) is repo-owned
95
+ * and never touched. Merges — clean or conflicted — stamp the new version:
96
+ * after resolution the new shipped text is the base the copy descends from.
97
+ */
98
+ export function upgradeWorkflowFile(
99
+ source: string,
100
+ opts: { merge3: Merge3; history?: ShippedHistory; current?: string },
101
+ ): UpgradeResult {
102
+ const history = opts.history ?? shippedHistory();
103
+ const current = opts.current ?? DOCKET_VERSION;
104
+
105
+ const fmMatch = source.match(/^---\n[\s\S]*?\n---\n/);
106
+ if (!fmMatch)
107
+ return { action: "skipped", content: source, reason: "no frontmatter" };
108
+ const fm = fmMatch[0];
109
+ const body = source.slice(fm.length).trim();
110
+
111
+ const stamped = fm.match(/^origin:\s*(\S+)\s*$/m);
112
+ const origin = stamped?.[1]
113
+ ? parseOrigin(stamped[1])
114
+ : recoverOrigin(source, history);
115
+ if (!origin) {
116
+ return {
117
+ action: "skipped",
118
+ content: source,
119
+ reason: stamped
120
+ ? `malformed origin "${stamped[1]}"`
121
+ : "origin unknown — customized before stamping, resolve by hand",
122
+ };
123
+ }
124
+
125
+ const base = shippedWorkflow(origin.slug, origin.version, history);
126
+ if (base === undefined) {
127
+ return {
128
+ action: "skipped",
129
+ content: source,
130
+ from: origin.version,
131
+ reason: `no shipped text for ${formatOrigin(origin)}`,
132
+ };
133
+ }
134
+ const available = shippedWorkflow(origin.slug, current, history);
135
+ if (available === undefined) {
136
+ return {
137
+ action: "skipped",
138
+ content: source,
139
+ from: origin.version,
140
+ reason: `${origin.slug} is not shipped at ${current}`,
141
+ };
142
+ }
143
+
144
+ const newFm = stampOrigin(fm, `${origin.slug}@${current}`);
145
+ const rebuild = (newBody: string) => `${newFm}\n${newBody.trim()}\n`;
146
+
147
+ if (body === base.trim()) {
148
+ const content = rebuild(available);
149
+ return content === source
150
+ ? { action: "up-to-date", content: source, from: origin.version }
151
+ : { action: "replaced", content, from: origin.version };
152
+ }
153
+
154
+ const merged = opts.merge3(base.trim(), body, available.trim());
155
+ const content = rebuild(merged.content);
156
+ if (merged.conflict)
157
+ return { action: "conflict", content, from: origin.version };
158
+ if (content === source) {
159
+ // Nothing propagated. When the copy still differs from the shipped text,
160
+ // say so instead of a bare up-to-date — this is either a customization
161
+ // (fine) or a copy vendored from a since-edited pre-release template
162
+ // A bare report would hide this change.
163
+ const reason =
164
+ body === available.trim()
165
+ ? {}
166
+ : {
167
+ reason: `differs from shipped ${current} — local text kept (customized, or the template changed in place)`,
168
+ };
169
+ return {
170
+ action: "up-to-date",
171
+ content: source,
172
+ from: origin.version,
173
+ ...reason,
174
+ };
175
+ }
176
+ return { action: "merged", content, from: origin.version };
177
+ }