@event4u/agent-config 14.20.0 → 14.21.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 (100) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +243 -0
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  8. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  9. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  10. package/dist/agent-src/commands/analyze.md +12 -7
  11. package/dist/agent-src/commands/feature/plan.md +55 -16
  12. package/dist/agent-src/commands/feature/refactor.md +8 -3
  13. package/dist/agent-src/commands/humanize.md +48 -4
  14. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  17. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  18. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  19. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  20. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  21. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  23. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  24. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  25. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/features.md +5 -0
  28. package/dist/agent-src/templates/roadmaps.md +15 -0
  29. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  30. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  31. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
  32. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  33. package/dist/cli-delegate/cmd_conformance.js +17 -3
  34. package/dist/cli-delegate/cmd_doctor.js +3 -3
  35. package/dist/cli-delegate/cmd_export.js +2 -2
  36. package/dist/cli-delegate/cmd_handoff.js +1 -1
  37. package/dist/cli-delegate/cmd_prune.js +2 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_sync.js +2 -2
  40. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  41. package/dist/cli-delegate/cmd_validate.js +2 -2
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +56 -25
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +7 -6
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +6 -5
  51. package/dist/hooks/dispatch.js +479 -241
  52. package/dist/install/install.mjs +4 -1
  53. package/dist/mcp/registry-manifest.json +2 -2
  54. package/dist/mcp/server.json +2 -2
  55. package/dist/mcp/server.mjs +313 -63
  56. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  57. package/dist/scripts/ai_council/seat_policy.js +106 -0
  58. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  59. package/llms.txt +14 -14
  60. package/package.json +1 -1
  61. package/src/config/gate-violation-baselines.json +3 -3
  62. package/src/config/release-gate-locality.yml +4 -3
  63. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  64. package/src/scripts/_lib/host_capability.ts +38 -1
  65. package/src/scripts/_lib/release_highlights.ts +19 -211
  66. package/src/scripts/_lib/release_material.ts +27 -77
  67. package/src/scripts/_lib/structured_ask.ts +117 -0
  68. package/src/scripts/_lib/tty_prompt.ts +76 -0
  69. package/src/scripts/ai_council/api_transport.ts +163 -0
  70. package/src/scripts/ai_council/clients.ts +58 -63
  71. package/src/scripts/ai_council/config.ts +13 -0
  72. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  73. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  74. package/src/scripts/ai_council/seat_policy.ts +137 -0
  75. package/src/scripts/ai_council/status_surface.ts +79 -0
  76. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  77. package/src/scripts/ai_tells_rules.ts +446 -5
  78. package/src/scripts/ask_block_census.ts +499 -0
  79. package/src/scripts/bench_humanizer_eval.ts +264 -40
  80. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  81. package/src/scripts/check_branch_work_committed.ts +234 -0
  82. package/src/scripts/check_pack_size.ts +1 -1
  83. package/src/scripts/check_release_highlights.ts +0 -157
  84. package/src/scripts/council_cli.ts +18 -27
  85. package/src/scripts/detect_ai_tells.ts +142 -20
  86. package/src/scripts/harvest_reference_tokens.ts +698 -0
  87. package/src/scripts/hook_manifest.json +1 -1
  88. package/src/scripts/hook_manifest.yaml +31 -1
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  91. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  92. package/src/scripts/install-hooks.sh +52 -0
  93. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  94. package/src/scripts/migrate_command_suggestions.ts +1 -1
  95. package/src/scripts/probe_unblocked_ask.ts +135 -2
  96. package/src/scripts/release.ts +26 -71
  97. package/src/scripts/release_drill.ts +6 -10
  98. package/src/scripts/release_env.ts +1 -0
  99. package/src/scripts/release_publication.ts +34 -29
  100. package/src/scripts/self_review_gate.ts +409 -15
@@ -25,15 +25,7 @@
25
25
  */
26
26
  import { spawnSync } from 'node:child_process';
27
27
 
