@holmes-lab/holmes-kit 0.12.2 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +12 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-context.js +10 -10
  5. package/dist/holmes/cli/approve-ref.js +5 -5
  6. package/dist/holmes/cli/approve-watch.d.ts +1 -1
  7. package/dist/holmes/cli/approve-watch.js +6 -6
  8. package/dist/holmes/cli/approve.d.ts +3 -3
  9. package/dist/holmes/cli/approve.js +57 -57
  10. package/dist/holmes/cli/autonomy.d.ts +22 -0
  11. package/dist/holmes/cli/autonomy.js +145 -0
  12. package/dist/holmes/cli/colophon.d.ts +6 -0
  13. package/dist/holmes/cli/colophon.js +24 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -2
  15. package/dist/holmes/cli/doctor.js +104 -87
  16. package/dist/holmes/cli/index.js +122 -63
  17. package/dist/holmes/cli/init.d.ts +2 -0
  18. package/dist/holmes/cli/init.js +31 -19
  19. package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
  20. package/dist/holmes/cli/interactive-prompt.js +23 -0
  21. package/dist/holmes/cli/semantic-key.js +9 -9
  22. package/dist/holmes/cli/settings-merge.d.ts +2 -1
  23. package/dist/holmes/cli/settings-merge.js +15 -3
  24. package/dist/holmes/cli/upgrade.js +7 -7
  25. package/dist/holmes/cpg/proposed-content.js +2 -2
  26. package/dist/holmes/governance/autonomy.d.ts +9 -2
  27. package/dist/holmes/governance/autonomy.js +166 -5
  28. package/dist/holmes/guardrail/blind-spots.js +15 -15
  29. package/dist/holmes/hooks/pre-tool-use.js +111 -42
  30. package/dist/holmes/hooks/session-start.js +17 -0
  31. package/dist/holmes/hooks/stop.d.ts +1 -1
  32. package/dist/holmes/hooks/stop.js +12 -12
  33. package/dist/holmes/mcp/handlers.js +14 -1
  34. package/dist/holmes/semantic/credentials.js +1 -1
  35. package/dist/holmes/spec/id-collision.js +2 -2
  36. package/package.json +2 -2
  37. package/playbooks/publish/PLAYBOOK.md +47 -35
  38. package/playbooks/remediation/PLAYBOOK.md +1 -1
@@ -3,12 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GATE_BLIND_SPOTS = void 0;
4
4
  exports.blindSpotSummary = blindSpotSummary;
5
5
  exports.GATE_BLIND_SPOTS = [
6
- { example: 'node ./cleanup.js', why: '스크립트 파일의 내용은 명령 문자열에 없습니다' },
7
- { example: 'bash ./deploy.sh', why: '같은 이유실행할 내용이 파일 안에 있습니다' },
8
- { example: 'npm run clean', why: 'package.json 스크립트 정의가 명령 문자열 밖에 있습니다' },
9
- { example: 'make distclean', why: 'Makefile 규칙이 명령 문자열 밖에 있습니다' },
10
- { example: 'CMD="something"; $CMD', why: '실행될 명령이 실행 시점에 조립됩니다' },
11
- { example: 'xargs rm < targets.txt', why: '대상 목록이 표준 입력으로 들어옵니다' },
6
+ { example: 'node ./cleanup.js', why: 'the script file\'s contents are not in the command string' },
7
+ { example: 'bash ./deploy.sh', why: 'same reasonwhat runs lives inside the file' },
8
+ { example: 'npm run clean', why: 'the script definition in package.json is outside the command string' },
9
+ { example: 'make distclean', why: 'the Makefile rule is outside the command string' },
10
+ { example: 'CMD="something"; $CMD', why: 'the command to run is assembled at execution time' },
11
+ { example: 'xargs rm < targets.txt', why: 'the target list arrives on standard input' },
12
12
  ];
13
13
  /**
14
14
  * One line for `doctor`.
@@ -18,23 +18,23 @@ exports.GATE_BLIND_SPOTS = [
18
18
  * parts include creating the script in the first place.
19
19
  */
