@clossys/writer 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +587 -2
  2. package/dist/approval-bypass.d.ts +120 -0
  3. package/dist/approval-bypass.d.ts.map +1 -0
  4. package/dist/approval-bypass.js +2318 -0
  5. package/dist/approval-bypass.js.map +1 -0
  6. package/dist/approval.d.ts +62 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/approval.js +421 -0
  9. package/dist/approval.js.map +1 -0
  10. package/dist/approve-cli.d.ts +82 -0
  11. package/dist/approve-cli.d.ts.map +1 -0
  12. package/dist/approve-cli.js +430 -0
  13. package/dist/approve-cli.js.map +1 -0
  14. package/dist/cli.d.ts +15 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +48 -13
  17. package/dist/cli.js.map +1 -1
  18. package/dist/field-options.d.ts +59 -0
  19. package/dist/field-options.d.ts.map +1 -0
  20. package/dist/field-options.js +109 -0
  21. package/dist/field-options.js.map +1 -0
  22. package/dist/front-door.check.d.ts +2 -0
  23. package/dist/front-door.check.d.ts.map +1 -0
  24. package/dist/front-door.check.js +8 -0
  25. package/dist/front-door.check.js.map +1 -0
  26. package/dist/front-door.d.ts +82 -0
  27. package/dist/front-door.d.ts.map +1 -0
  28. package/dist/front-door.js +271 -0
  29. package/dist/front-door.js.map +1 -0
  30. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  31. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  32. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  33. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  34. package/dist/generated/contract-schema.generated.d.ts +97 -0
  35. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  36. package/dist/generated/contract-schema.generated.js +496 -0
  37. package/dist/generated/contract-schema.generated.js.map +1 -0
  38. package/dist/index.d.ts +14 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +24 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/messaging-kit.d.ts +113 -0
  43. package/dist/messaging-kit.d.ts.map +1 -0
  44. package/dist/messaging-kit.js +121 -0
  45. package/dist/messaging-kit.js.map +1 -0
  46. package/dist/passage.d.ts +2 -1
  47. package/dist/passage.d.ts.map +1 -1
  48. package/dist/passage.js +2 -1
  49. package/dist/passage.js.map +1 -1
  50. package/dist/plan-authority.d.ts +73 -0
  51. package/dist/plan-authority.d.ts.map +1 -0
  52. package/dist/plan-authority.js +126 -0
  53. package/dist/plan-authority.js.map +1 -0
  54. package/dist/plan-contract.d.ts +16 -0
  55. package/dist/plan-contract.d.ts.map +1 -0
  56. package/dist/plan-contract.js +33 -0
  57. package/dist/plan-contract.js.map +1 -0
  58. package/dist/plan-digest.d.ts +23 -0
  59. package/dist/plan-digest.d.ts.map +1 -0
  60. package/dist/plan-digest.js +82 -0
  61. package/dist/plan-digest.js.map +1 -0
  62. package/dist/plan-rules.d.ts +54 -0
  63. package/dist/plan-rules.d.ts.map +1 -0
  64. package/dist/plan-rules.js +145 -0
  65. package/dist/plan-rules.js.map +1 -0
  66. package/dist/resolve.d.ts +40 -6
  67. package/dist/resolve.d.ts.map +1 -1
  68. package/dist/resolve.js +144 -18
  69. package/dist/resolve.js.map +1 -1
  70. package/dist/scan.d.ts +32 -0
  71. package/dist/scan.d.ts.map +1 -1
  72. package/dist/scan.js +67 -6
  73. package/dist/scan.js.map +1 -1
  74. package/dist/schema.d.ts +1 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +209 -5
  77. package/dist/schema.js.map +1 -1
  78. package/dist/site-identity.d.ts +88 -0
  79. package/dist/site-identity.d.ts.map +1 -0
  80. package/dist/site-identity.js +82 -0
  81. package/dist/site-identity.js.map +1 -0
  82. package/dist/types.d.ts +65 -7
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +9 -7
  85. package/dist/types.js.map +1 -1
  86. package/package.json +7 -6
  87. package/skill/SKILL.md +2 -2
  88. package/src/approval-bypass.ts +2263 -0
  89. package/src/approval.ts +491 -0
  90. package/src/approve-cli.ts +474 -0
  91. package/src/cli.ts +46 -13
  92. package/src/field-options.ts +134 -0
  93. package/src/front-door.check.ts +10 -0
  94. package/src/front-door.ts +312 -0
  95. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  96. package/src/generated/contract-schema.generated.ts +520 -0
  97. package/src/index.ts +97 -1
  98. package/src/messaging-kit.ts +195 -0
  99. package/src/passage.ts +2 -1
  100. package/src/plan-authority.ts +160 -0
  101. package/src/plan-contract.ts +45 -0
  102. package/src/plan-digest.ts +81 -0
  103. package/src/plan-rules.ts +179 -0
  104. package/src/resolve.ts +185 -19
  105. package/src/scan.ts +97 -6
  106. package/src/schema.ts +223 -5
  107. package/src/site-identity.ts +125 -0
  108. package/src/types.ts +72 -7
  109. package/templates/front-door.en.json +426 -0
