@event4u/agent-config 8.11.0 → 8.12.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 (67) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/CHANGELOG.md +49 -279
  4. package/README.md +2 -2
  5. package/dist/agent-src/contexts/execution/roadmap-ci-steps-mechanics.md +117 -0
  6. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +106 -94
  7. package/dist/agent-src/rules/architecture.md +6 -51
  8. package/dist/agent-src/rules/code-comment-discipline.md +10 -94
  9. package/dist/agent-src/rules/decision-revisit-gate.md +9 -77
  10. package/dist/agent-src/rules/improve-before-implement.md +7 -66
  11. package/dist/agent-src/rules/no-roadmap-references.md +13 -77
  12. package/dist/agent-src/rules/persona-governance.md +5 -47
  13. package/dist/agent-src/rules/provider-lifecycle-discipline.md +6 -32
  14. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +21 -104
  15. package/dist/agent-src/rules/untrusted-input-defense.md +3 -75
  16. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +83 -0
  17. package/dist/agent-src/skills/decision-review/SKILL.md +86 -0
  18. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +58 -1
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/contexts/knowledge-card.md +12 -0
  21. package/dist/discovery/deprecation-report.md +1 -1
  22. package/dist/discovery/discovery-manifest.json +14 -14
  23. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  24. package/dist/discovery/discovery-manifest.summary.md +1 -1
  25. package/dist/discovery/orphan-report.md +1 -1
  26. package/dist/discovery/packs.json +2 -2
  27. package/dist/discovery/trust-report.md +1 -1
  28. package/dist/discovery/workspaces.json +2 -2
  29. package/dist/mcp/registry-manifest.json +1 -1
  30. package/dist/router.json +1 -1
  31. package/docs/CLAIMS.md +30 -2
  32. package/docs/architecture.md +1 -1
  33. package/docs/archive/CHANGELOG-pre-8.12.0.md +304 -0
  34. package/docs/benchmark.md +39 -0
  35. package/docs/contracts/ai-council-config.md +9 -4
  36. package/docs/contracts/persona-schema.md +49 -0
  37. package/docs/contracts/provider-lifecycle.md +29 -4
  38. package/docs/decisions/ADR-120-council-chairman-mode.md +70 -0
  39. package/docs/decisions/ADR-121-knowledge-sensitivity-classes.md +90 -0
  40. package/docs/decisions/INDEX.md +2 -0
  41. package/docs/design/council-vs-solo-baseline.md +92 -0
  42. package/docs/design/utilization-window-criteria.md +51 -0
  43. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +68 -0
  44. package/docs/guidelines/agent-infra/rule-body-migration-inventory.md +241 -0
  45. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +80 -3
  46. package/docs/guidelines/code-clarity.md +86 -1
  47. package/docs/maintainers/branch-protection.md +87 -0
  48. package/docs/maintainers/system-map.md +85 -0
  49. package/docs/proof.md +14 -3
  50. package/package.json +1 -1
  51. package/src/config/agent-settings.template.yml +9 -0
  52. package/src/scripts/_cli/cmd_doctor.ts +95 -0
  53. package/src/scripts/_lib/knowledge_global.ts +44 -0
  54. package/src/scripts/_lib/knowledge_global_promote.ts +150 -0
  55. package/src/scripts/ai_council/orchestrator.ts +188 -1
  56. package/src/scripts/bench_persona_placebo.ts +308 -0
  57. package/src/scripts/bench_quality_rerun.ts +357 -0
  58. package/src/scripts/build_proof.ts +9 -0
  59. package/src/scripts/check_artefact_count_messaging.ts +56 -0
  60. package/src/scripts/check_knowledge_cards.ts +33 -4
  61. package/src/scripts/check_portability.ts +1 -0
  62. package/src/scripts/complexity_report.ts +776 -0
  63. package/src/scripts/consumer_matrix.ts +251 -12
  64. package/src/scripts/council_cli.ts +56 -1
  65. package/src/scripts/explain_run.ts +593 -0
  66. package/src/scripts/knowledge_global_cli.ts +234 -45
  67. package/src/scripts/rule_backlinks.ts +0 -0
@@ -34,6 +34,7 @@
34
34
  */
