@holmes-lab/holmes-kit 0.19.6 → 0.20.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 (128) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +8 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  13. package/dist/holmes/cli/gitignore-merge.js +6 -2
  14. package/dist/holmes/cli/index.js +176 -139
  15. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  16. package/dist/holmes/cli/playbook-skills.js +10 -54
  17. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  18. package/dist/holmes/governance/approval-grants.js +55 -1
  19. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  20. package/dist/holmes/governance/approval-queue.js +86 -12
  21. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  22. package/dist/holmes/governance/display-id-aliases.js +86 -0
  23. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  24. package/dist/holmes/governance/ledger-store.js +49 -3
  25. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  26. package/dist/holmes/governance/ledger-timeline.js +11 -1
  27. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  28. package/dist/holmes/governance/provenance-chain.js +13 -2
  29. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  30. package/dist/holmes/governance/provenance-ledger.js +69 -16
  31. package/dist/holmes/governance/session-context.d.ts +3 -0
  32. package/dist/holmes/governance/session-context.js +26 -8
  33. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  34. package/dist/holmes/guardrail/write-target.js +2 -1
  35. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  36. package/dist/holmes/hooks/stop.js +14 -3
  37. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  38. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  39. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  40. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  41. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  42. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  43. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  44. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  45. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  46. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  47. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  48. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  49. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  50. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  51. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  52. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  53. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  54. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  55. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  56. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  57. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  58. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  59. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  60. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  61. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
  62. package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
  63. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  64. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  65. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  66. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  67. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  68. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  69. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  70. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  71. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  72. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  73. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  74. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  75. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  76. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  77. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  78. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  79. package/dist/holmes/mcp/handlers.d.ts +653 -424
  80. package/dist/holmes/mcp/handlers.js +267 -3250
  81. package/dist/holmes/mcp/server.js +2 -1
  82. package/dist/holmes/mcp/tool-schemas.js +43 -2
  83. package/dist/holmes/project/execution-context.d.ts +17 -0
  84. package/dist/holmes/project/execution-context.js +120 -0
  85. package/dist/holmes/project/installer-markers.d.ts +34 -0
  86. package/dist/holmes/project/installer-markers.js +65 -0
  87. package/dist/holmes/project/root.d.ts +3 -1
  88. package/dist/holmes/project/root.js +4 -3
  89. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  90. package/dist/holmes/project/workspace-identity.js +181 -0
  91. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  92. package/dist/holmes/review/test-outcomes.js +23 -5
  93. package/dist/holmes/review/test-runner.d.ts +18 -0
  94. package/dist/holmes/review/test-runner.js +132 -4
  95. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  96. package/dist/holmes/rtm/graph-store.js +244 -3
  97. package/dist/holmes/rtm/incremental.d.ts +1 -0
  98. package/dist/holmes/rtm/incremental.js +12 -3
  99. package/dist/holmes/rtm/localize.js +7 -0
  100. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  101. package/dist/holmes/rtm/rtm-graph.js +13 -0
  102. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  103. package/dist/holmes/semantic/vector-cache.js +155 -19
  104. package/dist/holmes/spec/approval-status.d.ts +10 -0
  105. package/dist/holmes/spec/approval-status.js +7 -3
  106. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  107. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  108. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  109. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  110. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  111. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  112. package/dist/holmes/spec/entity-integration.d.ts +207 -0
  113. package/dist/holmes/spec/entity-integration.js +747 -0
  114. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  115. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  116. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  117. package/dist/holmes/spec/entity-renumber.js +156 -0
  118. package/dist/holmes/spec/entity-store.d.ts +135 -0
  119. package/dist/holmes/spec/entity-store.js +1051 -0
  120. package/dist/holmes/spec/entity-transaction.d.ts +89 -0
  121. package/dist/holmes/spec/entity-transaction.js +701 -0
  122. package/dist/holmes/spec/renumber.d.ts +58 -0
  123. package/dist/holmes/spec/renumber.js +200 -1
  124. package/dist/holmes/spec/spec-store.d.ts +3 -2
  125. package/dist/holmes/spec/spec-store.js +23 -2
  126. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  127. package/dist/holmes/spec/transition-policy.js +135 -0
  128. package/package.json +1 -1
@@ -37,7 +37,8 @@ exports.isBrokenPipe = void 0;
37
37
  exports.packageRoot = packageRoot;
38
38
  exports.main = main;
39
39
  exports.installPipeGuard = installPipeGuard;
40
- // @implements A-SPEC-591
40
+ // @implements A-SPEC-591, A-SPEC-626
41
+ const cli_execution_1 = require("./cli-execution");
41
42
  // @implements A-SPEC-100.2
42
43
  // @implements A-SPEC-213