28
- import {
29
- CURATED_HEAD_INSTRUCTION,
30
- MIX_RESPONSE_MARKER,
31
- MIX_RESPONSE_PLACEHOLDERS,
32
- PROMISE_OUTCOMES,
33
- PROMISE_PHRASE,
34
- PROMISE_READBACK_MARKER,
35
- extract_changelog_section,
36
- } from './release_material.js';
28
+ import { CURATED_HEAD_INSTRUCTION } from './release_material.js';
37
29
 
38
30
  /** The five curated labels, in the order an operator reads them. */
39
31
  export const HEAD_LABELS: readonly string[] = [
@@ -406,7 +398,6 @@ export function publication_blockers(
406
398
  sectionBody: string,
407
399
  version: string,
408
400
  where = '`main`',
409
- mix: MixObligation | null = null,
410
401
  ): string[] {
411
402
  const out: string[] = [];
412
403
  if (sectionBody.includes(DERIVED_MARKER)) {
@@ -423,13 +414,15 @@ export function publication_blockers(
423
414
  'comment line from the section and re-run.',
424
415
  );
425
416
  }
426
- out.push(...mix_response_blockers(sectionBody, version, where, mix));
427
417
  return out;
428
418
  }
429
419
 
430
420
  /**
431
421
  * Everything that makes a whole SECTION unpublishable — the head-level
432
- * blockers above plus the two obligations that only exist for a full section.
422
+ * blockers above plus the tests footer, which only exists for a full section.
423
+ *
424
+ * It was "the two obligations" until 2026-09-07: the governance-versus-product
425
+ * written answer was the other, and ADR-261 deleted it. One remains.
433
426
  *
434
427
  * Two levels and not one, because `publication_blockers` is legitimately called
435
428
  * with a bare curated head (the prefill tests do exactly that), and a head
@@ -450,9 +443,8 @@ export function section_publication_blockers(
450
443
  sectionBody: string,
451
444
  version: string,
452
445
  where = '`main`',
453
- mix: MixObligation | null = null,
454
446
  ): string[] {
455
- const out = publication_blockers(sectionBody, version, where, mix);
447
+ const out = publication_blockers(sectionBody, version, where);
456
448
  if (!TEST_FOOTER_RE.test(sectionBody)) {
457
449
  out.push(
458
450
  `the ${version} section carries no \`Tests: N (+M since PREV)\` footer. ` +
@@ -479,208 +471,24 @@ export function section_publication_blockers(
479
471
  const TEST_FOOTER_RE = /^Tests: \d+/mu;
480
472
 
481
473
  /**
482
- * The measured governance-versus-product level, as the writer and the guards
483
- * both receive it.
484
- *
485
- * A plain record and not the measurement itself: this module is reached by the
486
- * push guard, the pre-commit ask and the CI gate, and only the callers have git
487
- * to measure with. Passing the reading in keeps the predicate pure and keeps
488
- * `measure_release_mix`'s git access out of a module three guards import.
474
+ * The measured governance-versus-product level, as the writer receives it.
475
+ *
476
+ * A plain record and not the measurement itself: only the callers have git to
477
+ * measure with, and keeping `measure_release_mix`'s git access out of this
478
+ * module keeps it importable by the guards.
479
+ *
480
+ * It carried a `triggered` boolean until ADR-261, when the obligation that
481
+ * boolean gated was deleted. It was removed rather than kept with a rationale:
482
+ * the first attempt at this docblock claimed a reader of the published line
483
+ * could see the verdict, and a neutral review falsified that — `triggered` was
484
+ * set once and read nowhere, and `render_mix_response` never received it. The
485
+ * two counts in `level` are what the line publishes, and the taxonomy's own
486
+ * verdict stays where it is computed and printed, in `measure_release_mix`.
489
487
  */
490
488
  export interface MixObligation {
491
- triggered: boolean;
492
489
  level: string;
493
490
  }
494
491
 
495
- /** Minimum written characters after the marker — a bare marker is not an answer. */
496
- export const MIX_RESPONSE_MIN_CHARS = 40;
497
-
498
- /**
499
- * The ONE predicate for the governance-versus-product obligation.
500
- *
501
- * `check_release_highlights` refuses this from the CI side and
502
- * `guard_release_branch_push` from the local side, and until 2026-09-05 only
503
- * the first of the two knew the obligation existed. That asymmetry is what the
504
- * contract itself recorded — *"the earliest refusal for this one obligation is
505
- * the PR, not the push"* (`docs/contracts/CHANGELOG-conventions.md`) — and what
506
- * cost 14.17.0 a red release PR. Both sides now read this function, so a change
507
- * to the obligation cannot reach one guard and miss the other.
508
- *
509
- * `null` means the measurement did not run (shallow clone, missing tag). That
510
- * degrades to no blocker, matching the gate's documented stance: this is a
511
- * governance signal, not a correctness control, and an environment fact must
512
- * not block a release.
513
- */
514
- export function mix_response_blockers(
515
- sectionBody: string,
516
- version: string,
517
- where = '`main`',
518
- mix: MixObligation | null = null,
519
- ): string[] {
520
- if (!mix || !mix.triggered) {
521
- return [];
522
- }
523
- const idx = sectionBody.indexOf(MIX_RESPONSE_MARKER);
524
- if (idx === -1) {
525
- return [
526
- `the ${version} section owes a governance-versus-product response ` +
527
- `(${mix.level}) and carries none. Add one \`> ${MIX_RESPONSE_MARKER}\` line ` +
528
- `immediately under the curated head on ${where}, naming the next cycle's ` +
529
- 'consumer work or a maintainer justification.',
530
- ];
531
- }
532
- const block = mix_response_block(sectionBody, idx);
533
- const left = MIX_RESPONSE_PLACEHOLDERS.filter((t) => block.includes(t));
534
- if (left.length > 0) {
535
- return [
536
- `the ${version} governance-versus-product response still carries the writer's ` +
537
- `placeholder(s) (${left.map((t) => `\`${t}\``).join(', ')}) — the measured level ` +
538
- 'is the generator\'s, the answer is not, and `CHANGELOG.md` is published to npm. ' +
539
- `Name the next cycle's consumer work (or the justification) on ${where} and re-run.`,
540
- ];
541
- }
542
- if (human_answer(block, mix.level).length < MIX_RESPONSE_MIN_CHARS) {
543
- return [
544
- `the ${version} governance-versus-product response carries no written answer ` +
545
- `beyond the measured level (${mix.level}). The level is the generator's; the ` +
546
- `response has to name the next cycle's consumer work or a maintainer ` +
547
- `justification. Write it on ${where}, in the \`> \` block under the marker.`,
548
- ];
549
- }
550
- return [];
551
- }
552
-
553
- /**
554
- * The promise the PREVIOUS release head made, or null when it made none.
555
- *
556
- * Scoped to that section's governance-response block, not to its whole body: a
557
- * product line that happens to contain the phrase is not a promise the project
558
- * made about the next cycle.
559
- */
560
- export function previous_promise(changelogText: string, previousVersion: string): string | null {
561
- const section = extract_changelog_section(changelogText, previousVersion);
562
- if (section === null) return null;
563
- const idx = section.body.indexOf(MIX_RESPONSE_MARKER);
564
- if (idx === -1) return null;
565
- const block = mix_response_block(section.body, idx);
566
- return block.includes(PROMISE_PHRASE) ? block : null;
567
- }
568
-
569
- /**
570
- * The ONE predicate for "did this head answer the previous head's promise?".
571
- *
572
- * The defect: `_lib/release_material.ts` generates a `Next cycle ships …` line
573
- * and nothing has ever read the previous one back, so an unmet promise can be
574
- * restated indefinitely at zero cost — the debt is booked and never called. A
575
- * promise nobody reads is not a commitment, it is a sentence.
576
- *
577
- * Three outcomes are accepted and one of them must be chosen, which is the
578
- * whole mechanism: it does not require the promise to have been KEPT, and no
579
- * ratio, deadline or count is enforced here. Refusing an unkept promise would
580
- * make the honest answer the expensive one and teach an author to promise less
581
- * rather than to answer truthfully.
582
- *
583
- * Length floor and placeholder scan reuse the governance-response discipline
584
- * for the same reason it exists there: a bare marker, or the writer's own
585
- * scaffolding, is not an answer a human wrote.
586
- */
587
- export function promise_readback_blockers(
588
- sectionBody: string,
589
- version: string,
590
- previousVersion: string,
591
- promise: string | null,
592
- where = '`main`',
593
- ): string[] {
594
- if (promise === null) return [];
595
- const idx = sectionBody.indexOf(PROMISE_READBACK_MARKER);
596
- if (idx === -1) {
597
- return [
598
- `the ${version} section does not answer the ${previousVersion} head's ` +
599
- `\`${PROMISE_PHRASE}\` promise. Add one \`> ${PROMISE_READBACK_MARKER}\` line ` +
600
- `immediately under the curated head on ${where}, naming the promise and whether ` +
601
- `it ${PROMISE_OUTCOMES.join(', ')} (the last one with its reason).`,
602
- ];
603
- }
604
- const block = mix_response_block(sectionBody, idx);
605
- const left = MIX_RESPONSE_PLACEHOLDERS.filter((t) => block.includes(t));
606
- if (left.length > 0) {
607
- return [
608
- `the ${version} previous-cycle answer still carries the writer's placeholder(s) ` +
609
- `(${left.map((t) => `\`${t}\``).join(', ')}). \`CHANGELOG.md\` is published to npm. ` +
610
- `Write the outcome on ${where} and re-run.`,
611
- ];
612
- }
613
- const lower = block.toLowerCase();
614
- const named = PROMISE_OUTCOMES.filter((o) => lower.includes(o));
615
- if (named.length === 0) {
616
- return [
617
- `the ${version} previous-cycle answer names no outcome. It has to say which of ` +
618
- `${PROMISE_OUTCOMES.join(', ')} applies to the ${previousVersion} promise — an ` +
619
- 'answer that restates the promise without resolving it is the failure this reads ' +
620
- 'back for.',
621
- ];
622
- }
623
- if (readback_answer(block).length < MIX_RESPONSE_MIN_CHARS) {
624
- return [
625
- `the ${version} previous-cycle answer is shorter than a written answer ` +
626
- `(${String(MIX_RESPONSE_MIN_CHARS)} characters after the marker). Name what was ` +
627
- `promised and what became of it on ${where}, not the outcome word alone.`,
628
- ];
629
- }
630
- return [];
631
- }
632
-
633
- /** What the author wrote under the read-back marker, marker and quoting removed. */
634
- export function readback_answer(block: string): string {
635
- return block
636
- .replace(PROMISE_READBACK_MARKER, '')
637
- .replace(/^[>\s.]+|[>\s.]+$/gu, '')
638
- .replace(/[>\s]+/gu, ' ')
639
- .trim();
640
- }
641
-
642
- /**
643
- * The whole response block: the marker's line plus every blockquote line under it.
644
- *
645
- * Reading only the FIRST line after the marker was wrong in both directions, and
646
- * both were reproduced before this was written. It **accepted** a section whose
647
- * placeholder line had simply been deleted, because the machine-written level
648
- * alone is 54 characters and cleared the floor — the smuggled auto-approval the
649
- * placeholder exists to prevent. And it **refused** the writer's own two-line
650
- * template, where the level sits on line 1 and the answer on line 2.
651
- *
652
- * Scoping to the block also fixes the placeholder scan, which searched the
653
- * entire section body: a legitimate changelog line containing
654
- * `<roadmap or issue>` blocked the release.
655
- */
656
- export function mix_response_block(sectionBody: string, markerIdx: number): string {
657
- const lines = sectionBody.slice(markerIdx).split('\n');
658
- const out: string[] = [lines[0] ?? ''];
659
- for (const line of lines.slice(1)) {
660
- if (!line.trimStart().startsWith('>')) break;
661
- out.push(line);
662
- }
663
- return out.join('\n');
664
- }
665
-
666
- /**
667
- * What is left of the response block once the generator's own contribution is
668
- * removed — i.e. the part a human had to write.
669
- *
670
- * The measured level is stripped because the writer emitted it; a floor that
671
- * counted it would be satisfied by the tool on the author's behalf, which is
672
- * the whole failure this obligation guards against.
673
- */
674
- export function human_answer(block: string, level: string): string {
675
- return block
676
- .replace(MIX_RESPONSE_MARKER, '')
677
- .split(level)
678
- .join('')
679
- .replace(/^[>\s.]+|[>\s.]+$/gu, '')
680
- .replace(/[>\s]+/gu, ' ')
681
- .trim();
682
- }
683
-
684
492
  /** Labels whose curated value is still the generator's unedited draft. */