35
35
  import { spawnSync } from 'node:child_process';
36
36
  import * as fs from 'node:fs';
37
+ import * as os from 'node:os';
37
38
  import * as path from 'node:path';
38
39
  import process from 'node:process';
39
40
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -105,10 +106,13 @@ function _disabled_notice(): number {
105
106
  // Subcommands
106
107
  // ---------------------------------------------------------------------------
107
108
 
108
- function cmd_list(args: { json: boolean }): number {
109
+ function cmd_list(args: { json: boolean; revoked: boolean }): number {
109
110
  if (!kg.is_enabled()) {
110
111
  return _disabled_notice();
111
112
  }
113
+ if (args.revoked) {
114
+ return _list_revoked(args.json);
115
+ }
112
116
  const cfg = kg.load_global_sharing_config();
113
117
  const cards = _list_cards();
114
118
  const rows: Record<string, string>[] = [];
@@ -142,6 +146,24 @@ function cmd_list(args: { json: boolean }): number {
142
146
  return 0;
143
147
  }
144
148
 
149
+ /** Render the revocation ledger (`list --revoked`) — table or `--json`. */
150
+ function _list_revoked(json: boolean): number {
151
+ const trail = kgp.load_tombstones();
152
+ if (json) {
153
+ process.stdout.write(kg.pyJsonDumps(trail, 2, true) + '\n');
154
+ return 0;
155
+ }
156
+ if (trail.length === 0) {
157
+ process.stdout.write(`No revocations recorded in ${kg.global_store_dir()}.\n`);
158
+ return 0;
159
+ }
160
+ process.stdout.write(`${_ljust('REVOKED-AT', 12)} ${_ljust('CARD', 28)} REASON\n`);
161
+ for (const t of trail) {
162
+ process.stdout.write(`${_ljust(t.revoked_at, 12)} ${_ljust(t.card_id, 28)} ${t.reason}\n`);
163
+ }
164
+ return 0;
165
+ }
166
+
145
167
  function cmd_show(args: { card: string }): number {
146
168
  if (!kg.is_enabled()) {
147
169
  return _disabled_notice();
@@ -185,37 +207,45 @@ function cmd_trace(args: { card: string; json: boolean }): number {
185
207
  return 0;
186
208
  }
187
209
 
188
- function _forget_one(card: string): boolean {
210
+ /**
211
+ * Remove one global card + its usage entry. Writes an append-only tombstone
212
+ * to the revocation ledger BEFORE deleting anything — no silent deletes.
213
+ */
214
+ function _forget_one(card: string, reason: string): boolean {
189
215
  const p = _card_path(card);
190
- let removed = false;
191
- if (fs.existsSync(p)) {
192
- fs.unlinkSync(p);
193
- removed = true;
194
- }
195
216
  const cid = kgp.card_id_from({ card_name: card });
196
217
  const usage = kgp.load_usage();
197
218
  const cards = usage['cards'] as SettingsDict;
198
- if (cid in cards) {
219
+ const has_card = fs.existsSync(p);
220
+ const has_usage = cid in cards;
221
+ if (!has_card && !has_usage) {
222
+ return false;
223
+ }
224
+ kgp.append_tombstone(cid, reason, { today: _today() });
225
+ if (has_card) {
226
+ fs.unlinkSync(p);
227
+ }
228
+ if (has_usage) {
199
229
  delete cards[cid];
200
230
  write_atomic(
201
231
  path.join(kg.global_store_dir(null, { create: true }), kgp.USAGE_FILENAME),
202
232
  kg.pyJsonDumps(usage, 2, true) + '\n',
203
233
  );
204
- removed = true;
205
234
  }
206
- return removed;
235
+ return true;
207
236
  }
208
237
 
209
- function cmd_forget(args: { card: string; tier: string }): number {
238
+ function cmd_forget(args: { card: string; tier: string; reason: string }): number {
210
239
  if (!kg.is_enabled()) {
211
240
  return _disabled_notice();
212
241
  }
213
242
  if (args.tier) {
243
+ const reason = args.reason || `bulk forget (tier=${args.tier})`;
214
244
  let removed = 0;
215
245
  for (const p of _list_cards()) {
216
246
  const prov = kg.parse_provenance_footer(_readReplace(p));
217
247
  if (prov['tier'] === args.tier) {
218
- if (_forget_one(_stem(p))) {
248
+ if (_forget_one(_stem(p), reason)) {
219
249
  removed += 1;
220
250
  }
221
251
  }
@@ -228,7 +258,7 @@ function cmd_forget(args: { card: string; tier: string }): number {
228
258
  process.exitCode = 1;
229
259
  return 1;
230
260
  }
231
- if (_forget_one(args.card)) {
261
+ if (_forget_one(args.card, args.reason || 'manual forget')) {
232
262
  process.stdout.write(`Forgot global card '${args.card}'.\n`);
233
263
  return 0;
234
264
  }
@@ -237,7 +267,16 @@ function cmd_forget(args: { card: string; tier: string }): number {
237
267
  return 1;
238
268
  }
239
269
 
240
- function cmd_promote(args: { path: string; source: string; tier: string; manual: boolean }): number {
270
+ function cmd_promote(args: {
271
+ path: string;
272
+ source: string;
273
+ tier: string;
274
+ manual: boolean;
275
+ sensitivity: string;
276
+ reason: string;
277
+ owner: string;
278
+ review_after: string;
279
+ }): number {
241
280
  if (!kg.is_enabled()) {
242
281
  return _disabled_notice();
243
282
  }
@@ -258,13 +297,20 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
258
297
  const allowed = [...kg.allowed_tiers()];
259
298
 
260
299
  const redaction = _isPlainObject(cfg['redaction']) ? (cfg['redaction'] as SettingsDict) : {};
300
+ const redaction_enabled = _pyBool(redaction['enabled'], true);
301
+ const halt_on_trigger = _pyBool(redaction['halt_on_trigger'], true);
302
+ // Computed once — feeds BOTH the existing tier gate below AND the new
303
+ // sensitivity auto-derivation, independent of `halt_on_trigger` (a
304
+ // `prohibited` override never depends on that unrelated config flag).
305
+ const violations = redaction_enabled ? kgr.redaction_scan(text) : [];
306
+
261
307
  const result = kgr.gate_card_for_global(text, {
262
308
  tier,
263
309
  source: args.source,
264
310
  card_name: path.basename(src_path),
265
311
  allowed_tiers: allowed,
266
- redaction_enabled: _pyBool(redaction['enabled'], true),
267
- halt_on_trigger: _pyBool(redaction['halt_on_trigger'], true),
312
+ redaction_enabled,
313
+ halt_on_trigger,
268
314
  });
269
315
  if (!result.eligible) {
270
316
  if (result.manual_only && !args.manual) {
@@ -282,8 +328,7 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
282
328
  return 2;
283
329
  }
284
330
  // manual override of proprietary: still run redaction, never skip it.
285
- const violations = kgr.redaction_scan(text);
286
- if (violations.length > 0 && _pyBool(redaction['halt_on_trigger'], true)) {
331
+ if (violations.length > 0 && halt_on_trigger) {
287
332
  process.stderr.write(
288
333
  'global-share BLOCKED (manual proprietary): redaction halt — ' +
289
334
  violations.map((v) => `${v.category}: ${_pyRepr(v.snippet)}`).join('; ') +
@@ -294,6 +339,19 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
294
339
  }
295
340
  }
296
341
 
342
+ // Sensitivity gate — layered ON TOP of the tier/redaction gate above.
343
+ const declared_sensitivity =
344
+ args.sensitivity || _frontmatter_sensitivity(text) || kg.DEFAULT_SENSITIVITY;
345
+ const sens = kgp.gate_sensitivity_for_promotion(declared_sensitivity, {
346
+ violations_present: violations.length > 0,
347
+ promotion_reason: args.reason,
348
+ });
349
+ if (!sens.eligible) {
350
+ process.stderr.write(`global-share BLOCKED: ${sens.reason}\n`);
351
+ process.exitCode = 2;
352
+ return 2;
353
+ }
354
+
297
355
  // Build provenance + usage.
298
356
  const cid = kgp.card_id_from({ card_name: path.basename(src_path), source: args.source });
299
357
  const slug = kgp.repo_slug();
@@ -306,6 +364,11 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
306
364
  ? (entry['first_seen'] as SettingsDict)
307
365
  : { repo: slug, date: today };
308
366
 
367
+ const fresh = _isPlainObject(cfg['freshness']) ? (cfg['freshness'] as SettingsDict) : {};
368
+ const review_after =
369
+ args.review_after || _addDaysIso(today, _pyInt(fresh['stale_after_days'], 180));
370
+ const owner = args.owner || _defaultOwner();
371
+
309
372
  const footer = kg.render_provenance_footer({
310
373
  first_seen_repo: (first['repo'] as string) ?? slug,
311
374
  first_seen_date: (first['date'] as string) ?? today,
@@ -313,8 +376,13 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
313
376
  last_verified: today,
314
377
  tier,
315
378
  seen_in,
379
+ source_repo: slug,
380
+ owner,
381
+ review_after,
382
+ promotion_reason: args.reason,
316
383
  });
317
- let out_text = _ensure_tier_frontmatter(text, tier);
384
+ let out_text = _ensure_frontmatter_field(text, 'tier', tier);
385
+ out_text = _ensure_frontmatter_field(out_text, 'sensitivity', sens.sensitivity);
318
386
  out_text = _rstrip(kg.strip_provenance_footer(out_text)) + '\n\n' + footer;
319
387
  const dest = _card_path(cid);
320
388
  write_atomic(path.join(kg.global_store_dir(null, { create: true }), path.basename(dest)), out_text);
@@ -329,11 +397,11 @@ function cmd_promote(args: { path: string; source: string; tier: string; manual:
329
397
  kg.pyJsonDumps(usage, 2, true) + '\n',
330
398
  );
331
399
  }
332
- process.stdout.write(`Promoted '${cid}' (tier=${tier}) → ${dest}\n`);
400
+ process.stdout.write(`Promoted '${cid}' (tier=${tier}, sensitivity=${sens.sensitivity}) → ${dest}\n`);
333
401
  return 0;
334
402
  }
335
403
 
336
- function cmd_purge(args: { confirm: boolean }): number {
404
+ function cmd_purge(args: { confirm: boolean; reason: string }): number {
337
405
  // Remove the global store and strip provenance footers from project cards.
338
406
  if (!args.confirm) {
339
407
  process.stderr.write(
@@ -345,9 +413,30 @@ function cmd_purge(args: { confirm: boolean }): number {
345
413
  }
346
414
 
347
415
  const store = kg.global_store_dir();
416
+ const reason = args.reason || 'full store purge';
417
+ if (fs.existsSync(store)) {
418
+ // Tombstone every surviving card_id BEFORE deleting anything — no
419
+ // silent deletes, even on the biggest deletion event this CLI has.
420
+ const usage = kgp.load_usage();
421
+ const cards = usage['cards'] as SettingsDict;
422
+ const seen_ids = new Set<string>();
423
+ for (const p of _list_cards()) {
424
+ const cid = _stem(p);
425
+ seen_ids.add(cid);
426
+ kgp.append_tombstone(cid, reason, { today: _today() });
427
+ }
428
+ for (const cid of Object.keys(cards)) {
429
+ if (!seen_ids.has(cid)) {
430
+ kgp.append_tombstone(cid, reason, { today: _today() });
431
+ }
432
+ }
433
+ }
348
434
  let removed = 0;
349
435
  if (fs.existsSync(store)) {
350
436
  for (const p of _globAllSorted(store)) {
437
+ if (path.basename(p) === kgp.REVOCATIONS_FILENAME) {
438
+ continue; // the ledger survives its own purge entry — the audit trail persists
439
+ }
351
440
  if (_isFile(p)) {
352
441
  fs.unlinkSync(p);
353
442
  removed += 1;
@@ -356,7 +445,7 @@ function cmd_purge(args: { confirm: boolean }): number {
356
445
  try {
357
446
  fs.rmdirSync(store);
358
447
  } catch {
359
- // non-empty (subdirs) — leave it
448
+ // non-empty (the ledger, or subdirs) — leave it
360
449
  }
361
450
  }
362
451
  process.stdout.write(`Purged global store (${removed} file(s)) at ${store}.\n`);
@@ -511,32 +600,43 @@ function cmd_lead_check(args: { report: string; strict: boolean }): number {
511
600
  // ---------------------------------------------------------------------------
512
601
 
513
602
  function _frontmatter_tier(text: string): string {
603
+ const val = _frontmatter_scalar(text, 'tier');
604
+ return kg.TIERS.includes(val) ? val : '';
605
+ }
606
+
607
+ function _frontmatter_sensitivity(text: string): string {
608
+ const val = _frontmatter_scalar(text, 'sensitivity');
609
+ return kg.SENSITIVITIES.includes(val) ? val : '';
610
+ }
611
+
612
+ /** Read a top-level scalar frontmatter field's raw (quote-stripped) value. */
613
+ function _frontmatter_scalar(text: string, field: string): string {
514
614
  const m = _FRONTMATTER_RE.exec(text);
515
615
  if (!m) {
516
616
  return '';
517
617
  }
518
618
  for (const line of _splitlines(m[1] as string)) {
519
619
  const s = _strip(line);
520
- if (s.startsWith('tier:')) {
521
- const val = _stripQuotes(_strip(s.slice('tier:'.length)));
522
- return kg.TIERS.includes(val) ? val : '';
620
+ if (s.startsWith(`${field}:`)) {
621
+ return _stripQuotes(_strip(s.slice(field.length + 1)));
523
622
  }
524
623
  }
525
624
  return '';
526
625
  }
527
626
 
528
- function _ensure_tier_frontmatter(text: string, tier: string): string {
529
- // Ensure the card frontmatter carries an accurate `tier:` field.
627
+ /** Ensure the card frontmatter carries an accurate `<field>: <value>` line. */
628
+ function _ensure_frontmatter_field(text: string, field: string, value: string): string {
530
629
  const m = _FRONTMATTER_RE.exec(text);
531
630
  if (!m) {
532
631
  return text;
533
632
  }
534
633
  const block = m[1] as string;
634
+ const line_re = new RegExp(`^\\s*${field}:.*$`, 'm');
535
635
  let new_block: string;
536
- if (/^\s*tier:/m.test(block)) {
537
- new_block = _reSubFirst(block, /^\s*tier:.*$/m, `tier: ${tier}`);
636
+ if (line_re.test(block)) {
637
+ new_block = _reSubFirst(block, line_re, `${field}: ${value}`);
538
638
  } else {
539
- new_block = `tier: ${tier}\n${block}`;
639
+ new_block = `${field}: ${value}\n${block}`;
540
640
  }
541
641
  // m.index of group 1: in Python `m.start(1)` / `m.end(1)`. The full match
542
642
  // starts at 0 (anchored `^`); group 1 begins after the leading `---\n`.
@@ -612,20 +712,23 @@ function _wrap<T>(cmd: string, rest: string[], parse: (rest: string[]) => T | nu
612
712
 
613
713
  // Per-subcommand argparse-faithful parsers.
614
714
 
615
- function _parseList(rest: string[]): { json: boolean } | number {
715
+ function _parseList(rest: string[]): { json: boolean; revoked: boolean } | number {
616
716
  let json = false;
617
- const usage = `${_PROG} list [-h] [--json]`;
717
+ let revoked = false;
718
+ const usage = `${_PROG} list [-h] [--json] [--revoked]`;
618
719
  for (const a of rest) {
619
720
  if (a === '-h' || a === '--help') {
620
721
  return _subHelp(usage);
621
722
  }
622
723
  if (a === '--json') {
623
724
  json = true;
725
+ } else if (a === '--revoked') {
726
+ revoked = true;
624
727
  } else {
625
728
  return _subError('list', `unrecognized arguments: ${a}`, usage);
626
729
  }
627
730
  }
628
- return { json };
731
+ return { json, revoked };
629
732
  }
630
733
 
631
734
  function _parseShow(rest: string[]): { card: string } | number {
@@ -674,10 +777,11 @@ function _parseTrace(rest: string[]): { card: string; json: boolean } | number {
674
777
  return { card, json };
675
778
  }
676
779
 
677
- function _parseForget(rest: string[]): { card: string; tier: string } | number {
678
- const usage = `${_PROG} forget [-h] [--tier {public,vendor,proprietary}] [card]`;
780
+ function _parseForget(rest: string[]): { card: string; tier: string; reason: string } | number {
781
+ const usage = `${_PROG} forget [-h] [--tier {public,vendor,proprietary}] [--reason REASON] [card]`;
679
782
  let card = '';
680
783
  let tier = '';
784
+ let reason = '';
681
785
  let cardSet = false;
682
786
  for (let i = 0; i < rest.length; i += 1) {
683
787
  const a = rest[i] as string;
@@ -689,6 +793,10 @@ function _parseForget(rest: string[]): { card: string; tier: string } | number {
689
793
  tier = choice;
690
794
  } else if (a.startsWith('--tier=')) {
691
795
  tier = _choiceOrExit(a.slice('--tier='.length), ['public', 'vendor', 'proprietary'], '--tier', 'forget', usage);
796
+ } else if (a === '--reason') {
797
+ reason = (rest[++i] ?? '') as string;
798
+ } else if (a.startsWith('--reason=')) {
799
+ reason = a.slice('--reason='.length);
692
800
  } else if (a.startsWith('-')) {
693
801
  return _subError('forget', `unrecognized arguments: ${a}`, usage);
694
802
  } else if (!cardSet) {
@@ -698,15 +806,35 @@ function _parseForget(rest: string[]): { card: string; tier: string } | number {
698
806
  return _subError('forget', `unrecognized arguments: ${a}`, usage);
699
807
  }
700
808
  }
701
- return { card, tier };
702
- }
703
-
704
- function _parsePromote(rest: string[]): { path: string; source: string; tier: string; manual: boolean } | number {
705
- const usage = `${_PROG} promote [-h] [--source SOURCE] [--tier {public,vendor,proprietary}] [--manual] path`;
809
+ return { card, tier, reason };
810
+ }
811
+
812
+ function _parsePromote(
813
+ rest: string[],
814
+ ):
815
+ | {
816
+ path: string;
817
+ source: string;
818
+ tier: string;
819
+ manual: boolean;
820
+ sensitivity: string;
821
+ reason: string;
822
+ owner: string;
823
+ review_after: string;
824
+ }
825
+ | number {
826
+ const usage =
827
+ `${_PROG} promote [-h] [--source SOURCE] [--tier {public,vendor,proprietary}] ` +
828
+ '[--manual] [--sensitivity {prohibited,project,shareable}] [--reason REASON] ' +
829
+ '[--owner OWNER] [--review-after DATE] path';
706
830
  let p: string | null = null;
707
831
  let source = '';
708
832
  let tier = '';
709
833
  let manual = false;
834
+ let sensitivity = '';
835
+ let reason = '';
836
+ let owner = '';
837
+ let review_after = '';
710
838
  for (let i = 0; i < rest.length; i += 1) {
711
839
  const a = rest[i] as string;
712
840
  if (a === '-h' || a === '--help') {
@@ -722,6 +850,34 @@ function _parsePromote(rest: string[]): { path: string; source: string; tier: st
722
850
  tier = _choiceOrExit(a.slice('--tier='.length), ['public', 'vendor', 'proprietary'], '--tier', 'promote', usage);
723
851
  } else if (a === '--manual') {
724
852
  manual = true;
853
+ } else if (a === '--sensitivity') {
854
+ sensitivity = _choiceOrExit(
855
+ rest[++i],
856
+ ['prohibited', 'project', 'shareable'],
857
+ '--sensitivity',
858
+ 'promote',
859
+ usage,
860
+ );
861
+ } else if (a.startsWith('--sensitivity=')) {
862
+ sensitivity = _choiceOrExit(
863
+ a.slice('--sensitivity='.length),
864
+ ['prohibited', 'project', 'shareable'],
865
+ '--sensitivity',
866
+ 'promote',
867
+ usage,
868
+ );
869
+ } else if (a === '--reason') {
870
+ reason = (rest[++i] ?? '') as string;
871
+ } else if (a.startsWith('--reason=')) {
872
+ reason = a.slice('--reason='.length);
873
+ } else if (a === '--owner') {
874
+ owner = (rest[++i] ?? '') as string;
875
+ } else if (a.startsWith('--owner=')) {
876
+ owner = a.slice('--owner='.length);
877
+ } else if (a === '--review-after') {
878
+ review_after = (rest[++i] ?? '') as string;
879
+ } else if (a.startsWith('--review-after=')) {
880
+ review_after = a.slice('--review-after='.length);
725
881
  } else if (a.startsWith('-')) {
726
882
  return _subError('promote', `unrecognized arguments: ${a}`, usage);
727
883
  } else if (p === null) {
@@ -733,7 +889,7 @@ function _parsePromote(rest: string[]): { path: string; source: string; tier: st
733
889
  if (p === null) {
734
890
  return _subError('promote', 'the following arguments are required: path', usage);
735
891
  }
736
- return { path: p, source, tier, manual };
892
+ return { path: p, source, tier, manual, sensitivity, reason, owner, review_after };
737
893
  }
738
894
 
739
895
  function _parseValidate(rest: string[]): { check_urls: boolean } | number {
@@ -774,20 +930,26 @@ function _parseLeadCheck(rest: string[]): { report: string; strict: boolean } |
774
930
  return { report, strict };
775
931
  }
776
932
 
777
- function _parsePurge(rest: string[]): { confirm: boolean } | number {
778
- const usage = `${_PROG} purge [-h] [--confirm]`;
933
+ function _parsePurge(rest: string[]): { confirm: boolean; reason: string } | number {
934
+ const usage = `${_PROG} purge [-h] [--confirm] [--reason REASON]`;
779
935
  let confirm = false;
780
- for (const a of rest) {
936
+ let reason = '';
937
+ for (let i = 0; i < rest.length; i += 1) {
938
+ const a = rest[i] as string;
781
939
  if (a === '-h' || a === '--help') {
782
940
  return _subHelp(usage);
783
941
  }
784
942
  if (a === '--confirm') {
785
943
  confirm = true;
944
+ } else if (a === '--reason') {
945
+ reason = (rest[++i] ?? '') as string;
946
+ } else if (a.startsWith('--reason=')) {
947
+ reason = a.slice('--reason='.length);
786
948
  } else {
787
949
  return _subError('purge', `unrecognized arguments: ${a}`, usage);
788
950
  }
789
951
  }
790
- return { confirm };
952
+ return { confirm, reason };
791
953
  }
792
954
 
793
955
  function _subHelp(usage: string): number {
@@ -1077,6 +1239,33 @@ function _dateDiffDays(a: YMD, b: YMD): number {
1077
1239
  return Math.round((ta - tb) / 86_400_000);
1078
1240
  }
1079
1241
 
1242
+ /** `YYYY-MM-DD` + N days → `YYYY-MM-DD` (UTC). Invalid input → the input unchanged. */
1243
+ function _addDaysIso(iso: string, days: number): string {
1244
+ const parsed = _strptime(iso);
1245
+ if (parsed === null) {
1246
+ return iso;
1247
+ }
1248
+ const ms = Date.UTC(parsed.y, parsed.m - 1, parsed.d) + days * 86_400_000;
1249
+ const d = new Date(ms);
1250
+ const y = d.getUTCFullYear();
1251
+ const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
1252
+ const dd = String(d.getUTCDate()).padStart(2, '0');
1253
+ return `${y}-${mo}-${dd}`;
1254
+ }
1255
+
1256
+ /** Default `owner` for a promoted card when `--owner` is not given. */
1257
+ function _defaultOwner(): string {
1258
+ try {
1259
+ const name = os.userInfo().username;
1260
+ if (name) {
1261
+ return name;
1262
+ }
1263
+ } catch {
1264
+ // platform without a resolvable user (rare) — fall through
1265
+ }
1266
+ return process.env['USER'] ?? process.env['USERNAME'] ?? 'unknown';
1267
+ }
1268
+
1080
1269
  /** Mirror Python `OSError`'s `str(exc)` for the read-failure message. */
1081
1270
  function _osErrorStr(exc: unknown): string {
1082
1271
  if (exc && typeof exc === 'object') {
Binary file