43
44
  // @implements A-SPEC-215
@@ -79,7 +80,7 @@ const KNOWN_FLAGS = {
79
80
  skills: ['help', 'target'],
80
81
  ci: ['help', 'target', 'specs-dir', 'json'],
81
82
  serve: ['help', 'target', 'port'],
82
- approve: ['help', 'target', 'list', 'refusals', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale', 'watch', 'poll-ms'],
83
+ approve: ['help', 'target', 'list', 'status', 'refusals', 'grant', 'deny', 'ask', 'revoke', 'reason', 'question', 'ttl', 'rationale', 'watch', 'poll-ms'], // A-SPEC-649: --revoke · A-SPEC-650: --status
83
84
  ledger: ['help', 'target', 'ref', 'dry-run'],
84
85
  // @implements A-SPEC-477 — the human's opt-in act for the cloud semantic tier.
85
86
  'semantic-key': ['help'],
@@ -101,10 +102,12 @@ const APPROVE_USAGE = `holmes-kit approve — decide pending approval requests (
101
102
  interactive (TTY): per item [a] approve [e] approve with reason/duration [v] details [d] deny [q] ask [s] skip [A] approve all remaining
102
103
  --watch resident mode — decide as requests arrive, wait when the queue is empty (Ctrl-C to exit, --poll-ms <ms>)
103
104
  --list print the pending list only
105
+ --status the whole picture: workspace, delegation, who asked and how risky, recent outcomes
104
106
  --grant <ref> issue a narrow grant for that request (--ttl <minutes>, --rationale <text>)
105
107
  ref = list number (1,2,…) | id | id prefix — no need to retype a long id
106
108
  --deny <id> --reason <text> deny — the reason rides the agent's next refusal
107
109
  --ask <id> --question <text> leave a question and hold — the agent relays it to the user
110
+ --revoke <id> --reason <text> withdraw a live grant — the request must be decided again
108
111
  --target <dir> project root (default: current directory)
109
112
  `;
110
113
  class UnknownFlagError extends Error {
@@ -115,7 +118,7 @@ class UnknownFlagError extends Error {
115
118
  * what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
116
119
  * --force). A boolean flag must never consume a token.
117
120
  */
118
- const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'watch', 'autonomy', 'no-autonomy']);
121
+ const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'status', 'watch', 'autonomy', 'no-autonomy']); // A-SPEC-650: --status
119
122
  function parseFlags(argv, known) {
120
123
  const flags = {};
121
124
  const positionals = [];
@@ -209,9 +212,11 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
209
212
  holmes-kit approve review pending approval requests (HITL)
210
213
  (no flags, TTY) interactive: [a]approve [e]approve-with-terms [v]details [d]deny [q]ask [s]skip [A]approve-all
211
214
  --list print the pending queue and exit
215
+ --status workspace · delegation · per-request risk/run/subject · recent outcomes
212
216
  --grant <ref> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
213
217
  --deny <ref> --reason <text> record a denial the agent will see
214
218
  --ask <ref> --question <text> hold with a question the agent relays
219
+ --revoke <id> --reason <text> withdraw a live grant (full id or unique prefix)
215
220
  <ref> list index (1,2,…), full id, or a unique id prefix
216
221
  --target <dir> project root (default: cwd)
217
222
 
@@ -331,13 +336,15 @@ async function main(argv) {
331
336
  const { runAutonomy } = await Promise.resolve().then(() => __importStar(require('./autonomy')));
332
337
  const root = path.resolve(typeof flags.target === 'string' ? flags.target : process.cwd());
333
338
  const forStr = typeof flags.for === 'string' ? flags.for : undefined;
334
- const r = runAutonomy(root, positionals[0], forStr, {
335
- now: new Date().toISOString(),
336
- isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY,
337
- approval: readApproval(),
339
+ return (0, cli_execution_1.runOperatorExecution)(root, () => {
340
+ const r = runAutonomy(root, positionals[0], forStr, {
341
+ now: new Date().toISOString(),
342
+ isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY,
343
+ approval: readApproval(),
344
+ });
345
+ process[r.ok ? 'stdout' : 'stderr'].write(`${r.message}\n`);
346
+ return r.exitCode;
338
347
  });
339
- process[r.ok ? 'stdout' : 'stderr'].write(`${r.message}\n`);
340
- return r.exitCode;
341
348
  }
342
349
  if (cmd === 'doctor') {
343
350
  // A nonexistent target read as "healthy but unwired" and both advised remedies then refused it
@@ -799,158 +806,188 @@ async function main(argv) {
799
806
  process.stdout.write(APPROVE_USAGE);
800
807
  return 0;
801
808
  }
802
- if (typeof flags.grant === 'string') {
803
- const opts = { actor };
804
- if (typeof flags.ttl === 'string')
805
- opts.ttlMinutes = Number(flags.ttl);
806
- if (typeof flags.rationale === 'string')
807
- opts.rationale = flags.rationale;
808
- const ref = resolveRef(flags.grant);
809
- if ('refusal' in ref) {
810
- process.stdout.write(`\u2717 ${ref.refusal}\n`);
811
- return 1;
809
+ return (0, cli_execution_1.runOperatorExecution)(root, async () => {
810
+ if (typeof flags.grant === 'string') {
811
+ const opts = { actor };
812
+ if (typeof flags.ttl === 'string')
813
+ opts.ttlMinutes = Number(flags.ttl);
814
+ if (typeof flags.rationale === 'string')
815
+ opts.rationale = flags.rationale;
816
+ const ref = resolveRef(flags.grant);
817
+ if ('refusal' in ref) {
818
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
819
+ return 1;
820
+ }
821
+ const r = grantRequest(root, ref.id, opts);
822
+ process.stdout.write(r.ok ? `✓ approved — ${ref.subject('✓ approved — ')}\n${ref.detail(`valid until ${r.expires}`)}\n` : `✗ ${r.reason}\n`);
823
+ return r.ok ? 0 : 1;
812
824
  }
813
- const r = grantRequest(root, ref.id, opts);
814
- process.stdout.write(r.ok ? `✓ approved — ${ref.subject('✓ approved ')}\n${ref.detail(`valid until ${r.expires}`)}\n` : `✗ ${r.reason}\n`);
815
- return r.ok ? 0 : 1;
816
- }
817
- if (typeof flags.deny === 'string') {
818
- const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
819
- if (reason === '') {
820
- process.stderr.write(' --deny requires --reason — a denial with no reason is invisible to the agent\n');
821
- return 1;
825
+ if (typeof flags.deny === 'string') {
826
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
827
+ if (reason === '') {
828
+ process.stderr.write('✗ --deny requires --reason — a denial with no reason is invisible to the agent\n');
829
+ return 1;
830
+ }
831
+ const ref = resolveRef(flags.deny);
832
+ if ('refusal' in ref) {
833
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
834
+ return 1;
835
+ }
836
+ const r = denyRequest(root, ref.id, reason, actor);
837
+ process.stdout.write(r.ok ? `✓ denied — ${ref.subject('✓ denied — ')}\n${ref.detail('the denial was recorded')}\n` : `✗ ${r.reason}\n`);
838
+ return r.ok ? 0 : 1;
822
839
  }
823
- const ref = resolveRef(flags.deny);
824
- if ('refusal' in ref) {
825
- process.stdout.write(`\u2717 ${ref.refusal}\n`);
826
- return 1;
840
+ // @implements A-SPEC-649 — the operator withdraws a live grant. A granted id is no longer
841
+ // pending, so it resolves against the DECIDED ids (full id or a unique prefix), never a list index.
842
+ if (typeof flags.revoke === 'string') {
843
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
844
+ if (reason === '') {
845
+ process.stderr.write('✗ --revoke requires --reason — a revocation with no reason is invisible to the agent\n');
846
+ return 1;
847
+ }
848
+ const { revokeGrant } = require('./approve');
849
+ const ref = String(flags.revoke).trim();
850
+ const granted = Object.entries(readQueue(root, { includeAllKinds: true }).decisions).filter(([, d]) => d.event === 'granted').map(([id]) => id);
851
+ const matches = granted.includes(ref) ? [ref] : granted.filter((id) => id.startsWith(ref));
852
+ if (matches.length !== 1) {
853
+ process.stdout.write(`\u2717 ${matches.length === 0 ? `no granted request matches ${ref}` : `${ref} matches ${matches.length} granted requests — use the full id`}\n`);
854
+ return 1;
855
+ }
856
+ const r = revokeGrant(root, matches[0], reason, actor);
857
+ process.stdout.write(r.ok ? `✓ revoked — ${matches[0]}\n the grant is withdrawn; the agent's next attempt is refused and re-filed\n` : `✗ ${r.reason}\n`);
858
+ return r.ok ? 0 : 1;
827
859
  }
828
- const r = denyRequest(root, ref.id, reason, actor);
829
- process.stdout.write(r.ok ? `✓ denied — ${ref.subject('✓ denied ')}\n${ref.detail('the denial was recorded')}\n` : `✗ ${r.reason}\n`);
830
- return r.ok ? 0 : 1;
831
- }
832
- if (typeof flags.ask === 'string') {
833
- const question = typeof flags.question === 'string' ? flags.question.trim() : '';
834
- if (question === '') {
835
- process.stderr.write(' --ask requires --question\n');
836
- return 1;
860
+ if (typeof flags.ask === 'string') {
861
+ const question = typeof flags.question === 'string' ? flags.question.trim() : '';
862
+ if (question === '') {
863
+ process.stderr.write('✗ --ask requires --question\n');
864
+ return 1;
865
+ }
866
+ const ref = resolveRef(flags.ask);
867
+ if ('refusal' in ref) {
868
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
869
+ return 1;
870
+ }
871
+ const r = holdRequest(root, ref.id, question, actor);
872
+ process.stdout.write(r.ok ? `✓ held — ${ref.subject('✓ held — ')}\n${ref.detail('the question rides the next refusal')}\n` : `✗ ${r.reason}\n`);
873
+ return r.ok ? 0 : 1;
837
874
  }
838
- const ref = resolveRef(flags.ask);
839
- if ('refusal' in ref) {
840
- process.stdout.write(`\u2717 ${ref.refusal}\n`);
841
- return 1;
875
+ // @implements A-SPEC-507.1 — stale folding is a LIST-VIEW judgment: the clock enters only
876
+ // here, never on gate-path folds. `--stale-hours 0` disables it; `--all` unfolds.
877
+ const { staleView } = require('./approve');
878
+ const stale = staleView(flags);
879
+ const queueOpts = stale ? { now: Date.now(), ttlMs: stale.ttlMs } : undefined;
880
+ const listView = stale ? { staleHours: stale.ttlMs / 3_600_000, showAll: stale.showAll } : undefined;
881
+ if (flags.list) {
882
+ process.stdout.write(renderPending(readQueue(root, queueOpts), listView) + '\n');
883
+ return 0;
842
884
  }
843
- const r = holdRequest(root, ref.id, question, actor);
844
- process.stdout.write(r.ok ? `✓ held — ${ref.subject('✓ held — ')}\n${ref.detail('the question rides the next refusal')}\n` : `✗ ${r.reason}\n`);
845
- return r.ok ? 0 : 1;
846
- }
847
- // @implements A-SPEC-507.1 — stale folding is a LIST-VIEW judgment: the clock enters only
848
- // here, never on gate-path folds. `--stale-hours 0` disables it; `--all` unfolds.
849
- const { staleView } = require('./approve');
850
- const stale = staleView(flags);
851
- const queueOpts = stale ? { now: Date.now(), ttlMs: stale.ttlMs } : undefined;
852
- const listView = stale ? { staleHours: stale.ttlMs / 3_600_000, showAll: stale.showAll } : undefined;
853
- if (flags.list) {
854
- process.stdout.write(renderPending(readQueue(root, queueOpts), listView) + '\n');
855
- return 0;
856
- }
857
- // @implements A-SPEC-563.2 — the read-only refusal view (local log; raw targets stay on-screen).
858
- if (flags.refusals !== undefined) {
859
- const { renderRefusals } = require('./approve');
860
- const { readRefusals } = require('../governance/approval-queue');
861
- const limit = typeof flags.refusals === 'string' ? Number(flags.refusals) : 20;
862
- if (!Number.isInteger(limit) || limit <= 0) {
863
- process.stderr.write(`✗ --refusals expects a positive integer count — received: ${flags.refusals}\n`);
864
- return 1;
885
+ // @implements A-SPEC-650 the whole picture, read-only; the list above stays script-clean.
886
+ if (flags.status) {
887
+ const { renderStatus, statusContext } = require('./approve-status');
888
+ process.stdout.write(renderStatus(readQueue(root), statusContext(root)) + '\n');
889
+ return 0;
865
890
  }
866
- process.stdout.write(renderRefusals(readRefusals(root), limit) + '\n');
867
- return 0;
868
- }
869
- // @implements A-SPEC-262.2 the RESIDENT surface. Validate --poll-ms first (so a bad value is
870
- // named even on a non-TTY), then refuse a non-TTY (a resident interactive loop needs a terminal),
871
- // then run the loop. SIGINT and readline close both abort it — the operator leaving, either way,
872
- // ends the watch at once and exit 0; pending items stay in the queue.
873
- if (flags.watch === true) {
874
- let pollMs;
875
- if (typeof flags['poll-ms'] === 'string') {
876
- pollMs = Number(flags['poll-ms']);
877
- if (!Number.isInteger(pollMs) || pollMs <= 0) {
878
- process.stderr.write(`✗ --poll-ms must be a positive integer (milliseconds) — received: ${flags['poll-ms']}\n`);
891
+ // @implements A-SPEC-563.2 — the read-only refusal view (local log; raw targets stay on-screen).
892
+ if (flags.refusals !== undefined) {
893
+ const { renderRefusals } = require('./approve');
894
+ const { readRefusals } = require('../governance/approval-queue');
895
+ const limit = typeof flags.refusals === 'string' ? Number(flags.refusals) : 20;
896
+ if (!Number.isInteger(limit) || limit <= 0) {
897
+ process.stderr.write(`✗ --refusals expects a positive integer count received: ${flags.refusals}\n`);
898
+ return 1;
899
+ }
900
+ process.stdout.write(renderRefusals(readRefusals(root), limit) + '\n');
901
+ return 0;
902
+ }
903
+ // @implements A-SPEC-262.2 — the RESIDENT surface. Validate --poll-ms first (so a bad value is
904
+ // named even on a non-TTY), then refuse a non-TTY (a resident interactive loop needs a terminal),
905
+ // then run the loop. SIGINT and readline close both abort it — the operator leaving, either way,
906
+ // ends the watch at once and exit 0; pending items stay in the queue.
907
+ if (flags.watch === true) {
908
+ let pollMs;
909
+ if (typeof flags['poll-ms'] === 'string') {
910
+ pollMs = Number(flags['poll-ms']);
911
+ if (!Number.isInteger(pollMs) || pollMs <= 0) {
912
+ process.stderr.write(`✗ --poll-ms must be a positive integer (milliseconds) — received: ${flags['poll-ms']}\n`);
913
+ return 1;
914
+ }
915
+ }
916
+ if (!process.stdin.isTTY) {
917
+ process.stderr.write('✗ interactive resident mode (--watch) requires a TTY — use --list in scripts\n');
879
918
  return 1;
880
919
  }
920
+ // @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
921
+ // Enter, no deadline) and pings the OS on each new arrival. Raw mode is restored on the way
922
+ // out whatever happens; Ctrl-C arrives as \x03 through decideByKey and ends the loop.
923
+ const { runWatch } = require('./approve-watch');
924
+ const { notifyQueueArrival } = require('./os-notify');
925
+ const ac = new AbortController();
926
+ const onSig = () => ac.abort();
927
+ process.once('SIGINT', onSig);
928
+ const stdin = process.stdin;
929
+ try {
930
+ stdin.setRawMode?.(true);
931
+ stdin.resume();
932
+ await runWatch(root, {
933
+ print: (t) => process.stdout.write(t + '\n'),
934
+ ask: async () => '', // unused on the key surface; kept for the ApproveIO shape
935
+ }, actor, {
936
+ signal: ac.signal,
937
+ keyStream: stdin,
938
+ notify: (p) => notifyQueueArrival(p),
939
+ // @implements A-SPEC-650 — the status header before each new-item pass.
940
+ status: () => { const { renderStatus, statusContext } = require('./approve-status'); return renderStatus(readQueue(root), statusContext(root)); },
941
+ ...(pollMs !== undefined ? { pollMs } : {}),
942
+ });
943
+ }
944
+ catch (e) {
945
+ if (e?.name !== 'AbortError')
946
+ throw e;
947
+ }
948
+ finally {
949
+ process.removeListener('SIGINT', onSig);
950
+ stdin.setRawMode?.(false);
951
+ stdin.pause();
952
+ }
953
+ return 0;
881
954
  }
955
+ // @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
956
+ // with the real pending id filled in; the explicit --list above stays script-clean.
882
957
  if (!process.stdin.isTTY) {
883
- process.stderr.write('✗ interactive resident mode (--watch) requires a TTY — use --list in scripts\n');
884
- return 1;
958
+ const state = readQueue(root, queueOpts);
959
+ const hint = renderNonTtyHint(state);
960
+ process.stdout.write(renderPending(state, listView) + (hint ? '\n\n' + hint : '') + '\n');
961
+ return 0;
885
962
  }
886
- // @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
887
- // Enter, no deadline) and pings the OS on each new arrival. Raw mode is restored on the way
888
- // out whatever happens; Ctrl-C arrives as \x03 through decideByKey and ends the loop.
889
- const { runWatch } = require('./approve-watch');
890
- const { notifyQueueArrival } = require('./os-notify');
963
+ // @implements A-SPEC-262.1
964
+ // AWAITED, not fire-and-forget. Measured 2026-08-25 against the shipped bin with a real pty: the
965
+ // prompt printed and the session ended with zero grants, because `void runInteractive(...)`
966
+ // returned 0 immediately and bin/holmes-kit.js turns that into `process.exit(0)` — killing the
967
+ // loop before the first keypress. The decision surface REQ-246 shipped had never run for a real
968
+ // operator; every test called `runInteractive` directly and so never crossed this seam.
969
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
970
+ // EOF is the operator LEAVING (Ctrl-D, a closed pipe). Node leaves a pending `question`
971
+ // unsettled when the interface closes, so without this the awaited loop would hang the command
972
+ // forever — measured 2026-08-25, a pty probe hung for five minutes. The abort ends the session
973
+ // the way the operator asked: quietly, exit 0, nothing decided that was not decided.
891
974
  const ac = new AbortController();
892
- const onSig = () => ac.abort();
893
- process.once('SIGINT', onSig);
894
- const stdin = process.stdin;
975
+ rl.once('close', () => ac.abort());
895
976
  try {
896
- stdin.setRawMode?.(true);
897
- stdin.resume();
898
- await runWatch(root, {
977
+ await runInteractive(root, {
899
978
  print: (t) => process.stdout.write(t + '\n'),
900
- ask: async () => '', // unused on the key surface; kept for the ApproveIO shape
901
- }, actor, {
902
- signal: ac.signal,
903
- keyStream: stdin,
904
- notify: (p) => notifyQueueArrival(p),
905
- ...(pollMs !== undefined ? { pollMs } : {}),
906
- });
979
+ ask: (q) => rl.question(q, { signal: ac.signal }),
980
+ }, actor);
907
981
  }
908
982
  catch (e) {
909
983
  if (e?.name !== 'AbortError')
910
984
  throw e;
911
985
  }
912
986
  finally {
913
- process.removeListener('SIGINT', onSig);
914
- stdin.setRawMode?.(false);
915
- stdin.pause();
987
+ rl.close();
916
988
  }
917
989
  return 0;
918
- }
919
- // @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
920
- // with the real pending id filled in; the explicit --list above stays script-clean.
921
- if (!process.stdin.isTTY) {
922
- const state = readQueue(root, queueOpts);
923
- const hint = renderNonTtyHint(state);
924
- process.stdout.write(renderPending(state, listView) + (hint ? '\n\n' + hint : '') + '\n');
925
- return 0;
926
- }
927
- // @implements A-SPEC-262.1
928
- // AWAITED, not fire-and-forget. Measured 2026-08-25 against the shipped bin with a real pty: the
929
- // prompt printed and the session ended with zero grants, because `void runInteractive(...)`
930
- // returned 0 immediately and bin/holmes-kit.js turns that into `process.exit(0)` — killing the
931
- // loop before the first keypress. The decision surface REQ-246 shipped had never run for a real
932
- // operator; every test called `runInteractive` directly and so never crossed this seam.
933
- const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
934
- // EOF is the operator LEAVING (Ctrl-D, a closed pipe). Node leaves a pending `question`
935
- // unsettled when the interface closes, so without this the awaited loop would hang the command
936
- // forever — measured 2026-08-25, a pty probe hung for five minutes. The abort ends the session
937
- // the way the operator asked: quietly, exit 0, nothing decided that was not decided.
938
- const ac = new AbortController();
939
- rl.once('close', () => ac.abort());
940
- try {
941
- await runInteractive(root, {
942
- print: (t) => process.stdout.write(t + '\n'),
943
- ask: (q) => rl.question(q, { signal: ac.signal }),
944
- }, actor);
945
- }
946
- catch (e) {
947
- if (e?.name !== 'AbortError')
948
- throw e;
949
- }
950
- finally {
951
- rl.close();
952
- }
953
- return 0;
990
+ });
954
991
  }
955
992
  if (cmd === 'serve') {
956
993
  if (typeof flags.target === 'string') {
@@ -11,12 +11,8 @@
11
11
  * Playbooks are ADVISORY. No gate decision reads them, and a user who deletes every one of them
12
12
  * still gets identical enforcement — which is why a missing playbook is a doctor WARN, never a FAIL.
13
13
  */
14
- /**
15
- * Marks a file as OURS. Ownership is decided by this marker and never by the path, because the path
16
- * is exactly what a user's own skill of the same name would occupy, and overwriting that would be
17
- * the same class of error as clobbering their settings.
18
- */
19
- export declare const PLAYBOOK_SKILL_MARKER = "<!-- installed by holmes-kit; edits will be replaced on upgrade -->";
14
+ import { PLAYBOOK_SKILL_MARKER, carriesMarker, frontmatterEnd } from '../project/installer-markers';
15
+ export { PLAYBOOK_SKILL_MARKER, carriesMarker, frontmatterEnd };
20
16
  /**
21
17
  * @implements A-SPEC-190 (round 7)
22
18
  * The bytes an install writes. The marker used to go FIRST, which put it ahead of the playbook's
@@ -28,22 +24,6 @@ export declare const PLAYBOOK_SKILL_MARKER = "<!-- installed by holmes-kit; edit
28
24
  * while doctor certified the layout as `current`. The marker now follows the frontmatter block.
29
25
  */
30
26
  export declare function installedSkillBytes(body: string): string;
31
- /**
32
- * @implements A-SPEC-190 (round 7)
33
- * Ownership is the marker AT ITS PLACE, not the string anywhere in the file. `includes` made any
34
- * document that merely QUOTES the marker — documentation about holmes-kit, a review note — read as
35
- * ours: doctor called it `drifted` and the refresh it advised overwrote the user's file with no
36
- * backup. A quotation is not a claim of ownership.
37
- */
38
- export declare function carriesMarker(content: string): boolean;
39
- /**
40
- * @implements A-SPEC-190 (round 8)
41
- * Where a leading YAML frontmatter block ends, or null. Round-8 measured the shapes the first
42
- * regex missed — a BOM, an EMPTY block (`---\n---\n`), and a closing fence at end-of-file with no
43
- * trailing newline — each of which sent the marker back to byte 0, recreating the CRITICAL the
44
- * round-7 fix existed to remove while doctor certified the result as `current`.
45
- */
46
- export declare function frontmatterEnd(content: string): number | null;
47
27
  /**
48
28
  * @implements A-SPEC-172
49
29
  * What the user types to reach a playbook.
@@ -33,10 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PLAYBOOK_SKILL_MARKER = void 0;
36
+ exports.frontmatterEnd = exports.carriesMarker = exports.PLAYBOOK_SKILL_MARKER = void 0;
37
37
  exports.installedSkillBytes = installedSkillBytes;
38
- exports.carriesMarker = carriesMarker;
39
- exports.frontmatterEnd = frontmatterEnd;
40
38
  exports.invocableSkillName = invocableSkillName;
41
39
  exports.skillPathFor = skillPathFor;
42
40
  exports.installPlaybookSkills = installPlaybookSkills;
@@ -62,12 +60,10 @@ const path = __importStar(require("node:path"));
62
60
  * Playbooks are ADVISORY. No gate decision reads them, and a user who deletes every one of them
63
61
  * still gets identical enforcement — which is why a missing playbook is a doctor WARN, never a FAIL.
64
62
  */
65
- /**
66
- * Marks a file as OURS. Ownership is decided by this marker and never by the path, because the path
67
- * is exactly what a user's own skill of the same name would occupy, and overwriting that would be
68
- * the same class of error as clobbering their settings.
69
- */
70
- exports.PLAYBOOK_SKILL_MARKER = '<!-- installed by holmes-kit; edits will be replaced on upgrade -->';
63
+ const installer_markers_1 = require("../project/installer-markers");
64
+ Object.defineProperty(exports, "PLAYBOOK_SKILL_MARKER", { enumerable: true, get: function () { return installer_markers_1.PLAYBOOK_SKILL_MARKER; } });
65
+ Object.defineProperty(exports, "carriesMarker", { enumerable: true, get: function () { return installer_markers_1.carriesMarker; } });
66
+ Object.defineProperty(exports, "frontmatterEnd", { enumerable: true, get: function () { return installer_markers_1.frontmatterEnd; } });
71
67
  /**
72
68
  * @implements A-SPEC-190 (round 7)
73
69
  * The bytes an install writes. The marker used to go FIRST, which put it ahead of the playbook's
@@ -79,54 +75,14 @@ exports.PLAYBOOK_SKILL_MARKER = '<!-- installed by holmes-kit; edits will be rep
79
75
  * while doctor certified the layout as `current`. The marker now follows the frontmatter block.
80
76
  */
81
77
  function installedSkillBytes(body) {
82
- const end = frontmatterEnd(body);
78
+ const end = (0, installer_markers_1.frontmatterEnd)(body);
83
79
  if (end === null)
84
- return `${exports.PLAYBOOK_SKILL_MARKER}\n${body}`;
80
+ return `${installer_markers_1.PLAYBOOK_SKILL_MARKER}\n${body}`;
85
81
  // The offset is computed on normalised text; a body with CRLF or a BOM is rewritten normalised so
86
82
  // the marker cannot land inside the frontmatter it must follow.
87
83
  const text = body.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
88
84
  const head = text.slice(0, end);
89
- return `${head}${head.endsWith('\n') ? '' : '\n'}${exports.PLAYBOOK_SKILL_MARKER}\n${text.slice(end)}`;
90
- }
91
- /**
92
- * @implements A-SPEC-190 (round 7)
93
- * Ownership is the marker AT ITS PLACE, not the string anywhere in the file. `includes` made any
94
- * document that merely QUOTES the marker — documentation about holmes-kit, a review note — read as
95
- * ours: doctor called it `drifted` and the refresh it advised overwrote the user's file with no
96
- * backup. A quotation is not a claim of ownership.
97
- */
98
- function carriesMarker(content) {
99
- // Round-8: judging on raw bytes made ownership brittle in the direction that cannot be repaired.
100
- // A CRLF normalisation (a Windows editor, `core.autocrlf`, `.gitattributes eol=crlf`), a BOM, or a
101
- // trailing space on the marker line each turned a file the kit itself wrote into `foreign` —
102
- // permanently, since install then skips it forever and doctor advises deleting it as "yours".
103
- // Whitespace and line endings are not ownership; the marker is.
104
- const text = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
105
- const markerAt = (s) => {
106
- const line = s.split('\n', 1)[0];
107
- return line.trimEnd() === exports.PLAYBOOK_SKILL_MARKER;
108
- };
109
- if (markerAt(text))
110
- return true; // 옛 배치(마커 먼저) — 우리 것이므로 고칠 수 있다
111
- const fm = frontmatterEnd(text);
112
- // 빈 줄 하나가 소유를 지우지 않는다(round-9: frontmatter 뒤 개행 하나로 영구 foreign 이 됐다).
113
- return fm !== null && markerAt(text.slice(fm).replace(/^\s*\n/, ''));
114
- }
115
- /**
116
- * @implements A-SPEC-190 (round 8)
117
- * Where a leading YAML frontmatter block ends, or null. Round-8 measured the shapes the first
118
- * regex missed — a BOM, an EMPTY block (`---\n---\n`), and a closing fence at end-of-file with no
119
- * trailing newline — each of which sent the marker back to byte 0, recreating the CRITICAL the
120
- * round-7 fix existed to remove while doctor certified the result as `current`.
121
- */
122
- function frontmatterEnd(content) {
123
- const text = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
124
- if (!/^---[ \t]*(\n|$)/.test(text))
125
- return null;
126
- const m = /\n---[ \t]*(\n|$)/.exec(text.slice(3));
127
- if (!m)
128
- return null;
129
- return 3 + m.index + m[0].length;
85
+ return `${head}${head.endsWith('\n') ? '' : '\n'}${installer_markers_1.PLAYBOOK_SKILL_MARKER}\n${text.slice(end)}`;
130
86
  }
131
87
  /**
132
88
  * @implements A-SPEC-172
@@ -168,7 +124,7 @@ function playbookNames(packageRoot) {
168
124
  }
169
125
  const isOurs = (file) => {
170
126
  try {
171
- return carriesMarker(fs.readFileSync(file, 'utf8'));
127
+ return (0, installer_markers_1.carriesMarker)(fs.readFileSync(file, 'utf8'));
172
128
  }
173
129
  catch {
174
130
  return false;
@@ -637,7 +593,7 @@ function playbookSkillStates(packageRoot, target) {
637
593
  out.push({ name, state: err.code === 'ENOENT' && !lstatPresent ? 'missing' : 'foreign' });
638
594
  continue;
639
595
  }
640
- const state = !carriesMarker(installed) ? 'foreign'
596
+ const state = !(0, installer_markers_1.carriesMarker)(installed) ? 'foreign'
641
597
  : installed === installedSkillBytes(body) ? 'current' : 'drifted';
642
598
  out.push({ name, state });
643
599
  }
@@ -33,6 +33,27 @@ export declare function readGrants(root: string): {
33
33
  grants: Approval[];
34
34
  ignored: number;
35
35
  };
36
+ /**
37
+ * @implements A-SPEC-649
38
+ * A minted grant may carry its BINDING: the workspace it was minted in and the digest of the subject
39
+ * the operator read. Neither is part of the guardrail's `Approval` (an operator's env token has no
40
+ * binding); both are optional so legacy grant files keep covering exactly as before (stated).
41
+ */
42
+ export type BoundGrant = Approval & {
43
+ workspace?: string;
44
+ subject?: string;
45
+ requestedBy?: string;
46
+ };
47
+ export type BindingRefusal = 'foreign-workspace' | 'stale-subject';
48
+ /** @implements A-SPEC-649 — the grants that WOULD cover this action but are refused by their binding, by name. */
49
+ export declare function refusedGrantReasons(root: string, action: {
50
+ kind: string;
51
+ target: string;
52
+ subject?: string;
53
+ }, now: string): Array<{
54
+ nonce: string;
55
+ reason: BindingRefusal;
56
+ }>;
36
57
  /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
37
58
  export declare function pickCoveringGrant(grants: Approval[], action: {
38
59
  kind: string;
@@ -48,6 +69,7 @@ export declare function pickCoveringGrant(grants: Approval[], action: {
48
69
  export declare function resolveApproval(root: string, envApproval: Approval | undefined, action: {
49
70
  kind: string;
50
71
  target: string;
72
+ subject?: string;
51
73
  }, now: string): {
52
74
  approval: Approval;
53
75
  source: 'env' | 'grant';