685
493
  export function stale_draft_labels(
686
494
  curated: Readonly<Record<string, string>>,
@@ -300,95 +300,45 @@ function _first_diff(a: string, b: string): string {
300
300
  * a branch, a PR and a CI run — the exact cost `guard_release_branch_push` was
301
301
  * added to avoid for the sibling obligation two releases earlier.
302
302
  *
303
- * This module has no imports on purpose (see `CURATED_HEAD_INSTRUCTION`), which
304
- * is what lets the writer, the local push guard and the CI gate all reach one
305
- * definition instead of three copies.
303
+ * This module has no imports on purpose (see `CURATED_HEAD_INSTRUCTION`).
304
+ *
305
+ * **One reader left, as of ADR-261 (2026-09-07).** This paragraph used to say
306
+ * the definition was shared by "the writer, the local push guard and the CI
307
+ * gate", which was true while `mix_response_blockers` and
308
+ * `check_governance_mix_response` scanned a section for this marker. Both are
309
+ * deleted, and with them every search for it: the only consumer now is
310
+ * `render_mix_response` in this same file, which WRITES the line. Nothing reads
311
+ * a changelog looking for it, so do not assume a guard notices when it is
312
+ * missing — none does.
306
313
  */
307
314
  export const MIX_RESPONSE_MARKER = '**Governance mix:**';
308
315
 
309
316
  /**
310
- * The sentinel that marks an EMITTED-BUT-UNANSWERED response.
311
- *
312
- * A generator that emits the measured level alone would satisfy the gate's
313
- * length floor (`MIX_RESPONSE_MIN_CHARS`, 40) with a number the machine
314
- * produced, turning a written-answer obligation into a formality the tool
315
- * discharges for itself. That is a smuggled auto-approval, not a fix.
317
+ * Render the measured governance-versus-product line the writer emits.
316
318
  *
317
- * So the writer emits the level AND this sentinel, and every gate refuses the
318
- * sentinel — the same discipline `CURATED_HEAD_INSTRUCTION` and
319
- * `DERIVED_MARKER` already follow. The net effect is only that the obligation
320
- * moves from CI to the moment the section is written, where the fix is an edit
321
- * to a file already open in the working tree.
322
- */
323
- export const MIX_RESPONSE_PLACEHOLDER = '<the consumer work>';
324
-
325
- /**
326
- * Every placeholder token the writer emits, so none of them can ship.
319
+ * ONE line, and machine-written end to end. It reports what the span measured
320
+ * and asks the releaser for nothing.
327
321
  *
328
- * The block carries two, and refusing only the first would leave
329
- * `<roadmap or issue>` publishable — `CHANGELOG.md` is in `package.json`
330
- * `files`, so that is the same "the generator's own scaffolding reached npm"
331
- * failure `CURATED_HEAD_INSTRUCTION` exists for, one token to the right.
322
+ * The written-answer obligation is gone (ADR-261). This used to emit a second line — `Next cycle ships <the consumer work>,
323
+ * tracked in <roadmap or issue>.` — a placeholder no generator was allowed to
324
+ * fill, backed by four refusals, an interactive prompt, a staging channel under
325
+ * `## [Unreleased]` and a read-back obligation on the following release. The
326
+ * releaser had to type prose into a running pipeline to get a version out.
332
327
  *
333
- * A named list rather than a shape match over angle brackets, for the reason
334
- * that constant already states: a pattern such as `/<[a-z ]+>/` rejects
335
- * unrelated legitimate prose and misses a reworded placeholder. Adding a token
336
- * to `render_mix_response` means adding it here.
337
- */
338
- export const MIX_RESPONSE_PLACEHOLDERS: readonly string[] = [
339
- MIX_RESPONSE_PLACEHOLDER,
340
- '<roadmap or issue>',
341
- ];
342
-
343
- /**
344
- * Render the response block the writer emits when the obligation triggers.
328
+ * The owner ended it: this package exists to make the maintainer's work
329
+ * cheaper, and a release gate that stops a green pipeline until a human writes
330
+ * a sentence about a FUTURE cycle is the opposite. The measurement stays
331
+ * because it is free and true; the homework does not.
345
332
  *
346
- * Two blockquote lines, immediately under the curated head and outside it, per
347
- * `docs/contracts/CHANGELOG-conventions.md` § Governance-versus-product
348
- * response — the head's ten-line cap is for product lines, and a sixth label
349
- * would make every historical section retroactively incomplete.
333
+ * The removed placeholder constants (`MIX_RESPONSE_PLACEHOLDER(S)`) and promise
334
+ * sentinels (`PROMISE_PHRASE`, `PROMISE_READBACK_MARKER`, `PROMISE_OUTCOMES`)
335
+ * are deleted rather than deprecated — a sentinel nothing writes and nothing
336
+ * refuses is dead weight that reads as live policy.
350
337
  */
351
338
  export function render_mix_response(level: string): string[] {
352
- return [
353
- `> ${MIX_RESPONSE_MARKER} ${level}.`,
354
- `> ${PROMISE_PHRASE} ${MIX_RESPONSE_PLACEHOLDER}, tracked in <roadmap or issue>.`,
355
- ];
339
+ return [`> ${MIX_RESPONSE_MARKER} ${level}.`];
356
340
  }
357
341
 
358
- /**
359
- * The words that turn a governance response into a PROMISE about a later cycle.
360
- *
361
- * Named rather than inlined because two sides now read it: the writer emits it,
362
- * and the next release reads the previous section for it to decide whether a
363
- * read-back is owed. A promise nothing reads back costs nothing to restate, so
364
- * it can be restated for as many cycles as there are cycles — the defect this
365
- * constant exists to make addressable.
366
- */
367
- export const PROMISE_PHRASE = 'Next cycle ships';
368
-
369
- /**
370
- * The marker under which a release head answers the PREVIOUS head's promise.
371
- *
372
- * A named sentinel, on the same discipline as `MIX_RESPONSE_MARKER` and
373
- * `DERIVED_MARKER`: a shape match over prose would accept a sentence that
374
- * happens to mention last cycle and reject a reworded answer.
375
- */
376
- export const PROMISE_READBACK_MARKER = '**Previous cycle:**';
377
-
378
- /**
379
- * The three outcomes a read-back may report, and the reason there are exactly
380
- * three.
381
- *
382
- * `shipped` and `did not ship` are the honest halves of the fact. `withdrawn`
383
- * exists because a promise the project decided not to keep is a legitimate
384
- * third state — but only WITH a reason, which is why the answer also has to
385
- * clear a written-length floor rather than being one of these three words on
386
- * its own. Without the third state the mechanism would push an author toward
387
- * restating a dead promise as still-outstanding, which is the failure it is
388
- * built to stop.
389
- */
390
- export const PROMISE_OUTCOMES: readonly string[] = ['shipped', 'did not ship', 'withdrawn'];
391
-
392
342
  /** Every `## X.Y.Z` release version in the file, in document order (newest first). */
393
343
  export function changelog_versions(text: string): string[] {
394
344
  const out: string[] = [];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The structured-ask surface — one shared definition of "a host's own question
3
+ * tool", read by every consumer that needs it.
4
+ *
5
+ * Three consumers exist and they must not each invent their own answer:
6
+ *
7
+ * - `probe_unblocked_ask.ts` partitions hand-back ask turns into `text` and
8
+ * `native`, where `native` means the turn carried a call to such a tool.
9
+ * - `hooks/one_question_per_ask_hook.ts` denies a call carrying more than one
10
+ * question.
11
+ * - `_lib/host_capability.ts` carries the boolean `structured_ask` capability;
12
+ * the per-host SHAPE lives here rather than in the manifest, because every
13
+ * manifest field is a strict boolean that `asBool` coerces — a nested object
14
+ * put there would normalize silently to `false`.
15
+ *
16
+ * What is observed, and what is a shape guess — the line is drawn explicitly.
17
+ *
18
+ * `STRUCTURED_ASK_SHAPES` is **empty**. No host in this repository's registry
19
+ * carries an observed structured-ask tool, so there is no per-host tool name to
20
+ * match on, and inventing one from a vendor's documentation is exactly what
21
+ * `host-capability-manifest.md` § Observation protocol forbids.
22
+ *
23
+ * `STRUCTURED_ASK_TOOL_NAME_RE` is therefore a **name-shape pattern, not a host
24
+ * fact**. It exists so that if such a tool ever appears in a transcript, the
25
+ * probe MEASURES it rather than missing it — and so the number it reports today
26
+ * (zero) is a measurement rather than an absence of instrumentation. A reader
27
+ * must not read a match as evidence that a named host ships that tool; the only
28
+ * thing that establishes that is an observation written into
29
+ * `STRUCTURED_ASK_SHAPES` with the protocol's four-part citation.
30
+ *
31
+ * Consequence, stated rather than discovered later: `native` is expected to be
32
+ * **0** on every corpus this repo can scan today. That is the honest reading of
33
+ * a capability nobody has observed, not a broken detector.
34
+ */
35
+
36
+ /** The per-host shape of a structured-ask tool, once one has been OBSERVED. */
37
+ export interface StructuredAskShape {
38
+ /** The tool name as it appears in a transcript's `tool_use` block. */
39
+ readonly tool: string;
40
+ /** How many questions one call may carry. `1` is the shape this repo wants. */
41
+ readonly max_questions: number;
42
+ /** How many options one question may offer. */
43
+ readonly max_options_per_question: number;
44
+ /** Whether the host's picker admits a free-text answer alongside the options. */
45
+ readonly free_text: boolean;
46
+ }
47
+
48
+ /**
49
+ * Observed per-host structured-ask shapes.
50
+ *
51
+ * EMPTY, deliberately. A row is written only from an observation in a real
52
+ * session under `contexts/execution/host-capability-manifest.md` § Observation
53
+ * protocol — host · host version · transcript reference · date. Never from a
54
+ * host's documentation, and never by analogy to another host.
55
+ */
56
+ export const STRUCTURED_ASK_SHAPES: Readonly<Record<string, StructuredAskShape>> = {};
57
+
58
+ /** The observed shape for `hostId`, or `undefined` when none has been observed. */
59
+ export function structuredAskShape(
60
+ hostId: string | null | undefined,
61
+ ): StructuredAskShape | undefined {
62
+ if (hostId === null || hostId === undefined) {
63
+ return undefined;
64
+ }
65
+ return STRUCTURED_ASK_SHAPES[hostId];
66
+ }
67
+
68
+ /**
69
+ * Name-SHAPE pattern for a structured-ask tool. See the module header: this is
70
+ * a matcher, not a claim about any host.
71
+ */
72
+ export const STRUCTURED_ASK_TOOL_NAME_RE = /^(ask[_-]?user[_-]?question|user[_-]?question|ask[_-]?question)s?$/i;
73
+
74
+ /**
75
+ * Is `name` a structured-ask tool call?
76
+ *
77
+ * An observed per-host shape wins outright — that is a fact about the host. The
78
+ * name-shape pattern is the fallback, and it is why the probe can report a
79
+ * measured zero instead of nothing at all.
80
+ */
81
+ export function isStructuredAskTool(name: string, hostId?: string | null): boolean {
82
+ const shape = structuredAskShape(hostId);
83
+ if (shape !== undefined) {
84
+ return name === shape.tool;
85
+ }
86
+ return STRUCTURED_ASK_TOOL_NAME_RE.test(name);
87
+ }
88
+
89
+ /**
90
+ * How many questions a structured-ask tool payload carries.
91
+ *
92
+ * Returns `-1` when the payload's shape is not recognised at all — a caller
93
+ * that gates on this must treat `-1` as "cannot tell", never as "one". The two
94
+ * recognised shapes are the only ones a picker can plausibly use: an array of
95
+ * question objects under a `questions`-like key, or a single question field.
96
+ *
97
+ * A `questions` array of length 0 is 0, not 1: an empty ask is malformed and a
98
+ * gate should be able to say so.
99
+ */
100
+ export function countStructuredAskQuestions(input: unknown): number {
101
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
102
+ return -1;
103
+ }
104
+ const src = input as Record<string, unknown>;
105
+ for (const key of ['questions', 'question_list', 'items', 'prompts']) {
106
+ const v = src[key];
107
+ if (Array.isArray(v)) {
108
+ return v.length;
109
+ }
110
+ }
111
+ for (const key of ['question', 'prompt', 'header']) {
112
+ if (typeof src[key] === 'string' && (src[key] as string).trim() !== '') {
113
+ return 1;
114
+ }
115
+ }
116
+ return -1;
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Synchronous terminal prompting for the release pipeline.
3
+ *
4
+ * Its own module because `release.ts` sits 371 lines over the 1500-line source
5
+ * ratchet, where every added line is one unit of excess — and because a SECOND
6
+ * caller now needs these: `guard_release_curation` in `release_publication.ts`
7
+ * collects the governance answer it used to only refuse over.
8
+ *
9
+ * Extracted verbatim from `release.ts`, where the `/dev/tty` discipline below
10
+ * was established against a reproduced failure. Do not "simplify" it back to
11
+ * `readSync(0)`; the comment on `promptLine` says what that costs.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import process from 'node:process';
15
+
16
+ /** Can we prompt at all — is fd 0 a TTY, or is a controlling terminal openable? */
17
+ export function canPrompt(): boolean {
18
+ if (process.env.CI) return false; // CI is non-interactive by contract → require --yes
19
+ if (process.stdin.isTTY) return true;
20
+ try {
21
+ const fd = fs.openSync('/dev/tty', 'r');
22
+ fs.closeSync(fd);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Mirror of Python `input(prompt)` — write the prompt, read one line from the
31
+ * controlling terminal. Returns `null` when no terminal is reachable.
32
+ *
33
+ * The answer is ALWAYS read from a freshly opened, blocking `/dev/tty`
34
+ * (`openSync(..., 'rs')`), NEVER from fd 0. `task release` / `./scripts-run`
35
+ * run this under go-task's `interactive: true`, which leaves fd 0 a TTY
36
+ * (`process.stdin.isTTY === true`) but one Node treats as NON-blocking — so a
37
+ * bare `readSync(0)` throws `EAGAIN`, the catch swallows it, and the `[y/N]`
38
+ * prompt "auto-aborts" without ever waiting (the exact failure this fixes;
39
+ * reproduced with `isTTY === true`). A fresh blocking `/dev/tty` descriptor
40
+ * blocks for real input regardless of how the script was invoked — the proven
41
+ * synchronous-prompt pattern.
42
+ */
43
+ export function promptLine(prompt: string): string | null {
44
+ process.stdout.write(prompt);
45
+ let fd: number;
46
+ try {
47
+ // 'rs' → O_RDONLY | O_SYNC: a fresh, blocking descriptor on the
48
+ // controlling terminal, unaffected by Node's non-blocking fd 0.
49
+ fd = fs.openSync('/dev/tty', 'rs');
50
+ } catch {
51
+ return null; // no controlling terminal (true non-interactive)
52
+ }
53
+ try {
54
+ const buf = Buffer.alloc(1);
55
+ const chars: number[] = [];
56
+ for (;;) {
57
+ let bytesRead: number;
58
+ try {
59
+ bytesRead = fs.readSync(fd, buf, 0, 1, null);
60
+ } catch (e) {
61
+ const code = (e as NodeJS.ErrnoException).code;
62
+ // A blocking /dev/tty should not yield these, but inherited
63
+ // descriptor flags can — retry rather than abort the prompt.
64
+ if (code === 'EAGAIN' || code === 'EINTR') continue;
65
+ break; // EOF / EIO → EOFError analogue; return what we have.
66
+ }
67
+ if (bytesRead === 0) break;
68
+ const b = buf[0] as number;
69
+ if (b === 0x0a) break; // newline terminates the line (stripped, like input()).
70
+ chars.push(b);
71
+ }
72
+ return Buffer.from(chars).toString('utf-8');
73
+ } finally {
74
+ fs.closeSync(fd);
75
+ }
76
+ }