@@ -0,0 +1,474 @@
1
+ /**
2
+ * `writer-check approve` — writes a `CopyRegistryEntry.approval` record so
3
+ * nobody has to hand-edit one. It exists as a command, not a hand-edited
4
+ * JSON field, for the same reason `textFingerprint` (`fingerprint.ts`) is a
5
+ * content hash rather than a human-maintained counter: a hand-typed record
6
+ * is easy to get wrong — copying yesterday's `approvedAt`, pinning `textFingerprint` to
7
+ * some other entry's text, or leaving `pendingOwnerReview` set after an
8
+ * owner actually reviewed it. Every field this command writes is either
9
+ * computed (`approvedAt` from the clock, `textFingerprint` from the
10
+ * entry's own current `text` via `computeCopyFingerprint`) or copied
11
+ * verbatim from an argument this command itself validated (`--delegate`,
12
+ * `--scope`, `--expires`) — there is no field here a human types free-form
13
+ * into the JSON.
14
+ *
15
+ * This module owns argument parsing, registry-file I/O, and the mutate-
16
+ * then-atomically-rename write; `./approval.ts`'s `isDelegateScopeItem` and
17
+ * `isEntryInDelegateScope` decide whether a requested id is in a delegate's
18
+ * declared scope, and `./schema.ts`'s `validateCopyRegistryShape` decides
19
+ * whether the registry file is trustworthy enough to mutate at all — this
20
+ * file calls both rather than re-deriving either.
21
+ *
22
+ * `main`-equivalent shape is `mainApproveCommand`, exported (unlike a
23
+ * typical CLI `main`) so this package's tests (which do not ship) can exercise the whole
24
+ * argv-to-exit-code contract directly against real `mkdtemp` fixture
25
+ * files, without spawning a subprocess — the same discipline every other
26
+ * subcommand in this package's `cli.ts` already holds to. `argv` excludes
27
+ * the leading `"approve"` token: `cli.ts`'s top-level dispatch calls
28
+ * `mainApproveCommand(argv.slice(1))` for `writer-check approve ...`, the same way `"passages"` and
29
+ * `"addressability"` are already dispatched there.
30
+ *
31
+ * Exit codes — a three-state contract matching every other gate/command in
32
+ * this package:
33
+ *
34
+ * 0 — written: every named entry was approved or revoked, and the
35
+ * registry file on disk now reflects it.
36
+ * 1 — refused: at least one named entry is unknown, is `retired`, or —
37
+ * for a delegate approval — falls outside `--scope`. This is
38
+ * ALL-OR-NOTHING: when any requested id has a problem, EVERY problem
39
+ * is printed and NOTHING is written, even for the other ids that
40
+ * would otherwise have succeeded. A partial write here would leave a
41
+ * consumer's own audit trail unable to tell whether a whole
42
+ * requested batch was actually reviewed together.
43
+ * 2 — could not run: bad arguments (see below), or the registry file is
44
+ * missing/unreadable, not valid JSON, or fails
45
+ * `validateCopyRegistryShape`. Nothing is written in this state
46
+ * either — this command never mutates a registry it does not first
47
+ * fully trust.
48
+ *
49
+ * Bad-argument cases that produce exit 2, all before the registry file is
50
+ * ever opened: no registry-file argument, zero entry ids, neither `--by`
51
+ * nor `--revoke` (or both), `--by` other than `owner`/`delegate`, `--by
52
+ * owner` combined with `--delegate`/`--scope`/`--expires`, `--by delegate`
53
+ * missing `--delegate` or `--scope`, an empty `--delegate` value, a
54
+ * `--scope` item that fails `isDelegateScopeItem` or an empty scope list,
55
+ * `--revoke` combined with any of `--by`/`--delegate`/`--scope`/
56
+ * `--expires`, an `--expires` value that is not a well-formed ISO 8601 UTC
57
+ * timestamp or is not strictly after the current time, or an unknown flag.
58
+ * `--help`/`-h` always prints usage and returns 0, taking precedence over
59
+ * every other check reachable in the same parse. Any other unexpected
60
+ * exception is caught and mapped to exit 2 as well — this command never
61
+ * lets an uncaught error double as a "something went wrong" report.
62
+ */
63
+
64
+ import { randomBytes } from "node:crypto";
65
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
66
+ import { basename, dirname, join, resolve } from "node:path";
67
+ import { isDelegateScopeItem, isEntryInDelegateScope } from "./approval.js";
68
+ import { COPY_FINGERPRINT_ALGORITHM, computeCopyFingerprint } from "./fingerprint.js";
69
+ import { isIsoUtcTimestamp, validateCopyRegistryShape } from "./schema.js";
70
+
71
+ export const APPROVE_USAGE = `Usage: writer-check approve <registry-file> <entry-id>... --by owner
72
+ or: writer-check approve <registry-file> <entry-id>... --by delegate --delegate <id> --scope <ns>[,<ns>...] [--expires <ISO>]
73
+ or: writer-check approve <registry-file> <entry-id>... --revoke
74
+
75
+ registry-file Path to a CopyRegistry JSON file. Required.
76
+ entry-id One or more entry ids to approve or revoke (repeatable positional argument; duplicates are deduplicated). Required.
77
+
78
+ Options:
79
+ --help Print this message and exit 0.
80
+ --by <owner|delegate> Who is recording this approval. Exactly one of --by or --revoke is required.
81
+ --delegate <id> Opaque delegate identifier, never a personal name. Required (and only allowed) with --by delegate.
82
+ --scope <ns>[,<ns>...] One or more dot-separated entry-id namespaces the delegate may approve (repeatable; a comma-separated value and repeated flags union). Required (and only allowed) with --by delegate.
83
+ --expires <ISO> ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ, optionally with milliseconds) after which the delegate approval expires. Must be strictly after the current time. Only allowed with --by delegate.
84
+ --revoke Return every named entry to "draft" and delete its approval record. Cannot be combined with --by, --delegate, --scope, or --expires.
85
+
86
+ Approving an entry that already carries a delegate's approval record with
87
+ --by owner replaces that record with the owner's own — this is how a
88
+ pending delegate approval is durably confirmed.
89
+
90
+ Exit codes: 0 = written (every named entry approved or revoked; the
91
+ registry file was updated), 1 = refused (an unknown, retired, or — for a
92
+ delegate approval — out-of-scope entry id was named; every problem is
93
+ printed and nothing is written), 2 = could not run (bad arguments, or the
94
+ registry file is missing/unreadable/not valid JSON/fails schema
95
+ validation; nothing is written).
96
+ `;
97
+
98
+ /** Bad arguments only — never thrown once the registry file has been read. */
99
+ class ApproveInputError extends Error {}
100
+
101
+ type ApproveAction =
102
+ | { kind: "approve-owner" }
103
+ | { kind: "approve-delegate"; delegate: string; scope: string[]; expiresAt?: string }
104
+ | { kind: "revoke" };
105
+
106
+ interface RawArgs {
107
+ registryFile?: string;
108
+ entryIds: string[];
109
+ by?: string;
110
+ delegate?: string;
111
+ scope?: string[];
112
+ expires?: string;
113
+ revoke: boolean;
114
+ help: boolean;
115
+ }
116
+
117
+ interface ValidatedArgs {
118
+ registryFile: string;
119
+ entryIds: string[];
120
+ action: ApproveAction;
121
+ }
122
+
123
+ function parseRawArgs(argv: string[]): RawArgs {
124
+ let registryFile: string | undefined;
125
+ const entryIds: string[] = [];
126
+ let by: string | undefined;
127
+ let delegate: string | undefined;
128
+ let scope: string[] | undefined;
129
+ let expires: string | undefined;
130
+ let revoke = false;
131
+ let help = false;
132
+
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const arg = argv[i] as string;
135
+ if (arg === "--help" || arg === "-h") {
136
+ help = true;
137
+ continue;
138
+ }
139
+ if (arg === "--revoke") {
140
+ revoke = true;
141
+ continue;
142
+ }
143
+ if (arg === "--by") {
144
+ const value = argv[++i];
145
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--by requires a value");
146
+ by = value;
147
+ continue;
148
+ }
149
+ if (arg === "--delegate") {
150
+ const value = argv[++i];
151
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--delegate requires a value");
152
+ delegate = value;
153
+ continue;
154
+ }
155
+ if (arg === "--scope") {
156
+ const value = argv[++i];
157
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--scope requires a value");
158
+ const tokens = value.split(",").map((token) => token.trim());
159
+ scope = [...(scope ?? []), ...tokens];
160
+ continue;
161
+ }
162
+ if (arg === "--expires") {
163
+ const value = argv[++i];
164
+ if (value === undefined || value.startsWith("-")) {
165
+ throw new ApproveInputError("--expires requires an ISO 8601 UTC timestamp");
166
+ }
167
+ expires = value;
168
+ continue;
169
+ }
170
+ if (arg.startsWith("-")) {
171
+ throw new ApproveInputError(`unknown flag "${arg}"`);
172
+ }
173
+ if (registryFile === undefined) {
174
+ registryFile = arg;
175
+ } else {
176
+ entryIds.push(arg);
177
+ }
178
+ }
179
+
180
+ return { registryFile, entryIds, by, delegate, scope, expires, revoke, help };
181
+ }
182
+
183
+ /**
184
+ * `YYYY-MM-DDTHH:MM:SS(.sss)?Z` — deliberately the same shape
185
+ * `schema.ts`'s own (unexported) `ISO_UTC_TIMESTAMP_RE` accepts for
186
+ * `approvedAt`/`expiresAt`, so a value this command accepts on argv is
187
+ * never one `validateCopyRegistryShape` would then reject from the file
188
+ * this command just wrote. `Date.parse` must also actually accept it, so a
189
+ * string that merely matches the pattern but names an impossible date
190
+ * (e.g. a bad month) is still rejected.
191
+ */
192
+ function validateExpires(raw: string, now: Date): string {
193
+ if (!isIsoUtcTimestamp(raw)) {
194
+ throw new ApproveInputError(
195
+ `--expires must be an ISO 8601 UTC timestamp (e.g. 2026-01-01T00:00:00Z), got ${JSON.stringify(raw)}`,
196
+ );
197
+ }
198
+ if (Date.parse(raw) <= now.getTime()) {
199
+ throw new ApproveInputError(`--expires ${JSON.stringify(raw)} must be strictly after the current time`);
200
+ }
201
+ return raw;
202
+ }
203
+
204
+ /**
205
+ * Turns the raw, syntactically-parsed argv into a fully validated action —
206
+ * every semantic rule this command enforces before ever touching the
207
+ * registry file lives here. Throws `ApproveInputError` on the first
208
+ * violation, matching every other subcommand's `parseArgs` in this
209
+ * package.
210
+ */
211
+ function validateArgs(raw: RawArgs, now: Date): ValidatedArgs {
212
+ if (!raw.registryFile) {
213
+ throw new ApproveInputError("registry-file is required");
214
+ }
215
+ if (raw.entryIds.length === 0) {
216
+ throw new ApproveInputError("at least one entry-id is required");
217
+ }
218
+
219
+ if (!raw.revoke && raw.by === undefined) {
220
+ throw new ApproveInputError("exactly one of --by or --revoke is required");
221
+ }
222
+
223
+ let action: ApproveAction;
224
+ if (raw.revoke) {
225
+ if (raw.by !== undefined || raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
226
+ throw new ApproveInputError("--revoke cannot be combined with --by, --delegate, --scope, or --expires");
227
+ }
228
+ action = { kind: "revoke" };
229
+ } else if (raw.by === "owner") {
230
+ if (raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
231
+ throw new ApproveInputError("--by owner cannot be combined with --delegate, --scope, or --expires");
232
+ }
233
+ action = { kind: "approve-owner" };
234
+ } else if (raw.by === "delegate") {
235
+ if (raw.delegate === undefined) {
236
+ throw new ApproveInputError("--by delegate requires --delegate <id>");
237
+ }
238
+ if (raw.delegate.trim().length === 0) {
239
+ throw new ApproveInputError("--delegate must not be empty");
240
+ }
241
+ const delegateId = raw.delegate;
242
+ if (raw.scope === undefined || raw.scope.length === 0) {
243
+ throw new ApproveInputError("--by delegate requires --scope <ns>[,<ns>...]");
244
+ }
245
+ for (const item of raw.scope) {
246
+ if (!isDelegateScopeItem(item)) {
247
+ throw new ApproveInputError(`invalid --scope item ${JSON.stringify(item)}`);
248
+ }
249
+ }
250
+ const expiresAt = raw.expires !== undefined ? validateExpires(raw.expires, now) : undefined;
251
+ action = { kind: "approve-delegate", delegate: delegateId, scope: raw.scope, expiresAt };
252
+ } else {
253
+ throw new ApproveInputError(`--by must be "owner" or "delegate", got ${JSON.stringify(raw.by)}`);
254
+ }
255
+
256
+ return { registryFile: raw.registryFile, entryIds: [...new Set(raw.entryIds)], action };
257
+ }
258
+
259
+ interface ReadOutcome {
260
+ ok: boolean;
261
+ parsed?: Record<string, unknown>;
262
+ messages: string[];
263
+ }
264
+
265
+ /**
266
+ * Reads, parses, and shape-validates the registry file — never throws.
267
+ * Deliberately does NOT go through `parseCopyRegistry`/`buildCopyRegistry`
268
+ * (`schema.ts`): those rebuild a fresh object from validated fields, which
269
+ * would lose the original file's key order and any field this package
270
+ * does not itself model. This command mutates the raw parsed JSON value in
271
+ * place instead (see `mainApproveCommand`), so it only needs
272
+ * `validateCopyRegistryShape`'s findings, never a rebuilt object.
273
+ */
274
+ function readRegistry(path: string): ReadOutcome {
275
+ let raw: string;
276
+ try {
277
+ raw = readFileSync(path, "utf8");
278
+ } catch (error) {
279
+ return {
280
+ ok: false,
281
+ messages: [`registry file "${path}" could not be read: ${error instanceof Error ? error.message : String(error)}`],
282
+ };
283
+ }
284
+
285
+ let parsed: unknown;
286
+ try {
287
+ parsed = JSON.parse(raw);
288
+ } catch (error) {
289
+ return {
290
+ ok: false,
291
+ messages: [`registry file "${path}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
292
+ };
293
+ }
294
+
295
+ const findings = validateCopyRegistryShape(parsed);
296
+ if (findings.length > 0) {
297
+ return {
298
+ ok: false,
299
+ messages: findings.map((f) => `[${f.rule}]${f.path ? ` ${f.path}:` : ""} ${f.message}`),
300
+ };
301
+ }
302
+
303
+ return { ok: true, parsed: parsed as Record<string, unknown>, messages: [] };
304
+ }
305
+
306
+ function indexEntries(parsed: Record<string, unknown>): Map<string, Record<string, unknown>> {
307
+ const entries = parsed.entries as unknown[];
308
+ const byId = new Map<string, Record<string, unknown>>();
309
+ for (const raw of entries) {
310
+ const entry = raw as Record<string, unknown>;
311
+ byId.set(entry.id as string, entry);
312
+ }
313
+ return byId;
314
+ }
315
+
316
+ /**
317
+ * All-or-nothing pre-flight: every requested id that is unknown, retired,
318
+ * or (for a delegate approval) outside `--scope` is collected here, so
319
+ * `mainApproveCommand` can print every problem and refuse the WHOLE batch
320
+ * — never write some of the requested entries and skip others.
321
+ */
322
+ function findProblems(
323
+ entriesById: Map<string, Record<string, unknown>>,
324
+ entryIds: string[],
325
+ action: ApproveAction,
326
+ ): string[] {
327
+ const problems: string[] = [];
328
+ for (const id of entryIds) {
329
+ const entry = entriesById.get(id);
330
+ if (!entry) {
331
+ problems.push(`unknown entry id "${id}"`);
332
+ continue;
333
+ }
334
+ if (entry.status === "retired") {
335
+ problems.push(`entry "${id}" is retired and cannot be approved or revoked`);
336
+ continue;
337
+ }
338
+ if (action.kind === "approve-delegate" && !isEntryInDelegateScope(id, action.scope)) {
339
+ problems.push(`entry "${id}" is outside delegate scope [${action.scope.join(", ")}]`);
340
+ }
341
+ }
342
+ return problems;
343
+ }
344
+
345
+ /**
346
+ * Builds a fresh `CopyApproval`-shaped record with keys in the exact order
347
+ * this package's contract fixes: `approvedBy`, `approvedAt`,
348
+ * `textFingerprint`, `fingerprintAlgorithm`, then — delegate records only —
349
+ * `delegate`, `pendingOwnerReview`, `expiresAt`. Assigning this whole
350
+ * object to `entry.approval` either appends a new `approval` key at the
351
+ * end of the entry (it did not exist before) or replaces an existing one
352
+ * in place (its position in the entry is unchanged, because reassigning an
353
+ * existing object key never moves it) — see this file's top doc comment
354
+ * and `mainApproveCommand` for why that is exactly the key-order contract
355
+ * this command must preserve.
356
+ */
357
+ function buildApprovalRecord(action: ApproveAction, entry: Record<string, unknown>, now: Date): Record<string, unknown> {
358
+ const record: Record<string, unknown> = {
359
+ approvedBy: action.kind === "approve-delegate" ? "delegate" : "owner",
360
+ approvedAt: now.toISOString(),
361
+ textFingerprint: computeCopyFingerprint(entry.text as string),
362
+ fingerprintAlgorithm: COPY_FINGERPRINT_ALGORITHM,
363
+ };
364
+ if (action.kind === "approve-delegate") {
365
+ record.delegate = { id: action.delegate, scope: [...action.scope] };
366
+ record.pendingOwnerReview = true;
367
+ if (action.expiresAt !== undefined) {
368
+ record.expiresAt = action.expiresAt;
369
+ }
370
+ }
371
+ return record;
372
+ }
373
+
374
+ /**
375
+ * Writes `data` to `path` by writing a temp file in the SAME directory,
376
+ * then `renameSync`-ing it into place, so the registry file is replaced by
377
+ * one rename rather than rewritten in place. The temp file is cleaned up
378
+ * on any failure; a failure after a successful `renameSync` cannot happen,
379
+ * since nothing runs after it.
380
+ */
381
+ function writeRegistryFile(path: string, data: unknown): void {
382
+ const dir = dirname(path);
383
+ const base = basename(path);
384
+ const tempPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
385
+ const content = `${JSON.stringify(data, null, 2)}\n`;
386
+ try {
387
+ writeFileSync(tempPath, content, "utf8");
388
+ renameSync(tempPath, path);
389
+ } catch (error) {
390
+ try {
391
+ if (existsSync(tempPath)) unlinkSync(tempPath);
392
+ } catch {
393
+ // Best-effort cleanup only — the original error below is what matters.
394
+ }
395
+ throw error;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Exported (unlike a typical CLI `main`) so this package's tests (which do
401
+ * not ship) can exercise the whole argv-to-exit-code contract directly against real
402
+ * `mkdtemp` fixture files, without spawning a subprocess — see this file's
403
+ * top doc comment for the full exit-code contract. `argv` excludes the
404
+ * leading `"approve"` subcommand token. `io.now` lets a test pin the clock
405
+ * that `approvedAt` and `--expires` validation are measured against;
406
+ * omitted, it defaults to `new Date()`.
407
+ *
408
+ * Never throws: every failure, expected or not, is caught here and mapped
409
+ * to an exit code — bad arguments and an untrustworthy registry file both
410
+ * become 2 with nothing written, a refused (but well-formed) request
411
+ * becomes 1 with nothing written, and a clean run becomes 0 with the
412
+ * registry file updated on disk.
413
+ */
414
+ export function mainApproveCommand(argv: string[], io: { now?: Date } = {}): number {
415
+ try {
416
+ const now = io.now ?? new Date();
417
+ const raw = parseRawArgs(argv);
418
+ if (raw.help) {
419
+ console.log(APPROVE_USAGE);
420
+ return 0;
421
+ }
422
+
423
+ const validated = validateArgs(raw, now);
424
+
425
+ const registryFile = resolve(validated.registryFile);
426
+ const read = readRegistry(registryFile);
427
+ if (!read.ok || !read.parsed) {
428
+ console.error(`[approve] registry could not be loaded:`);
429
+ for (const message of read.messages) console.error(` ${message}`);
430
+ console.error("[approve] Refusing to modify a registry that could not be fully validated.");
431
+ return 2;
432
+ }
433
+
434
+ const entriesById = indexEntries(read.parsed);
435
+ const problems = findProblems(entriesById, validated.entryIds, validated.action);
436
+ if (problems.length > 0) {
437
+ console.error(`[approve] refusing to write — ${problems.length} problem(s) with the requested entries:`);
438
+ for (const problem of problems) console.error(` ${problem}`);
439
+ return 1;
440
+ }
441
+
442
+ const lines: string[] = [];
443
+ for (const id of validated.entryIds) {
444
+ const entry = entriesById.get(id) as Record<string, unknown>;
445
+ if (validated.action.kind === "revoke") {
446
+ entry.status = "draft";
447
+ delete entry.approval;
448
+ lines.push(`[approve] ${id}: revoked (now draft)`);
449
+ } else {
450
+ entry.status = "approved";
451
+ entry.approval = buildApprovalRecord(validated.action, entry, now);
452
+ lines.push(
453
+ validated.action.kind === "approve-delegate"
454
+ ? `[approve] ${id}: approved by delegate ${validated.action.delegate} (pending owner review)`
455
+ : `[approve] ${id}: approved by owner`,
456
+ );
457
+ }
458
+ }
459
+
460
+ writeRegistryFile(registryFile, read.parsed);
461
+ for (const line of lines) console.log(line);
462
+ return 0;
463
+ } catch (error) {
464
+ if (error instanceof ApproveInputError) {
465
+ console.error(`writer-check approve: ${error.message}`);
466
+ console.error(`\n${APPROVE_USAGE}`);
467
+ return 2;
468
+ }
469
+ console.error(
470
+ `writer-check approve: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
471
+ );
472
+ return 2;
473
+ }
474
+ }
package/src/cli.ts CHANGED
@@ -109,6 +109,21 @@
109
109
  * all. This is the explicit third state this gate is built around:
110
110
  * "could not check" must never be reported as a pass.
111
111
  *
112
+ * A FIFTH and SIXTH subcommand, `approve` and `approval-state`, are
113
+ * dispatched the same way `addressability` and `passages` are — a fully
114
+ * separate top-level branch at the very bottom of this file, never folded
115
+ * through `main()`. `writer-check approve` writes a
116
+ * `CopyRegistryEntry.approval` record so nobody hand-edits one; its whole
117
+ * argument-parsing, validation, and atomic-write contract lives in
118
+ * `mainApproveCommand` (`./approve-cli.ts`) — this file only wires its
119
+ * exit code through. `writer-check approval-state` reports the current
120
+ * approval state of a registry from two angles at once: whether every
121
+ * approved entry's own record is current (`assessCopyApprovals`) and
122
+ * whether consumer source actually routes copy through the registry's
123
+ * approval lifecycle rather than around it (`scanApprovalBypass`/
124
+ * `checkApprovalBypass`); its own contract lives in
125
+ * `mainApprovalStateCommand` (`./approval.ts`).
126
+ *
112
127
  * A malformed `pathExclusions` entry (see `scan.ts`/`path-exclusions.ts`)
113
128
  * gets the same `2` treatment as `unchecked`, for the identical reason: an
114
129
  * exclusion list this run cannot trust means this run cannot say which
@@ -134,6 +149,8 @@ import { checkCopyTraceability, type CopyGateFinding, type CopyGateIgnored, type
134
149
  import { checkLocaleCoverage, type LocaleCoverageReport } from "./locale-coverage.js";
135
150
  import { liveTreeExists, scanLiveCopyTrees } from "./live-copy-gate.js";
136
151
  import { checkPassageComposition, readPassageRecord, type PassageGateResult } from "./passage.js";
152
+ import { mainApprovalStateCommand } from "./approval.js";
153
+ import { mainApproveCommand } from "./approve-cli.js";
137
154
  import { readCopyRecord } from "./registry.js";
138
155
  import { checkRenderRegistryParity } from "./render-registry-parity.js";
139
156
  import { scanCopySourceTree, type ScanResult, type UncheckedItem } from "./scan.js";
@@ -147,13 +164,18 @@ const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
147
164
  record-file Path to a CopyRecord JSON file (see @clossys/writer's README). Required.
148
165
  scan-dir Directory to scan for user-facing string/template literals. Defaults to the current working directory.
149
166
 
150
- See also "writer-check addressability [scan-dir]" and "writer-check passages
151
- <registry-file>" (this same file's other subcommands, both dispatched on
152
- argv[0] before anything below) — addressability asks whether user-facing
153
- prose is resolved from the copy registry by id, rather than typed inline;
154
- passages asks whether a passage composes registered entries/terms rather
155
- than inlining a literal or reaching into another passage's internals. Both
156
- are separate, stricter gates; neither runs as part of this default command.
167
+ See also "writer-check addressability [scan-dir]", "writer-check passages
168
+ <registry-file>", "writer-check approve <registry-file> <entry-id>...", and
169
+ "writer-check approval-state <registry-file> <scan-dir>" (this same file's
170
+ other subcommands, all dispatched on argv[0] before anything below) —
171
+ addressability asks whether user-facing prose is resolved from the copy
172
+ registry by id, rather than typed inline; passages asks whether a passage
173
+ composes registered entries/terms rather than inlining a literal or
174
+ reaching into another passage's internals; approve writes an entry's
175
+ approval record; approval-state reports whether a
176
+ registry's approval records are current and whether consumer source
177
+ actually routes copy through them. All four are separate gates/commands;
178
+ none runs as part of this default command.
157
179
 
158
180
  Options:
159
181
  --help Print this message and exit 0.
@@ -177,6 +199,8 @@ Run "writer-check voice-derivation-coverage --help" for the second subcommand's
177
199
  Run "writer-check locale-coverage --help" for the third subcommand's own usage.
178
200
  Run "writer-check addressability --help" for that subcommand's own usage.
179
201
  Run "writer-check passages --help" for the passage-composition subcommand's own usage.
202
+ Run "writer-check approve --help" for the approval-recording subcommand's own usage.
203
+ Run "writer-check approval-state --help" for the approval-state subcommand's own usage.
180
204
  `;
181
205
 
182
206
  const VOICE_DERIVATION_COVERAGE_USAGE = `Usage: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
@@ -1510,11 +1534,12 @@ function detectMainModule(): boolean {
1510
1534
  }
1511
1535
 
1512
1536
  /**
1513
- * `writer-check`, `writer-check addressability`, and `writer-check passages`
1514
- * are ONE `bin` entry, one compiled file, dispatched by an explicit FIRST
1515
- * ARGUMENT — never by how this file was invoked (its own path, a symlink
1516
- * name, an installed `bin` shim, ...). That is deliberate: this
1517
- * repository's own root `package.json` invokes every gate by compiled path
1537
+ * `writer-check`, `writer-check addressability`, `writer-check passages`,
1538
+ * `writer-check approve`, and `writer-check approval-state` are ONE `bin`
1539
+ * entry, one compiled file, dispatched by an explicit FIRST ARGUMENT — never
1540
+ * by how this file was invoked (its own path, a symlink name, an installed
1541
+ * `bin` shim, ...). That is deliberate: this repository's own root
1542
+ * `package.json` invokes every gate by compiled path
1518
1543
  * (`node packages/ui/dist/tokens/contrast-cli.js`,
1519
1544
  * `node packages/controller/dist/cli.js`, ...), never by `bin` name, so
1520
1545
  * any dispatch keyed on the invoking path/name (a second `bin` entry
@@ -1524,7 +1549,11 @@ function detectMainModule(): boolean {
1524
1549
  * exactly the "runs the wrong thing without ever failing" failure mode a
1525
1550
  * gate must never have. `rawArgs[0]` is read BEFORE `main()`'s own
1526
1551
  * `parseArgs` ever sees the array, so the default command's argument
1527
- * shape is completely unaffected by either subcommand existing at all.
1552
+ * shape is completely unaffected by any subcommand existing at all.
1553
+ * `mainApproveCommand`/`mainApprovalStateCommand` never throw (see their own
1554
+ * doc comments), so — unlike `runAddressabilityCheck`/`runPassagesCheck`
1555
+ * above — their branches assign `process.exitCode` directly, with no
1556
+ * try/catch wrapper of their own to add.
1528
1557
  */
1529
1558
  if (detectMainModule()) {
1530
1559
  const rawArgs = process.argv.slice(2);
@@ -1532,6 +1561,10 @@ if (detectMainModule()) {
1532
1561
  runAddressabilityCheck(rawArgs.slice(1));
1533
1562
  } else if (rawArgs[0] === "passages") {
1534
1563
  runPassagesCheck(rawArgs.slice(1));
1564
+ } else if (rawArgs[0] === "approve") {
1565
+ process.exitCode = mainApproveCommand(rawArgs.slice(1));
1566
+ } else if (rawArgs[0] === "approval-state") {
1567
+ process.exitCode = mainApprovalStateCommand(rawArgs.slice(1));
1535
1568
  } else {
1536
1569
  run();
1537
1570
  }