@goodbones/cli 0.1.0-beta.16 → 0.1.0-beta.18

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 (39) hide show
  1. package/build/dts/campaigns/host.d.ts +51 -0
  2. package/build/dts/campaigns/host.d.ts.map +1 -0
  3. package/build/dts/campaigns/live.d.ts +3 -0
  4. package/build/dts/campaigns/live.d.ts.map +1 -0
  5. package/build/dts/campaigns/none.d.ts +7 -0
  6. package/build/dts/campaigns/none.d.ts.map +1 -0
  7. package/build/dts/campaigns/verbs.d.ts +6 -0
  8. package/build/dts/campaigns/verbs.d.ts.map +1 -0
  9. package/build/dts/config-loader.d.ts +7 -5
  10. package/build/dts/config-loader.d.ts.map +1 -1
  11. package/build/dts/output.d.ts +9 -0
  12. package/build/dts/output.d.ts.map +1 -0
  13. package/build/dts/run.d.ts +3 -18
  14. package/build/dts/run.d.ts.map +1 -1
  15. package/build/esm/campaigns/host.js +2 -0
  16. package/build/esm/campaigns/host.js.map +1 -0
  17. package/build/esm/campaigns/live.js +106 -0
  18. package/build/esm/campaigns/live.js.map +1 -0
  19. package/build/esm/campaigns/none.js +34 -0
  20. package/build/esm/campaigns/none.js.map +1 -0
  21. package/build/esm/campaigns/verbs.js +489 -0
  22. package/build/esm/campaigns/verbs.js.map +1 -0
  23. package/build/esm/config-loader.js +40 -25
  24. package/build/esm/config-loader.js.map +1 -1
  25. package/build/esm/infer.js +3 -3
  26. package/build/esm/infer.js.map +1 -1
  27. package/build/esm/output.js +8 -0
  28. package/build/esm/output.js.map +1 -0
  29. package/build/esm/run.js +39 -555
  30. package/build/esm/run.js.map +1 -1
  31. package/package.json +18 -6
  32. package/src/campaigns/host.ts +120 -0
  33. package/src/campaigns/live.ts +136 -0
  34. package/src/campaigns/none.ts +47 -0
  35. package/src/campaigns/verbs.ts +620 -0
  36. package/src/config-loader.ts +41 -26
  37. package/src/infer.ts +3 -3
  38. package/src/output.ts +18 -0
  39. package/src/run.ts +56 -726