20
20
  function blindSpotSummary() {
21
- return '가드레일은 명령 문자열을 검사합니다이미 저장소에 있는 스크립트를 실행하는 명령'
22
- + `(${exports.GATE_BLIND_SPOTS.slice(0, 3).map((b) => b.example).join(', ')})의 내용은 보지 못합니다.`
23
- + ' 스크립트를 새로 작성하는 것은 승인이 필요합니다(A-SPEC-167).'
21
+ return 'The guardrail inspects the command string it cannot see the contents of a command that runs a script already in the repository'
22
+ + ` (e.g. ${exports.GATE_BLIND_SPOTS.slice(0, 3).map((b) => b.example).join(', ')}).`
23
+ + ' Writing a new script requires approval (A-SPEC-167).'
24
24
  // @implements A-SPEC-170
25
25
  // Stated, not implied. Four sweep rounds each found runner-manifest names the previous round's
26
26
  // list had missed, so calling the list complete would be false — and this is the one place the
27
27
  // harness tells a user what it cannot see. It belongs in the prose, not in GATE_BLIND_SPOTS,
28
28
  // whose entries are commands a test executes against the gate.
29
- + ' 루트 러너 매니페스트(Rakefile·Gemfile ) 이름 목록은 완전하지 않습니다 생태계마다 새로 생깁니다.'
29
+ + ' The list of root runner-manifest names (Rakefile, Gemfile, etc.) is not completeevery ecosystem adds new ones.'
30
30
  // @implements A-SPEC-175
31
31
  // The residual after REQ-175. Losing `.ax/specs` is now detected, because the ledger records the
32
32
  // approvals and `init`'s .gitignore block keeps `provenance*.jsonl` committed. Losing the whole
33
33
  // `.ax` takes that evidence with it, and a project then looks like one that never opted in. Every
34
34
  // shell route to either state is denied by this gate; what remains is deletion from outside the
35
35
  // session, and saying so is the only honest option left.
36
- + ' `.ax` 전체가 세션 밖에서 삭제되면(에디터·Finder·세션 git) 원장도 함께 사라져'
37
- + ' 거버넌스를 없는 프로젝트와 구별되지 않습니다 — `.ax/specs`만 사라진 경우는 탐지됩니다(REQ-175).'
36
+ + ' If the whole `.ax` is deleted from outside the session (editor, Finder, git outside the session), the ledger goes with it,'
37
+ + ' making the project indistinguishable from one that never opted in losing only `.ax/specs` is detected (REQ-175).'
38
38
  // @implements A-SPEC-421
39
39
  // Measured against the INSTALLED build, which is the only place it shows: `tool_name: "Bash"`
40
40
  // with `rm -rf /` is denied and `tool_name: "mcp__shell__run"` with the same command is allowed.
@@ -43,7 +43,7 @@ function blindSpotSummary() {
43
43
  // is the shape of the gap, not a hypothetical. Denying unknown names instead was considered and
44
44
  // rejected: it would refuse every harness's Read, Grep and search tools and leave the guardrail
45
45
  // unusable. What is fixable here is the map, and an undeclared gap reads as coverage.
46
- + ' 게이트는 **도구 이름**으로 판정하므로 이름을 모르는 도구는 검사하지 않습니다 —'
47
- + ' MCP 서버가 제공하는 도구(`mcp__<서버>__<도구>`) 셸을 실행하더라도 게이트를 거치지'
48
- + ' 않습니다. MCP 서버는 각자의 승인 경로로 통제하십시오.';
46
+ + ' The gate dispatches on the **tool name**, so a tool whose name it does not know is not inspected —'
47
+ + ' a tool provided by an MCP server (`mcp__<server>__<tool>`) does not pass through this gate even when it runs a shell.'
48
+ + ' Control MCP servers through their own approval paths.';
49
49
  }
@@ -305,7 +305,7 @@ function preEditEvidenceGate(o) {
305
305
  }
306
306
  function decideOnGateError(opts) {
307
307
  if (opts.bypass) {
308
- return { permissionDecision: 'allow', permissionDecisionReason: `[Holmes-Kit] 내부 오류를 HOLMES_GATE_BYPASS 우회했습니다: ${opts.message}` };
308
+ return { permissionDecision: 'allow', permissionDecisionReason: `[Holmes-Kit] an internal error was bypassed via HOLMES_GATE_BYPASS: ${opts.message}` };
309
309
  }
310
310
  if (!opts.governed)
311
311
  return { permissionDecision: 'allow' };
@@ -313,8 +313,8 @@ function decideOnGateError(opts) {
313
313
  // spec chain that was never the problem.
314
314
  return {
315
315
  permissionDecision: 'deny',
316
- permissionDecisionReason: `[Holmes-Kit] 내부 오류로 판정할 없습니다게이트는 판정 불가 허용하지 않습니다: ${opts.message}`
317
- + ' → `holmes-kit doctor`로 설치를 점검하세요. 운영자 판단으로 통과시키려면 환경에 HOLMES_GATE_BYPASS=1 설정하십시오(세션이 스스로 설정할 없는 대역외 채널).',
316
+ permissionDecisionReason: `[Holmes-Kit] cannot judge due to an internal error the gate does not allow when it cannot decide: ${opts.message}`
317
+ + ' → check the install with `holmes-kit doctor`. To let it through by operator judgment, set HOLMES_GATE_BYPASS=1 in the environment (an out-of-band channel a session cannot set itself).',
318
318
  };
319
319
  }
320
320
  // @implements A-SPEC-514.1
@@ -479,7 +479,7 @@ function evaluateHook(input, specsDir, opts) {
479
479
  // first legitimate use in every new project. The single-use guarantee still holds: the record
480
480
  // lands in the resolved root, which is the same file every later call resolves to.
481
481
  if ((0, provenance_chain_1.blankNonce)(acting?.nonce)) {
482
- return deny('승인이 단일 사용(nonce) 선언했으나 값이 비어 있습니다1회성을 집행할 없어 거부합니다');
482
+ return deny('the approval declared single-use (nonce) but the value is empty single-use cannot be enforced, so it is denied');
483
483
  }
484
484
  if (acting?.nonce) {
485
485
  let won;
@@ -579,8 +579,8 @@ function evaluateHook(input, specsDir, opts) {
579
579
  return {
580
580
  permissionDecision: 'deny',
581
581
  permissionDecisionReason: GRANTS_SELF
582
- ? '[Holmes-Kit] 명령이 HOLMES_ROLE/HOLMES_GATE_BYPASS 지정하거나 읽습니다 세션이 스스로 권한을 부여하는 경로입니다.'
583
- + ' 역할은 에이전트를 기동하는 사람이 대역외로 정합니다. 운영자 판단으로 실행하려면 config-write 포함한 승인이 필요합니다.'
582
+ ? '[Holmes-Kit] the command sets or reads HOLMES_ROLE/HOLMES_GATE_BYPASS a path by which a session grants itself authority.'
583
+ + ' Roles are set out-of-band by whoever launches the agent. To run it by operator judgment, an approval that includes config-write is required.'
584
584
  : '[Holmes-Kit] command reads or dumps process environment (holds the out-of-band HOLMES_APPROVAL / HOLMES_LEDGER_KEY secrets) — requires out-of-band approval',
585
585
  };
586
586
  }
@@ -624,7 +624,7 @@ function evaluateHook(input, specsDir, opts) {
624
624
  // containing that letter. Caught by a test, not by reading.
625
625
  // @implements A-SPEC-463 — the path sits BETWEEN quotes here, so spaces inside it are part
626
626
  // of the path, not an argument boundary. `[^'"\s]*` stopped at the first space and missed
627
- // `open('C:\Users\이름 성\a.ts','w')`; `[^'"]*` keeps the space and still cannot cross a quote.
627
+ // `open('C:\Users\Given Family\a.ts','w')`; `[^'"]*` keeps the space and still cannot cross a quote.
628
628
  const NOT_QUOTE = String.raw `[^'"]*`;
629
629
  const QUOTED_PATH = new RegExp(String.raw `['"](` + NOT_QUOTE + String.raw `\.` + CODE_EXT + '|' + NOT_QUOTE + CONFIG_FILE + String.raw `)['"]`, 'g');
630
630
  const out = [];
@@ -644,7 +644,7 @@ function evaluateHook(input, specsDir, opts) {
644
644
  // @implements A-SPEC-463 — Windows W-2. The extraction below approximated a path with a
645
645
  // character class that excludes spaces (`[^\s...]*`) preceded by an OPTIONAL opening quote
646
646
  // that no closing quote ever paired. A quoted path with a space — the Windows default home is
647
- // `C:\Users\<이름 성>` — matched only up to the first space, never reached the extension, and
647
+ // `C:\Users\<Given Family>` — matched only up to the first space, never reached the extension, and
648
648
  // was captured as nothing, so the write was allowed. `pathAlt` is three alternatives: a
649
649
  // double- or single-quoted path (spaces kept, up to the closing quote) or the unquoted form
650
650
  // (still ends at the first shell word boundary — an un-escaped space is an argument break).
@@ -740,6 +740,7 @@ function evaluateHook(input, specsDir, opts) {
740
740
  // which is an ordinary backup and not this gate's business.
741
741
  // (A data heredoc body may still carry separators inside one segment, so the per-segment
742
742
  // text is split the way the whole command used to be — the base is the segment's either way.)
743
+ const CODE_TAIL_RE = new RegExp(`\\.${CODE_EXT}$`);
743
744
  for (const seg of segments) {
744
745
  for (const sub of seg.text.split(/[;|&]+/)) {
745
746
  if (!/\b(?:cp|mv|install|rsync|ln|dd)\b/.test(sub))
@@ -752,10 +753,72 @@ function evaluateHook(input, specsDir, opts) {
752
753
  continue;
753
754
  }
754
755
  }
755
- const all = [...sub.matchAll(new RegExp(pathAlt(EXT_TAIL), 'g'))]
756
- .map(firstGroup).filter((x) => x !== undefined);
757
- if (all.length >= 2)
758
- candidates.push({ raw: all[all.length - 1], base: seg.base }); // last path is the destination
756
+ // @implements A-SPEC-550.1 for cp/mv/install/rsync/ln the DESTINATION is the last path
757
+ // argument, whatever the source's extension. The old rule matched only code-extension paths
758
+ // and required >=2 of them, so a NON-code source left the destination as the sole code path
759
+ // and walked through: `cp /tmp/x.txt src/a.ts`, `install /dev/null src/a.ts`, and even
760
+ // `cp /dev/stdin src/a.ts <<EOF` (content in the command). Judge the destination directly.
761
+ // A code path that is a SOURCE (not last) is not the destination and is left alone, so a
762
+ // read-out backup `cp src/a.ts /tmp/backup.ts` (destination outside) still resolves 'out'.
763
+ const head = sub.split(/<<|>>|>/)[0]; // drop a heredoc/redirect tail
764
+ // Quote-aware tokens: a quoted string is ONE argument even with spaces (Windows W-2 /
765
+ // A-SPEC-463 — a backup to `"C:\Users\Full Name\a.ts"` must not be split into two).
766
+ const rawToks = head.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
767
+ const unq = (t) => t.replace(/^['"]|['"]$/g, '');
768
+ // @implements A-SPEC-557.1 — judge the LANDING path, not the last token's extension. GNU
769
+ // `-t DIR`/`--target-directory[=]DIR` name an explicit destination directory and make every
770
+ // positional a SOURCE; and a destination that is a directory lands each source at
771
+ // `dir/basename(src)`. Both slipped a code file into the governed tree unjudged before.
772
+ let targetDir;
773
+ const positional = [];
774
+ for (let i = 0; i < rawToks.length; i++) {
775
+ const t = rawToks[i];
776
+ const eq = /^--target-directory=(.+)$/.exec(t);
777
+ if (eq) {
778
+ targetDir = unq(eq[1]);
779
+ continue;
780
+ }
781
+ if (t === '-t' || t === '--target-directory') {
782
+ const n = rawToks[i + 1];
783
+ if (n !== undefined) {
784
+ targetDir = unq(n);
785
+ i++;
786
+ }
787
+ continue;
788
+ }
789
+ if (t.startsWith('-'))
790
+ continue; // any other flag
791
+ positional.push(unq(t));
792
+ }
793
+ const baseName = (s) => s.split(/[/\\]/).pop() ?? s;
794
+ const stripSlash = (s) => s.replace(/[/\\]+$/, '');
795
+ const pushLanding = (destDir, sources) => {
796
+ for (const src of sources)
797
+ if (CODE_TAIL_RE.test(src))
798
+ candidates.push({ raw: `${stripSlash(destDir)}/${baseName(src)}`, base: seg.base });
799
+ };
800
+ const looksLikeDir = (d) => {
801
+ if (/[/\\]$/.test(d))
802
+ return true; // trailing separator = explicit dir
803
+ try {
804
+ const abs = (0, write_target_1.absoluteKindOf)(d) !== null ? d : (seg.base === null ? undefined : (0, write_target_1.pathFlavorFor)(seg.base, d).resolve(seg.base, d));
805
+ return abs !== undefined && fs.statSync(abs).isDirectory();
806
+ }
807
+ catch {
808
+ return false;
809
+ } // non-existent / unreadable ⇒ a file, not a dir
810
+ };
811
+ const argv = positional.slice(1); // positional[0] is the tool name
812
+ if (targetDir !== undefined) {
813
+ pushLanding(targetDir, argv); // -t DIR: every positional is a source
814
+ }
815
+ else if (argv.length >= 2) {
816
+ const dest = argv[argv.length - 1];
817
+ if (CODE_TAIL_RE.test(dest))
818
+ candidates.push({ raw: dest, base: seg.base }); // dest is a code file
819
+ else if (looksLikeDir(dest))
820
+ pushLanding(dest, argv.slice(0, -1)); // dest is a directory
821
+ }
759
822
  }
760
823
  }
761
824
  // @implements A-SPEC-447 — same predicate as the config rule; it lived in two copies, which
@@ -772,7 +835,12 @@ function evaluateHook(input, specsDir, opts) {
772
835
  return { permissionDecision: 'deny', permissionDecisionReason: UNRESOLVED_BASE_DENY };
773
836
  }
774
837
  if (codeVerdicts.includes('in') || codeVerdicts.includes('unknown')) {
775
- const governed = readSpecsSync(specsDir).some((s) => s.status === 'approved');
838
+ // @implements A-SPEC-550.1 governance is "specs EXIST", not "a spec is approved". The old
839
+ // `some(status==='approved')` slept through the authoring window: after `spec_slice_init` and
840
+ // before approval a repo has DRAFT specs, so `cat > test/x.test.js <<EOF` walked through while
841
+ // Write/Edit denied the same unapproved anchor — the exact tool-vs-shell asymmetry reported.
842
+ // A truly spec-less repo still has zero specs here, so ordinary ungoverned coding stays free.
843
+ const governed = readSpecsSync(specsDir).length > 0;
776
844
  if (governed) {
777
845
  return codeVerdicts.includes('in')
778
846
  ? { permissionDecision: 'deny', permissionDecisionReason: '[Holmes-Kit] shell write to a project code file bypasses the No-Spec-No-Code gate — use the Write/Edit tools (gated) or supply out-of-band approval' }
@@ -786,7 +854,7 @@ function evaluateHook(input, specsDir, opts) {
786
854
  }
787
855
  // @implements A-SPEC-191 §27 (round 13) — `PATH_FIELDS` exists because `notebook_path` slipped
788
856
  // past a gate that knew only `file_path`, and this line still knew only `file_path`: a valid
789
- // NotebookEdit was denied in EVERY project with "편집 대상 경로를 읽을 없습니다", which names
857
+ // NotebookEdit was denied in EVERY project with "cannot read the edit target path", which names
790
858
  // the wrong fault (the path was there and readable) and blocks a legitimate tool outright.
791
859
  const p1 = typeof input.tool_input.file_path === 'string' ? input.tool_input.file_path : undefined;
792
860
  const p2 = typeof input.tool_input.notebook_path === 'string' ? input.tool_input.notebook_path : undefined;
@@ -804,7 +872,7 @@ function evaluateHook(input, specsDir, opts) {
804
872
  // has to recognise.
805
873
  return {
806
874
  permissionDecision: 'deny',
807
- permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로를 읽을 없어 판정할 없습니다 (file_path 누락 또는 문자열이 아님)',
875
+ permissionDecisionReason: '[Holmes-Kit] cannot read the edit target path, so it cannot be judged (file_path missing or not a string)',
808
876
  };
809
877
  }
810
878
  const writesCode = (0, write_target_1.writesFiles)(input.tool_name, input.tool_input);
@@ -818,8 +886,8 @@ function evaluateHook(input, specsDir, opts) {
818
886
  if (writesCode && !p) {
819
887
  return {
820
888
  permissionDecision: 'deny',
821
- permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로를 읽을 없어 판정할 없습니다 (file_path 누락 또는 문자열이 아님)'
822
- + ' — 이름을 없는 파일이 승인된 스펙에 덮이는지 확인할 방법이 없으므로 허용하지 않습니다.',
889
+ permissionDecisionReason: '[Holmes-Kit] cannot read the edit target path, so it cannot be judged (file_path missing or not a string)'
890
+ + ' — there is no way to check whether an unnamed file overwrites an approved spec, so it is not allowed.',
823
891
  };
824
892
  }
825
893
  if (!p || !writesCode)
@@ -880,9 +948,9 @@ function evaluateHook(input, specsDir, opts) {
880
948
  // nothing to govern"; an undecidable target is refused, not allowed.
881
949
  return {
882
950
  permissionDecision: 'deny',
883
- permissionDecisionReason: '[Holmes-Kit] 네트워크 경로(UNC) 로컬 파일시스템 정체로 해석할 없어'
884
- + ' 프로젝트 안팎을 판정할 없습니다판정할 없는 대상은 허용하지 않습니다.'
885
- + ' 프로젝트를 로컬 드라이브에 두거나 드라이브 문자로 매핑한 경로를 사용하십시오.',
951
+ permissionDecisionReason: '[Holmes-Kit] a network path (UNC) cannot be resolved to a local filesystem identity,'
952
+ + ' so it cannot be judged inside or outside the project an untellable target is not allowed.'
953
+ + ' Put the project on a local drive, or use a drive-letter-mapped path.',
886
954
  };
887
955
  }
888
956
  if (absKind === 'posix' || absKind === 'drive') {
@@ -918,8 +986,9 @@ function evaluateHook(input, specsDir, opts) {
918
986
  }
919
987
  })();
920
988
  const fold = (s) => (caseBlind ? s.toLowerCase() : s);
921
- // 접기는 해석된 위치에만 적용한다 철자 그대로의 norm 접어 '안'이라 부르면, 프로젝트
922
- // 밖을 가리키는 링크가 이름만으로 안에 들어온다. 해석이 먼저, 접기는 위에서.
989
+ // Case-folding applies only to the RESOLVED location folding the literal-spelling norm and
990
+ // calling it 'inside' lets a link that points OUTSIDE the project enter by name alone. Resolve
991
+ // first, fold on top of that.
923
992
  const canonical = inside(norm, projectRoot) ? asProject(norm, projectRoot)
924
993
  : inside(realTarget, realRoot) ? asProject(realTarget, realRoot)
925
994
  : inside(fold(realTarget), fold(realRoot)) ? asProject(realTarget, realRoot)
@@ -936,8 +1005,8 @@ function evaluateHook(input, specsDir, opts) {
936
1005
  if (norm.includes('\u0000')) {
937
1006
  return {
938
1007
  permissionDecision: 'deny',
939
- permissionDecisionReason: '[Holmes-Kit] 편집 대상 경로에 NUL 바이트가 있어 어느 파일인지 판정할 없습니다'
940
- + ' — 이름을 확정할 없는 대상은 허용하지 않습니다.',
1008
+ permissionDecisionReason: '[Holmes-Kit] the edit target path contains a NUL byte, so which file it is cannot be judged'
1009
+ + ' — a target whose name cannot be pinned down is not allowed.',
941
1010
  };
942
1011
  }
943
1012
  norm = norm.length > 1 ? norm.replace(/\/+$/, '') : norm;
@@ -964,15 +1033,15 @@ function evaluateHook(input, specsDir, opts) {
964
1033
  if (rolesResolved === undefined) {
965
1034
  return {
966
1035
  permissionDecision: 'deny',
967
- permissionDecisionReason: '[Holmes-Kit] 역할 정책(.ax/roles/) 게이트가 집행하는 권한 자체를 정의합니다 —'
968
- + ' 에이전트가 스스로 고칠 없습니다. config-write 포함한 대역외 승인이 필요합니다.'
969
- + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: '역할 정책(.ax/roles/) 쓰기' }),
1036
+ permissionDecisionReason: '[Holmes-Kit] the role policy (.ax/roles/) defines the very authority this gate enforces —'
1037
+ + ' an agent cannot change it itself. An out-of-band approval that includes config-write is required.'
1038
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: 'writing the role policy (.ax/roles/)' }),
970
1039
  };
971
1040
  }
972
1041
  if (rolesResolved.source === 'grant' && rolesResolved.approval.nonce)
973
1042
  (0, approval_grants_1.consumeGrantFile)(opts.projectRoot, rolesResolved.approval.nonce);
974
1043
  }
975
- // @implements A-SPEC-191 §22 — 신원으로 묻는다. 철자 정규식은 프로젝트 링크 하나로 빗나갔다.
1044
+ // @implements A-SPEC-191 §22 — ask by identity. A spelling regex was defeated by a single in-project link.
976
1045
  const configKind = (0, write_target_1.protectedFileKindOf)(opts.projectRoot, norm);
977
1046
  // @implements A-SPEC-163 — identity, not spelling. Measured: `.ax//ledger//p.jsonl`,
978
1047
  // `.ax/./ledger/p.jsonl` and `.AX/roles/policy.yaml` all reached the protected file while the
@@ -982,11 +1051,11 @@ function evaluateHook(input, specsDir, opts) {
982
1051
  // out-of-scope token does not, so config forgery cannot ride a narrow grant.
983
1052
  const cfgResolved = (0, approval_grants_1.resolveApproval)(opts.projectRoot, weApproval, { kind: 'config-write', target: norm }, nowTs);
984
1053
  if (cfgResolved === undefined) {
985
- // @implements A-SPEC-193 §8 — 무엇을 막았는지 이름한다. 목록을 문장에 박아 두면 하네스가
986
- // 때마다 문면이 거짓이 된다(REQ-155: 엉뚱한 파일을 지목하는 진단의 값은 음수다).
987
- const kindName = configKind ?? (0, write_target_1.protectedKindOf)(opts.projectRoot, norm) ?? '설정';
988
- return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} 은(는) 훅·승인 설정입니다 — HOLMES_APPROVAL 위조하거나 거버넌스를 있으므로 config-write 포함한 대역외 승인이 필요합니다`
989
- + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: `${kindName} 쓰기` }) };
1054
+ // @implements A-SPEC-193 §8 — name what was blocked. Baking a list into the sentence makes the
1055
+ // wording false as harnesses grow (REQ-155: a diagnosis that points at the wrong file has negative value).
1056
+ const kindName = configKind ?? (0, write_target_1.protectedKindOf)(opts.projectRoot, norm) ?? 'config';
1057
+ return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${kindName} is a hook/approval config it can forge HOLMES_APPROVAL or turn governance off, so an out-of-band approval that includes config-write is required`
1058
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'config-write', target: norm, why: `writing ${kindName}` }) };
990
1059
  }
991
1060
  if (cfgResolved.source === 'grant' && cfgResolved.approval.nonce)
992
1061
  (0, approval_grants_1.consumeGrantFile)(opts.projectRoot, cfgResolved.approval.nonce);
@@ -1092,7 +1161,7 @@ function evaluateHook(input, specsDir, opts) {
1092
1161
  if (violations.length > 0) {
1093
1162
  return {
1094
1163
  permissionDecision: 'deny',
1095
- permissionDecisionReason: '[Holmes-Kit] 구조 제약 위반 승인된 C-SPEC 이 금지한 형태입니다:\n'
1164
+ permissionDecisionReason: '[Holmes-Kit] structural-constraint violationa shape an approved C-SPEC forbids:\n'
1096
1165
  + violations.map((v) => ' - ' + v).join('\n')
1097
1166
  + '\n' + proposed_content_1.CSPEC_GATE_LIMITS,
1098
1167
  };
@@ -1138,7 +1207,7 @@ function evaluateHook(input, specsDir, opts) {
1138
1207
  // told `id "" fails …`. Splitting loses nothing and can collide with nothing.
1139
1208
  // Split on the spec's OWN id at a token boundary. A raw `split(id)` fired mid-token whenever
1140
1209
  // the id was a prefix of another id in the same sentence — measured: `A-SPEC-901` whose parent
1141
- // `A-SPEC-9011` is missing rendered `depends_on "해당 스펙1" not found`, destroying the one
1210
+ // `A-SPEC-9011` is missing rendered `depends_on "that spec 1" not found`, destroying the one
1142
1211
  // fact the author needed. This repository's own dotted ids (`A-SPEC-100.1`) make the prefix
1143
1212
  // relation ordinary, not exotic.
1144
1213
  const selfRe = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, 'g');
@@ -1168,7 +1237,7 @@ function evaluateHook(input, specsDir, opts) {
1168
1237
  let reasonLeft = REASON_MAX;
1169
1238
  // SKIP, never cut (round-4): truncation mid-sentence sliced spec ids into other (or
1170
1239
  // nonexistent) ids, split astral chars, and — worst — one pathological first group zeroed
1171
- // the budget so later SHORT sentences vanished under a FALSE '잘림' label. clampBlockers'
1240
+ // the budget so later SHORT sentences vanished under a FALSE 'truncated' label. clampBlockers'
1172
1241
  // discipline instead: an oversized sentence is replaced whole by an honest omission label,
1173
1242
  // and fitting sentences keep rendering out of the shared budget.
1174
1243
  const OMIT_OVER = ' (reason exceeds text budget — verify spec individually)';
@@ -1176,7 +1245,7 @@ function evaluateHook(input, specsDir, opts) {
1176
1245
  const shown0 = groups.slice(0, DISTINCT_REASON_CAP);
1177
1246
  // @implements A-SPEC-192 §5R (round 9) — a SHARE, not a race. First-come-full-draw made the
1178
1247
  // budget a cliff: one 1,199-character sentence (just under the cap) took the whole allowance
1179
- // and every short sibling vanished behind '예산 소진', while making that same sentence TEN
1248
+ // and every short sibling vanished behind 'budget exhausted', while making that same sentence TEN
1180
1249
  // characters LONGER flipped it to the omission label and brought the siblings back — a
1181
1250
  // refusal that improves when the input gets worse. Each group is guaranteed its equal share
1182
1251
  // first; whatever the short ones do not use is then offered to the rest in order.
@@ -1308,7 +1377,7 @@ function evaluateHook(input, specsDir, opts) {
1308
1377
  // Stale-aware gate (rev.1): a target A-SPEC whose seal is PRESENT and BROKEN (edited after
1309
1378
  // approval) or STALE (a parent moved) is not a trustworthy approval, so code must not be written
1310
1379
  // against it — even though its status still reads 'approved'. The denial names staleness and
1311
- // spec_approve, deliberately DISTINCT from phaseCheck's "…이 approved가 아닙니다" (REQ-130's lesson:
1380
+ // spec_approve, deliberately DISTINCT from phaseCheck's " is not approved" (REQ-130's lesson:
1312
1381
  // two different failures must not share the same words). An ABSENT seal falls through — the backfill
1313
1382
  // closes that window and the Stop hook's unsealed-approval error blocks the turn regardless.
1314
1383
  const resolveSpec = (id) => specs.find((s) => s.id === id) ?? null;
@@ -1322,14 +1391,14 @@ function evaluateHook(input, specsDir, opts) {
1322
1391
  if (!seal.approvedDigest)
1323
1392
  return null; // absent seal: backfill + Stop hook territory
1324
1393
  if ((0, spec_digest_1.specDigest)(spec) !== seal.approvedDigest)
1325
- return `${id} 승인 이후 내용이 변경되었습니다(post-approval-edit)`;
1394
+ return `${id} was edited after approval (post-approval-edit)`;
1326
1395
  for (const pid of spec.dependsOn) {
1327
1396
  const parent = resolveSpec(pid);
1328
1397
  if (!parent || parent.status !== 'approved')
1329
1398
  continue;
1330
1399
  const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
1331
1400
  if (parentSeal && seal.parentDigests[pid] !== parentSeal)
1332
- return `${id} 부모 ${pid} 재승인되어 체인이 stale입니다(stale-parent)`;
1401
+ return `${id}'s parent ${pid} was re-approved, so the chain is stale (stale-parent)`;
1333
1402
  }
1334
1403
  return null;
1335
1404
  };
@@ -1345,7 +1414,7 @@ function evaluateHook(input, specsDir, opts) {
1345
1414
  // @implements A-SPEC-133 — the override is authorized by a token covering the code write.
1346
1415
  const codeWriteCovered = (0, risk_gate_1.approvalCovers)(weApproval, { kind: 'code-write', target: relPath }, nowTs);
1347
1416
  if (staleTarget && !codeWriteCovered) {
1348
- return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${staleTarget} — spec_approve 재승인 진행하세요` };
1417
+ return { permissionDecision: 'deny', permissionDecisionReason: `[Holmes-Kit] ${staleTarget} — re-approve with spec_approve, then proceed` };
1349
1418
  }
1350
1419
  // @implements A-SPEC-152
1351
1420
  // Role gate — BEFORE the spec gate on purpose: if this identity may not perform the action at all,
@@ -1641,7 +1710,7 @@ if (require.main === module) {
1641
1710
  // @implements A-SPEC-133 — a master-key use becomes an audit line: the approval's
1642
1711
  // narrowing (or its absence) travels with the record (r7-191: promised, never shipped).
1643
1712
  inputs: [input.tool_name, target,
1644
- ...(unlocked ? [(0, provenance_chain_1.approvalMarkers)(approval)] : []), // 항목·마지막 자리표지가 갈라져 자기모순이 되지 않는다
1713
+ ...(unlocked ? [(0, provenance_chain_1.approvalMarkers)(approval)] : []), // one item, last position the marker cannot split and self-contradict
1645
1714
  ].filter(Boolean),
1646
1715
  // round-9: a deny inherited the actor/rationale/authorization of an ambient approval that
1647
1716
  // did NOT cover it, so the audit line credited an authorizer who authorized nothing.
@@ -158,6 +158,23 @@ if (require.main === module) {
158
158
  out.hookSpecificOutput.additionalContext += notice;
159
159
  }
160
160
  catch { /* the banner is never a gate */ }
161
+ // @implements A-SPEC-554.1 — remind the human of the current autonomy posture (surface only, the
162
+ // headless hook never decides), and clean up an expired/invalid session marker. Fail-open.
163
+ try {
164
+ const { autonomyBanner, autonomyMarkerShouldPrune, SESSION_AUTONOMY_MARKER } = require('../governance/autonomy');
165
+ const cwd = process.cwd();
166
+ const nowIso = new Date().toISOString();
167
+ const line = autonomyBanner(process.env, cwd, nowIso);
168
+ if (line)
169
+ out.hookSpecificOutput.additionalContext += '\n' + line;
170
+ if (autonomyMarkerShouldPrune(cwd, nowIso)) {
171
+ try {
172
+ fs.rmSync(path.join(cwd, ...SESSION_AUTONOMY_MARKER), { force: true });
173
+ }
174
+ catch { /* housekeeping only */ }
175
+ }
176
+ }
177
+ catch { /* the banner is never a gate */ }
161
178
  process.stdout.write(JSON.stringify({ hookSpecificOutput: out.hookSpecificOutput }));
162
179
  if (out.shouldRefresh) {
163
180
  // Detached, unref'd child so the session start does not wait on the network. The refresh
@@ -12,7 +12,7 @@ import type { TestOutcome } from '../review/test-runner';
12
12
  * or validation errors, it BLOCKS the stop and feeds the findings back so the model must fix them
13
13
  * before finishing. No model cooperation required — the governance fires whether or not it is asked.
14
14
  *
15
- * HARDENING H1 (GWT-실테스트 bridge): declared 4-quadrant coverage previously verified only the
15
+ * HARDENING H1 (GWT-to-real-test bridge): declared 4-quadrant coverage previously verified only the
16
16
  * T-SPEC DOCUMENT. Now, for every approved T-SPEC, its parent A-SPEC must have at least one anchored
17
17
  * TEST FILE containing REAL test cases (`it(`/`test(`) — declared coverage over an empty/absent test
18
18
  * suite blocks the stop. Evidence is supplied by the CLI (I/O half); evaluateStop stays pure.
@@ -318,27 +318,27 @@ function evaluateStop(specs, evidence) {
318
318
  // @implements A-SPEC-191 (§4a) — an existing-but-unreadable findings ledger is not a clean turn:
319
319
  // it may hold an open critical, and a "clean" verdict here would also CLEAR standing ART-7 debt.
320
320
  if (evidence?.findingsUnreadable) {
321
- problems.push('[ART-7] findings 원장을 읽을 없습니다열린 치명 발견의 존재를 확인할 없는 턴은 깨끗한 턴이 아닙니다 (원장 파일의 권한·형식을 복구하십시오)');
322
- structured.push({ article: 'ART-7', detail: 'findings 원장을 읽을 없습니다' });
321
+ problems.push('[ART-7] the findings ledger cannot be reada turn that cannot confirm whether an open critical exists is not a clean turn (repair the ledger file\'s permissions/format)');
322
+ structured.push({ article: 'ART-7', detail: 'the findings ledger cannot be read' });
323
323
  }
324
324
  // @implements A-SPEC-452 — ART-1's second enforcement point. `undefined` is no signal and says
325
325
  // nothing; an empty array is measured and clean. A file that claims nothing is a file the write
326
326
  // gate never judged, which is exactly what the two-step generator bypass produces.
327
327
  for (const file of evidence?.unanchoredChangedSources ?? []) {
328
- const detail = `${file}: 변경된 소스에 @implements 앵커가 없습니다 파일이 무엇을 구현하는지 말하지 않습니다 (승인된 A-SPEC 만들고 앵커를 다십시오)`;
328
+ const detail = `${file}: the changed source has no @implements anchorit does not say what it implements (create an approved A-SPEC and add the anchor)`;
329
329
  problems.push(`[ART-1] ${detail}`);
330
330
  structured.push({ article: 'ART-1', detail });
331
331
  }
332
332
  // @implements A-SPEC-453 — ART-5's second enforcement point. The seal is forgeable; the record
333
333
  // of the approving act is not.
334
334
  for (const id of evidence?.unrecordedApprovals ?? []) {
335
- const detail = `${id}: 승인 상태인데 원장에 승인 행위 기록이 없습니다봉인은 내용 해시라 스스로를 증명하지 못합니다 (spec_approve 승인하십시오)`;
335
+ const detail = `${id}: it is in the approved state but the ledger has no record of the approving act a seal is a content hash and cannot prove itself (approve with spec_approve)`;
336
336
  problems.push(`[ART-5] ${detail}`);
337
337
  structured.push({ article: 'ART-5', detail });
338
338
  }
339
339
  // @implements A-SPEC-455
340
340
  for (const file of evidence?.rolledBackLedgers ?? []) {
341
- const detail = `${file}: 커밋된 원장 이력이 되돌려졌습니다작업본이 커밋본을 접두로 포함하지 않습니다 (원장은 덧붙이기만 합니다; holmes-kit ledger rechain 으로 확인하십시오)`;
341
+ const detail = `${file}: the committed ledger history was rolled back the working copy does not contain the committed copy as a prefix (the ledger is append-only; check with holmes-kit ledger rechain)`;
342
342
  problems.push(`[ART-2] ${detail}`);
343
343
  structured.push({ article: 'ART-2', detail });
344
344
  }
@@ -440,18 +440,18 @@ function acknowledgeStop(violations, pending) {
440
440
  */
441
441
  function describeTokenHealth(raw) {
442
442
  if (raw === undefined || raw === '')
443
- return 'HOLMES_APPROVAL 토큰 없음 (읽기 전용 승인이 필요한 행위만 막힙니다)';
443
+ return 'no HOLMES_APPROVAL token (read-onlyonly actions that need approval are blocked)';
444
444
  let parsed;
445
445
  try {
446
446
  parsed = JSON.parse(raw);
447
447
  }
448
448
  catch {
449
- return 'HOLMES_APPROVAL 무효: JSON 파싱 실패승인이 필요한 행위가 막힙니다';
449
+ return 'HOLMES_APPROVAL invalid: JSON parse failedactions that need approval are blocked';
450
450
  }
451
451
  if (!(0, risk_gate_1.isValidApproval)(parsed)) {
452
- return 'HOLMES_APPROVAL 무효: 필수 필드(actor·token·rationale) 누락 승인이 필요한 행위가 막힙니다';
452
+ return 'HOLMES_APPROVAL invalid: missing required field(s) (actor/token/rationale) — actions that need approval are blocked';
453
453
  }
454
- return 'HOLMES_APPROVAL 유효';
454
+ return 'HOLMES_APPROVAL valid';
455
455
  }
456
456
  function decideStopGuard(wantsBlock, priorConsecutiveBlocks, cap = exports.MAX_CONSECUTIVE_BLOCKS) {
457
457
  if (!wantsBlock)
@@ -523,7 +523,7 @@ function findProjectRoot(specsDir) {
523
523
  if (r.marker !== 'given')
524
524
  return r.root;
525
525
  }
526
- catch { /* 표지가 없으면 짓지 않는다 */ }
526
+ catch { /* if there is no marker, build nothing */ }
527
527
  return null;
528
528
  }
529
529
  return null;
@@ -814,7 +814,7 @@ if (require.main === module) {
814
814
  else if (ackWaiting.length > 0) {
815
815
  // @implements A-SPEC-247 — acknowledged, not clean: name what is waiting so the user sees
816
816
  // the standing approval debt exactly once, and the agent knows the ball is not in its court.
817
- process.stderr.write(`[Holmes-Kit] 승인 대기 오너 결정을 기다리는 항목: ${ackWaiting.join(', ')}. ${(0, npx_bin_1.npxBin)()} holmes-kit approve 에서 결재하십시오. (이 부채로는 재차단하지 않습니다)\n`);
817
+ process.stderr.write(`[Holmes-Kit] awaiting approvalitems waiting on the owner's decision: ${ackWaiting.join(', ')}. Decide them at ${(0, npx_bin_1.npxBin)()} holmes-kit approve. (This debt does not re-block.)\n`);
818
818
  }
819
819
  else if (guard.capped) {
820
820
  process.stderr.write(`[Holmes-Kit] governance gate YIELDING after ${exports.MAX_CONSECUTIVE_BLOCKS} consecutive blocks — issues remain UNRESOLVED:\n${out.reason ?? ''}\n`);
@@ -826,7 +826,7 @@ if (require.main === module) {
826
826
  }
827
827
  catch (err) {
828
828
  process.stderr.write(`[Holmes-Kit Stop Hook] Error: ${err instanceof Error ? err.message : String(err)}\n`);
829
- process.exitCode = 0; // fail-open — exitCode 파이프를 비운 자연 종료(잘림 방지)
829
+ process.exitCode = 0; // fail-open — drain the pipe via exitCode, then exit naturally (avoids truncation)
830
830
  }
831
831
  });
832
832
  }
@@ -1383,10 +1383,23 @@ function makeRawHandlers(store, opts) {
1383
1383
  // itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
1384
1384
  // A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
1385
1385
  // unchanged — the autonomous-OFF path is byte-identical to before.
1386
- if ((0, autonomy_1.autonomousApprovalEnabled)(process.env)
1386
+ // @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
1387
+ // session envelope marker under this project's `.ax/state/` (which an agent cannot write).
1388
+ const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
1389
+ if (autonomyOn
1387
1390
  && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec])) === 'auto') {
1388
1391
  approveResolved = { approval: autonomousApproval(), source: 'autonomous' };
1389
1392
  }
1393
+ else if (autonomyOn) {
1394
+ // @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
1395
+ // dialog is auto-acceptable by an auto-mode client (the protocol cannot tell a human
1396
+ // from an auto-accept), so a governance-critical spec is never offered it. Do nothing
1397
+ // here — approveResolved stays undefined and the fail-closed refuse+enqueue path below
1398
+ // routes the act to the out-of-band human queue (holmes-kit approve). The A-SPEC-532.1
1399
+ // bound ("governance-critical specs never leave the human channel") thus becomes
1400
+ // ENFORCED, not aspirational. Autonomy OFF (the else) and the auto-grade branch above
1401
+ // stay byte-identical to before.
1402
+ }
1390
1403
  else {
1391
1404
  const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1392
1405
  // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
@@ -44,7 +44,7 @@ exports.removeSemanticKey = removeSemanticKey;
44
44
  * an agent assigning the env names (self-granted egress, the HOLMES_ROLE class); this module owns
45
45
  * the storage the human's CLI act writes to. A project-tree file is NEVER a source — a tree file
46
46
  * is a commit-accident surface and readable by every in-session tool, which is exactly what the
47
- * owner's ".env 임시 조치" call-out named.
47
+ * owner's ".env is a stopgap" call-out named.
48
48
  *
49
49
  * Resolution chain, the order being the contract:
50
50
  * 1. HOLMES_SEMANTIC_API_KEY — dedicated name, CI/headless.
@@ -49,13 +49,13 @@ function detectIdCollisions(entries) {
49
49
  sealContents.set(en.approvedDigest, new Set([en.contentDigest]));
50
50
  }
51
51
  const sharedSeals = [...sealContents.entries()].filter(([, cs]) => cs.size > 1).map(([s]) => s).sort();
52
- const parts = [...byKey.entries()].sort().map(([k, ens]) => `${k}[seal=${[...new Set(ens.map((en) => en.approvedDigest ?? '없음'))].sort().join('|')}] ← ${ens.map((en) => en.file).sort().join(', ')}`);
52
+ const parts = [...byKey.entries()].sort().map(([k, ens]) => `${k}[seal=${[...new Set(ens.map((en) => en.approvedDigest ?? 'none'))].sort().join('|')}] ← ${ens.map((en) => en.file).sort().join(', ')}`);
53
53
  issues.push({
54
54
  kind: 'id-collision',
55
55
  id,
56
56
  files: group.map((en) => en.file).sort(),
57
57
  detail: parts.join(' / ')
58
- + (sharedSeals.length > 0 ? ` — 같은 approved_digest 서로 다른 내용이 공유(${sharedSeals.join(', ')}): post-approval edit 또는 봉인 복사 의심` : ''),
58
+ + (sharedSeals.length > 0 ? ` — different contents share the same approved_digest (${sharedSeals.join(', ')}): suspected post-approval edit or seal copy` : ''),
59
59
  });
60
60
  }
61
61
  // --- family-coexistence: a bare A/T-SPEC id alongside its own dot-suffix family ---