@@ -0,0 +1,620 @@
1
+ import { existsSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import {
5
+ attest,
6
+ authorOf,
7
+ baseSideAt,
8
+ type CampaignEvaluation,
9
+ campaignsOf,
10
+ clear,
11
+ concede,
12
+ historyOf,
13
+ note,
14
+ nudgeOf,
15
+ readDiff,
16
+ renderCampaignRows,
17
+ renderHistory,
18
+ renderNudge,
19
+ renderSectorView,
20
+ sectorMovesOf,
21
+ sectorViewOf,
22
+ snapshotCampaignsOf,
23
+ } from "@goodbones/campaigns";
24
+ import * as Effect from "effect/Effect";
25
+ import * as Result from "effect/Result";
26
+
27
+ import { type CliFailure, count, fail, report } from "../output.js";
28
+ import type { LoadedPolicy, VerbContext } from "./host.js";
29
+
30
+ // The campaign commands. `campaigns` alone is the status table; `status
31
+ // --changed` is the nudge; `attest` and `note` write a sector's record;
32
+ // `history` replays the ledgers' git history. The ledgers themselves are
33
+ // written by `objectives clear` (the ledger reconciled with the code
34
+ // wherever that is not a regression) and `objectives concede` (the one way
35
+ // a holdout is added by hand, with a reason).
36
+
37
+ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
38
+ const at = argv.indexOf(flag);
39
+ const value = at === -1 ? undefined : argv[at + 1];
40
+ return value === undefined || value.startsWith("--") ? undefined : value;
41
+ };
42
+
43
+ const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"] as const;
44
+ const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"] as const;
45
+ const VALUE_FLAGS = [
46
+ "--reason",
47
+ "--by",
48
+ "--holdouts",
49
+ "--entries",
50
+ "--sector",
51
+ "--campaign",
52
+ "--evidence",
53
+ "--base",
54
+ "--since",
55
+ "--hotfix",
56
+ ] as const;
57
+
58
+ // The verbs the family shipped with, refused by name: each has a new name
59
+ // or no place.
60
+ const RETIRED: Readonly<Record<string, string>> = {
61
+ init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
62
+ prune: "`campaigns prune` is now `objectives clear`.",
63
+ allow: "`campaigns allow` is now `objectives concede`.",
64
+ };
65
+
66
+ // The positionals after the subcommand, with the value flags and their
67
+ // values stepped over: whatever is left names the roots to walk, as it
68
+ // does for every other command.
69
+ const positionalsOf = (argv: ReadonlyArray<string>): ReadonlyArray<string> => {
70
+ const positional: Array<string> = [];
71
+ for (let at = 0; at < argv.length; at += 1) {
72
+ const one = argv[at] ?? "";
73
+ if ((VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
74
+ at += 1;
75
+ continue;
76
+ }
77
+ if (!one.startsWith("--")) positional.push(one);
78
+ }
79
+ return positional;
80
+ };
81
+
82
+ // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
83
+ // history [<campaign>]] [roots…]`: the subcommand and its arguments come
84
+ // first.
85
+ const campaignArgsOf = (
86
+ argv: ReadonlyArray<string>,
87
+ ids: ReadonlyArray<string>,
88
+ ): {
89
+ readonly subcommand: string | undefined;
90
+ readonly args: ReadonlyArray<string>;
91
+ readonly roots: ReadonlyArray<string>;
92
+ } => {
93
+ const positional = positionalsOf(argv);
94
+ const [first, ...rest] = positional;
95
+ if (first === undefined) return { subcommand: undefined, args: [], roots: [] };
96
+ if (first in RETIRED) return { subcommand: first, args: [], roots: rest };
97
+ if (!(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
98
+ return { subcommand: undefined, args: [], roots: positional };
99
+ }
100
+ switch (first) {
101
+ case "attest":
102
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
103
+ case "note":
104
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
105
+ case "history": {
106
+ const [second] = rest;
107
+ return second !== undefined && ids.includes(second)
108
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
109
+ : { subcommand: first, args: [], roots: rest };
110
+ }
111
+ case "clear":
112
+ case "concede": {
113
+ const [second] = rest;
114
+ return second !== undefined && ids.includes(second.split("/")[0] ?? "")
115
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
116
+ : { subcommand: first, args: [], roots: rest };
117
+ }
118
+ default:
119
+ return { subcommand: first, args: [], roots: rest };
120
+ }
121
+ };
122
+
123
+ // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
124
+ // --reason <text>] [roots…]`.
125
+ const objectiveArgsOf = (
126
+ argv: ReadonlyArray<string>,
127
+ ids: ReadonlyArray<string>,
128
+ ): {
129
+ readonly subcommand: string | undefined;
130
+ readonly target: { campaign: string; objective: string | null } | null;
131
+ readonly roots: ReadonlyArray<string>;
132
+ } => {
133
+ const positional = positionalsOf(argv);
134
+ const [first, second, ...rest] = positional;
135
+ if (first === undefined || !(OBJECTIVE_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
136
+ return { subcommand: first, target: null, roots: positional.slice(1) };
137
+ }
138
+ const [campaign = "", objective] = (second ?? "").split("/");
139
+ const named = second !== undefined && ids.includes(campaign);
140
+ return {
141
+ subcommand: first,
142
+ target: named ? { campaign, objective: objective ?? null } : null,
143
+ roots: named ? rest : second === undefined ? [] : [second, ...rest],
144
+ };
145
+ };
146
+
147
+ // The one campaign, when there is one, else the one `--campaign` names.
148
+ const campaignFor = (
149
+ policy: LoadedPolicy,
150
+ argv: ReadonlyArray<string>,
151
+ ): Result.Result<CampaignEvaluation["rule"], string> => {
152
+ const named = flagOf(argv, "--campaign");
153
+ if (named !== undefined) {
154
+ const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
155
+ return found === undefined
156
+ ? Result.fail(`no campaign is named "${named}"`)
157
+ : Result.succeed(found);
158
+ }
159
+ const [only] = campaignsOf(policy).campaignRules;
160
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
161
+ return Result.succeed(only);
162
+ return Result.fail(
163
+ `this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
164
+ );
165
+ };
166
+
167
+ const objectiveFor = (
168
+ rule: CampaignEvaluation["rule"],
169
+ objective: string | null,
170
+ ): Result.Result<string, string> => {
171
+ if (objective !== null) {
172
+ return rule.objectives.some((one) => one.id === objective)
173
+ ? Result.succeed(objective)
174
+ : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
175
+ }
176
+ const [only] = rule.objectives;
177
+ if (rule.objectives.length === 1 && only !== undefined) return Result.succeed(only.id);
178
+ return Result.fail(
179
+ `campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`,
180
+ );
181
+ };
182
+
183
+ export const objectives = (context: VerbContext): Effect.Effect<void, CliFailure> =>
184
+ Effect.gen(function* () {
185
+ const { argv, defaultRoots, policy } = context;
186
+ const parsed = objectiveArgsOf(
187
+ argv,
188
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
189
+ );
190
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
191
+ if (campaignsOf(policy).campaignRules.length === 0) {
192
+ return yield* report(["this policy declares no campaigns."]);
193
+ }
194
+ const evaluations = (): ReadonlyArray<CampaignEvaluation> => context.evaluate(roots);
195
+
196
+ switch (parsed.subcommand) {
197
+ case "clear": {
198
+ const targets =
199
+ parsed.target === null
200
+ ? campaignsOf(policy).campaignRules
201
+ : campaignsOf(policy).campaignRules.filter(
202
+ (rule) => rule.id === parsed.target?.campaign,
203
+ );
204
+ const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
205
+ const all = evaluations();
206
+ const lines: Array<string> = [];
207
+ for (const rule of targets) {
208
+ const evaluation = all.find((one) => one.rule.id === rule.id);
209
+ if (evaluation === undefined) continue;
210
+ const only = parsed.target?.objective ?? null;
211
+ if (only !== null && !rule.objectives.some((one) => one.id === only)) {
212
+ return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
213
+ }
214
+ const outcomes = yield* Effect.try({
215
+ try: () => clear(policy, evaluation, only, by),
216
+ catch: (cause) => fail(String(cause)),
217
+ });
218
+ // Where each sector went, read off the ledgers as they stood: a
219
+ // clear of one objective places no sector, so it says nothing.
220
+ const moves = only === null ? sectorMovesOf(policy, evaluation) : [];
221
+ for (const outcome of outcomes) {
222
+ const parts = [
223
+ ...(outcome.entered.length > 0
224
+ ? [
225
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
226
+ ]
227
+ : []),
228
+ ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
229
+ ...(outcome.rewritten > 0
230
+ ? [`${count(outcome.rewritten, "holdout")} rewritten`]
231
+ : []),
232
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
233
+ ...(outcome.rebaselined.length > 0
234
+ ? [
235
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
236
+ ]
237
+ : []),
238
+ ];
239
+ if (outcome.measure !== undefined) {
240
+ const moved = [
241
+ ...(outcome.entered.length > 0
242
+ ? [
243
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
244
+ ]
245
+ : []),
246
+ ...(outcome.measure.improved.length > 0
247
+ ? [
248
+ `${count(outcome.measure.improved.length, "sector")} improved (${outcome.measure.improved
249
+ .map((one) => `${one.sector} ${String(one.from)} → ${String(one.to)}`)
250
+ .join(", ")})`,
251
+ ]
252
+ : []),
253
+ ...(outcome.measure.grown.length > 0
254
+ ? [
255
+ `${count(outcome.measure.grown.length, "sector")} grown (${outcome.measure.grown
256
+ .map(
257
+ (one) =>
258
+ `${one.sector} ${String(one.from)} → ${String(one.to)}, ${one.phase === null ? "measured, not held" : `as ${one.phase} expects`}`,
259
+ )
260
+ .join(", ")})`,
261
+ ]
262
+ : []),
263
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "sector")} closed`] : []),
264
+ ...(outcome.rebaselined.length > 0
265
+ ? [
266
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
267
+ ]
268
+ : []),
269
+ ];
270
+ lines.push(
271
+ `${outcome.campaign}/${outcome.objective}: ${moved.length === 0 ? "nothing to clear" : moved.join(", ")}; held to ${String(outcome.measure.recorded)}.`,
272
+ );
273
+ continue;
274
+ }
275
+ lines.push(
276
+ `${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
277
+ );
278
+ }
279
+ for (const move of moves) {
280
+ lines.push(
281
+ `${move.campaign}: ${move.sector} ${move.back ? "went back" : "moved"} ${move.from ?? "done"} → ${move.to ?? "done"}` +
282
+ (move.passed.length === 0
283
+ ? "."
284
+ : `, passing ${move.passed.join(", ")} in the same clear: nothing there was ever counted for it.`),
285
+ );
286
+ }
287
+ }
288
+ return yield* report(lines);
289
+ }
290
+ case "concede": {
291
+ if (parsed.target === null) {
292
+ return yield* Effect.fail(
293
+ fail(
294
+ "objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`",
295
+ ),
296
+ );
297
+ }
298
+ const rule = campaignsOf(policy).campaignRules.find(
299
+ (one) => one.id === parsed.target?.campaign,
300
+ );
301
+ if (rule === undefined)
302
+ return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
303
+ const objective = objectiveFor(rule, parsed.target.objective);
304
+ if (Result.isFailure(objective)) return yield* Effect.fail(fail(objective.failure));
305
+ const reason = flagOf(argv, "--reason");
306
+ if (reason === undefined) {
307
+ return yield* Effect.fail(
308
+ fail(
309
+ "objectives concede needs --reason <text>: growth is recorded with why, or not at all.",
310
+ ),
311
+ );
312
+ }
313
+ const by = authorOf(flagOf(argv, "--by"));
314
+ if (by === null) {
315
+ return yield* Effect.fail(
316
+ fail("objectives concede needs an author: pass --by <email>, or set git's user.email."),
317
+ );
318
+ }
319
+ const evaluation = evaluations().find((one) => one.rule.id === rule.id);
320
+ if (evaluation === undefined)
321
+ return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
322
+ // `--holdouts` concedes a subset and refuses the rest: a pull
323
+ // request that legitimately adds one hit while another is an
324
+ // accident.
325
+ const chosen =
326
+ (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
327
+ ?.split(",")
328
+ .map((one) => one.trim()) ?? null;
329
+ const outcome = concede(
330
+ policy,
331
+ evaluation,
332
+ objective.success,
333
+ chosen,
334
+ flagOf(argv, "--sector") ?? null,
335
+ {
336
+ at: policy.now,
337
+ by,
338
+ reason,
339
+ },
340
+ );
341
+ if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
342
+ // A concession that sends a sector back a phase says so, and names
343
+ // the attestations it revoked on the way.
344
+ const sentBack = outcome.success.sentBack.flatMap((one) => [
345
+ "",
346
+ `this concession moves ${one.sector} back ${one.from ?? "done"} → ${one.to ?? "done"}.`,
347
+ ...(one.revoked.length === 0
348
+ ? []
349
+ : [
350
+ `It revokes ${one.sector}'s attestation of ${one.revoked.join(", ")}: what was attested no longer holds. Once it does again, attest it anew:`,
351
+ ...one.revoked.map(
352
+ (phase) =>
353
+ ` architecture campaigns attest ${one.sector} ${phase} --reason "…" --campaign ${rule.id}`,
354
+ ),
355
+ ]),
356
+ ]);
357
+ const scalar =
358
+ rule.objectives.find((one) => one.id === objective.success)?.measure !== null;
359
+ if (scalar) {
360
+ if (outcome.success.conceded.length === 0) {
361
+ return yield* report([
362
+ `${rule.id}/${objective.success}: nothing to concede; no sector measures past its record.`,
363
+ ]);
364
+ }
365
+ return yield* report([
366
+ `${rule.id}/${objective.success}: a rise conceded in ${count(outcome.success.conceded.length, "sector")}, recorded by ${by}.`,
367
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
368
+ ...(outcome.success.left.length === 0
369
+ ? []
370
+ : [
371
+ "",
372
+ `${count(outcome.success.left.length, "sector")} left past the record; check still fails on them.`,
373
+ ]),
374
+ ...sentBack,
375
+ ]);
376
+ }
377
+ if (outcome.success.conceded.length === 0) {
378
+ return yield* report([
379
+ `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
380
+ ]);
381
+ }
382
+ return yield* report([
383
+ `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
384
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
385
+ ...(outcome.success.left.length === 0
386
+ ? []
387
+ : [
388
+ "",
389
+ `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
390
+ ]),
391
+ ...sentBack,
392
+ ]);
393
+ }
394
+ default:
395
+ return yield* Effect.fail(
396
+ fail(
397
+ `unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`,
398
+ ),
399
+ );
400
+ }
401
+ });
402
+
403
+ export const campaigns = (context: VerbContext): Effect.Effect<void, CliFailure> =>
404
+ Effect.gen(function* () {
405
+ const { argv, defaultRoots, policy } = context;
406
+ const parsed = campaignArgsOf(
407
+ argv,
408
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
409
+ );
410
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
411
+ if (campaignsOf(policy).campaignRules.length === 0) {
412
+ return yield* report(["this policy declares no campaigns."]);
413
+ }
414
+ const json = argv.includes("--json");
415
+ const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
416
+ if (retired !== undefined) return yield* Effect.fail(fail(retired));
417
+ // A positional that is no path is refused, not walked: most often it is
418
+ // a sector's name, and `--sector` is what asks about one.
419
+ for (const root of parsed.roots) {
420
+ if (existsSync(path.resolve(policy.repoRoot, root))) continue;
421
+ const named = context.evaluate(defaultRoots).some((one) => one.sectors.has(root));
422
+ return yield* Effect.fail(
423
+ fail(
424
+ `campaigns: ${root} is not a path in the repository, so there is nothing to walk there.` +
425
+ (named ? ` It names a sector: \`campaigns status --sector ${root}\`.` : ""),
426
+ ),
427
+ );
428
+ }
429
+ const sector = flagOf(argv, "--sector");
430
+ if (parsed.subcommand === "status" && sector !== undefined) {
431
+ const only = argv.includes("--campaign") ? campaignFor(policy, argv) : null;
432
+ if (only !== null && Result.isFailure(only)) return yield* Effect.fail(fail(only.failure));
433
+ const views = context
434
+ .evaluate(roots)
435
+ .filter((one) => only === null || one.rule.id === only.success.id)
436
+ .flatMap((one) => {
437
+ const view = sectorViewOf(policy, one, sector);
438
+ return view === null ? [] : [view];
439
+ });
440
+ if (views.length === 0) {
441
+ return yield* Effect.fail(
442
+ fail(`campaigns: no campaign under ${roots.join(", ")} has a sector named ${sector}.`),
443
+ );
444
+ }
445
+ return yield* report(
446
+ json
447
+ ? [JSON.stringify({ version: 1, sectors: views }, null, 2)]
448
+ : views.flatMap((view, i) => [...(i === 0 ? [] : [""]), ...renderSectorView(view)]),
449
+ );
450
+ }
451
+
452
+ // `status` without `--changed` is the overview, in text or JSON: the
453
+ // nudge alone is scoped to a diff.
454
+ const subcommand =
455
+ parsed.subcommand === "status" && !argv.includes("--changed") ? undefined : parsed.subcommand;
456
+ switch (subcommand) {
457
+ case undefined: {
458
+ const snapshot = { campaigns: snapshotCampaignsOf(policy, context.evaluate(roots)) };
459
+ if (json) {
460
+ // Each campaign as the conformance snapshot carries it: its
461
+ // burn-down, its ladder, and a row per sector.
462
+ return yield* report([
463
+ JSON.stringify({ version: 1, campaigns: snapshot.campaigns }, null, 2),
464
+ ]);
465
+ }
466
+ return yield* report([
467
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
468
+ "",
469
+ ...renderCampaignRows(snapshot.campaigns),
470
+ "",
471
+ " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
472
+ " architecture campaigns status --sector <sector> [--json] # where one sector stands, and what holds it",
473
+ " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
474
+ ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
475
+ ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
476
+ ' architecture campaigns note <sector> "<text>"',
477
+ " architecture campaigns history [<campaign>] [--since <ref>]",
478
+ ]);
479
+ }
480
+ case "status": {
481
+ const base = flagOf(argv, "--base") ?? null;
482
+ const diff = yield* Effect.try({
483
+ try: () => readDiff(policy.repoRoot, base),
484
+ catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
485
+ });
486
+ const current = context.evaluate(roots);
487
+ const baseSide =
488
+ base === null
489
+ ? null
490
+ : yield* Effect.tryPromise({
491
+ try: () => baseSideAt(policy, base, roots, context.reload),
492
+ catch: (cause) =>
493
+ fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
494
+ });
495
+ // The ledger mode's "before" for work done ahead of the plan is the
496
+ // HEAD tree, evaluated once per commit and cached: no ledger counts
497
+ // the holdouts of a phase a sector has not entered.
498
+ const headTree =
499
+ base !== null || !current.some((one) => one.rule.phases.length > 0)
500
+ ? null
501
+ : yield* Effect.tryPromise({
502
+ try: () => baseSideAt(policy, "HEAD", roots, context.reload),
503
+ catch: (cause) => fail(`could not evaluate the tree at HEAD: ${String(cause)}`),
504
+ });
505
+ const hotfix = flagOf(argv, "--hotfix") ?? null;
506
+ const nudge = nudgeOf(
507
+ policy,
508
+ current,
509
+ diff,
510
+ baseSide,
511
+ hotfix,
512
+ hotfix === null ? null : authorOf(flagOf(argv, "--by")),
513
+ headTree,
514
+ );
515
+ yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
516
+ if (!nudge.ok) {
517
+ const ahead = nudge.sectors.some((one) => one.verdict === "ahead");
518
+ const back = nudge.sectors.some(
519
+ (one) => one.verdict !== "ok" && one.verdict !== "ahead" && one.verdict !== "hotfix",
520
+ );
521
+ return yield* Effect.fail(
522
+ fail(
523
+ [
524
+ ...(back ? ["the diff sends a sector back under its onTouch"] : []),
525
+ ...(ahead ? ["the diff works ahead of a sector's phase under its onAhead"] : []),
526
+ ].join("; "),
527
+ ),
528
+ );
529
+ }
530
+ return;
531
+ }
532
+ case "attest": {
533
+ const [sector, phase] = parsed.args;
534
+ if (sector === undefined || phase === undefined) {
535
+ return yield* Effect.fail(
536
+ fail(
537
+ 'campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`',
538
+ ),
539
+ );
540
+ }
541
+ const reason = flagOf(argv, "--reason");
542
+ if (reason === undefined)
543
+ return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
544
+ const by = authorOf(flagOf(argv, "--by"));
545
+ if (by === null)
546
+ return yield* Effect.fail(
547
+ fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."),
548
+ );
549
+ const rule = campaignFor(policy, argv);
550
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
551
+ const evaluation = context.evaluate(roots).find((one) => one.rule.id === rule.success.id);
552
+ if (evaluation === undefined)
553
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
554
+ const written = attest(policy, evaluation, sector, phase, {
555
+ reason,
556
+ evidence: flagOf(argv, "--evidence"),
557
+ by,
558
+ });
559
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
560
+ return yield* report([
561
+ `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
562
+ "Run `objectives clear` to move it on.",
563
+ ]);
564
+ }
565
+ case "note": {
566
+ const [sector, text] = parsed.args;
567
+ if (sector === undefined || text === undefined) {
568
+ return yield* Effect.fail(
569
+ fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'),
570
+ );
571
+ }
572
+ const by = authorOf(flagOf(argv, "--by"));
573
+ if (by === null)
574
+ return yield* Effect.fail(
575
+ fail("campaigns note needs an author: pass --by <email>, or set git's user.email."),
576
+ );
577
+ const rule = campaignFor(policy, argv);
578
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
579
+ const evaluation = context.evaluate(roots).find((one) => one.rule.id === rule.success.id);
580
+ if (evaluation === undefined)
581
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
582
+ const written = note(policy, evaluation, sector, text, by);
583
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
584
+ return yield* report([
585
+ `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
586
+ ]);
587
+ }
588
+ case "history": {
589
+ const [named] = parsed.args;
590
+ const targets =
591
+ named === undefined
592
+ ? campaignsOf(policy).campaignRules
593
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
594
+ const manifestPath = path
595
+ .relative(policy.repoRoot, context.manifestPath)
596
+ .replaceAll(path.sep, "/");
597
+ const lines: Array<string> = [];
598
+ for (const rule of targets) {
599
+ const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
600
+ if (json) {
601
+ lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
602
+ continue;
603
+ }
604
+ if (lines.length > 0) lines.push("");
605
+ for (const line of renderHistory(rule, rows)) lines.push(line);
606
+ }
607
+ return yield* report(lines);
608
+ }
609
+ case "clear":
610
+ case "concede":
611
+ // The ledger verbs answer under `objectives`; accepted here too.
612
+ return yield* objectives(context);
613
+ default:
614
+ return yield* Effect.fail(
615
+ fail(
616
+ `unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`,
617
+ ),
618
+ );
619
+ }
620
+ });