oh-my-customcode 2.0.0 → 2.0.2

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.
package/README.md CHANGED
@@ -296,7 +296,7 @@ your-project/
296
296
 
297
297
  ## External Tool Integrations
298
298
 
299
- Starting with v2.0.0, project RTK integration is retired: the public `/rtk-exec` skill, automatic installation, doctor integration, and RTK hooks are removed. Separately installed RTK binaries remain on your machine. Updates automatically remove retired managed files only when their hashes match the previous lockfile. Modified files and files with unknown ownership are preserved and reported as conflicts; retained user skill files may still be discovered automatically. Other tools are optional:
299
+ Starting with v1.1.107, project RTK integration is retired: the public `/rtk-exec` skill, automatic installation, doctor integration, and RTK hooks are removed. Separately installed RTK binaries remain on your machine. Updates automatically remove retired managed files only when their hashes match the previous lockfile. Modified files and files with unknown ownership are preserved and reported as conflicts; retained user skill files may still be discovered automatically. Other tools are optional:
300
300
 
301
301
  | Tool | Purpose | Install | Status |
302
302
  |------|---------|---------|--------|
package/dist/cli/index.js CHANGED
@@ -335,7 +335,7 @@ var init_package = __esm(() => {
335
335
  workspaces: [
336
336
  "packages/*"
337
337
  ],
338
- version: "2.0.0",
338
+ version: "2.0.2",
339
339
  description: "Batteries-included agent harness for Claude Code",
340
340
  type: "module",
341
341
  bin: {
@@ -15800,7 +15800,7 @@ var MESSAGES = {
15800
15800
  "update.file_applied": "Applied update to {{path}}",
15801
15801
  "update.lockfile_regenerated": "Lockfile regenerated ({{files}} files tracked)",
15802
15802
  "update.lockfile_failed": "Failed to regenerate lockfile: {{error}}",
15803
- "update.protected_file_updated": "⟳ Protected file {{file}} in {{component}} updated: {{hint}}",
15803
+ "update.protected_file_updated": "⟳ Protected file {{file}} in {{component}} selected for update: The protected-file policy allows this template update.",
15804
15804
  "update.namespace_synced": "Namespace synced: {{file}} ({{component}})",
15805
15805
  "update.hook_commands_migrated": "Migrated {{count}} hook/statusLine command(s) to CLAUDE_PROJECT_DIR-anchored paths",
15806
15806
  "update.hook_commands_migration_failed": "Failed to migrate hook commands in {{path}}",
@@ -15836,8 +15836,8 @@ var MESSAGES = {
15836
15836
  "update.entry_doc_force_updated": "Entry document force-updated: {{path}}",
15837
15837
  "update.entry_merge_warning": "Entry document merge warning: {{warning}}",
15838
15838
  "update.entry_template_not_found": "Entry document template not found: {{template}}",
15839
- "update.protected_file_force_overwrite": "Protected file {{file}} in {{component}} overwritten: {{hint}}",
15840
- "update.protected_file_skipped": "Protected file {{file}} in {{component}} skipped: {{hint}}",
15839
+ "update.protected_file_force_overwrite": "Protected file {{file}} in {{component}} selected for overwrite: The --force-overwrite-all option bypasses the protected-file preservation policy.",
15840
+ "update.protected_file_skipped": "Protected file {{file}} in {{component}} skipped: The protected-file preservation policy skips this update. Use --force-overwrite-all to override.",
15841
15841
  "update.settings_local_backfill_failed": "Failed to backfill statusLine refreshInterval in {{path}}",
15842
15842
  "general.done": "Done!",
15843
15843
  "general.failed": "Failed",
@@ -15913,7 +15913,7 @@ var MESSAGES = {
15913
15913
  "update.file_applied": "{{path}} 업데이트 적용",
15914
15914
  "update.lockfile_regenerated": "잠금 파일 재생성 완료 ({{files}}개 파일 추적)",
15915
15915
  "update.lockfile_failed": "잠금 파일 재생성 실패: {{error}}",
15916
- "update.protected_file_updated": "⟳ 보호 파일 {{file}} ({{component}}) 업데이트됨: {{hint}}",
15916
+ "update.protected_file_updated": "⟳ 보호 파일 {{file}} ({{component}}) 업데이트 대상: 보호 파일 정책에 따라 템플릿 업데이트가 허용됩니다.",
15917
15917
  "update.namespace_synced": "네임스페이스 동기화: {{file}} ({{component}})",
15918
15918
  "update.hook_commands_migrated": "CLAUDE_PROJECT_DIR 기준 경로로 훅/상태줄 명령 {{count}}개를 마이그레이션했습니다",
15919
15919
  "update.hook_commands_migration_failed": "{{path}}의 훅 명령 마이그레이션에 실패했습니다",
@@ -15949,8 +15949,8 @@ var MESSAGES = {
15949
15949
  "update.entry_doc_force_updated": "진입 문서를 강제로 업데이트했습니다: {{path}}",
15950
15950
  "update.entry_merge_warning": "진입 문서 병합 경고: {{warning}}",
15951
15951
  "update.entry_template_not_found": "진입 문서 템플릿을 찾을 수 없습니다: {{template}}",
15952
- "update.protected_file_force_overwrite": "{{component}}의 보호 파일 {{file}}을(를) 덮어썼습니다: {{hint}}",
15953
- "update.protected_file_skipped": "{{component}}의 보호 파일 {{file}}을(를) 건너뜁니다: {{hint}}",
15952
+ "update.protected_file_force_overwrite": "{{component}}의 보호 파일 {{file}} 덮어쓰기 대상: --force-overwrite-all 옵션에 따라 보호 파일 보존 정책을 적용하지 않습니다.",
15953
+ "update.protected_file_skipped": "{{component}}의 보호 파일 {{file}}을(를) 건너뜁니다: 보호 파일 보존 정책에 따라 업데이트를 건너뜁니다. 덮어쓰려면 --force-overwrite-all 옵션을 사용하십시오.",
15954
15954
  "update.settings_local_backfill_failed": "{{path}}의 statusLine refreshInterval 보충에 실패했습니다",
15955
15955
  "general.done": "완료!",
15956
15956
  "general.failed": "실패",
@@ -22135,6 +22135,29 @@ async function runFullUpdatePostProcessing(options, result, config, customizatio
22135
22135
  });
22136
22136
  }
22137
22137
  }
22138
+ async function getUpdateVersionDecision(installedVersion, cliVersion) {
22139
+ if (!(installedVersion !== "0.0.0" && compareSemver2(installedVersion, cliVersion) > 0)) {
22140
+ return "standard";
22141
+ }
22142
+ if (installedVersion !== "2.0.0") {
22143
+ return "blocked";
22144
+ }
22145
+ const match = /^1\.1\.(0|[1-9]\d*)$/.exec(cliVersion);
22146
+ if (!match) {
22147
+ return "blocked";
22148
+ }
22149
+ const patch = Number(match[1]);
22150
+ if (!Number.isSafeInteger(patch) || patch < 107) {
22151
+ return "blocked";
22152
+ }
22153
+ const manifestPath = resolveTemplatePath(getProviderLayout().manifestFile);
22154
+ if (!await fileExists(manifestPath)) {
22155
+ return "blocked";
22156
+ }
22157
+ const manifest = await readJsonFile(manifestPath);
22158
+ const correctionAllowed = typeof manifest === "object" && manifest !== null && !Array.isArray(manifest) && "version" in manifest && manifest.version === cliVersion;
22159
+ return correctionAllowed ? "correction" : "blocked";
22160
+ }
22138
22161
  function compareSemver2(a, b) {
22139
22162
  const pa = a.split(".").map(Number);
22140
22163
  const pb = b.split(".").map(Number);
@@ -22195,7 +22218,8 @@ async function update(options) {
22195
22218
  const config = await loadConfig(options.targetDir);
22196
22219
  result.previousVersion = config.version;
22197
22220
  const cliVersion = package_default.version;
22198
- if (result.previousVersion !== "0.0.0" && compareSemver2(result.previousVersion, cliVersion) > 0) {
22221
+ const versionDecision = await getUpdateVersionDecision(result.previousVersion, cliVersion);
22222
+ if (versionDecision === "blocked") {
22199
22223
  result.success = false;
22200
22224
  result.error = `Downgrade prevented: project has v${result.previousVersion} but CLI is v${cliVersion}. Update the CLI first: npm install -g oh-my-customcode@latest`;
22201
22225
  return result;
@@ -22204,6 +22228,11 @@ async function update(options) {
22204
22228
  return result;
22205
22229
  }
22206
22230
  const updateCheck = await checkForUpdates(options.targetDir);
22231
+ if (versionDecision === "correction" && updateCheck.latestVersion !== cliVersion) {
22232
+ result.success = false;
22233
+ result.error = `Version correction prevented: CLI is v${cliVersion} but templates are v${updateCheck.latestVersion}. Reinstall the CLI: npm install -g oh-my-customcode@latest`;
22234
+ return result;
22235
+ }
22207
22236
  result.newVersion = updateCheck.latestVersion;
22208
22237
  if (!updateCheck.hasUpdates && !options.force) {
22209
22238
  info("update.no_updates");
@@ -22377,22 +22406,19 @@ async function updateComponent(targetDir, component, customizations, options, co
22377
22406
  if (options.forceOverwriteAll) {
22378
22407
  warn("update.protected_file_force_overwrite", {
22379
22408
  file: protectedPath,
22380
- component,
22381
- hint: "File contains AI behavioral constraints. Overwriting because --force-overwrite-all was set."
22409
+ component
22382
22410
  });
22383
22411
  } else {
22384
22412
  warn("update.protected_file_skipped", {
22385
22413
  file: protectedPath,
22386
- component,
22387
- hint: "File was modified by user and preserved. Use --force-overwrite-all to override."
22414
+ component
22388
22415
  });
22389
22416
  }
22390
22417
  }
22391
22418
  for (const updatedPath of protectedUpdatedPaths) {
22392
22419
  info("update.protected_file_updated", {
22393
22420
  file: updatedPath,
22394
- component,
22395
- hint: "Protected file updated (unmodified by user, matches lockfile hash)."
22421
+ component
22396
22422
  });
22397
22423
  }
22398
22424
  skipPaths.push(...protectedSkipPaths);
package/dist/index.js CHANGED
@@ -416,7 +416,7 @@ var MESSAGES = {
416
416
  "update.file_applied": "Applied update to {{path}}",
417
417
  "update.lockfile_regenerated": "Lockfile regenerated ({{files}} files tracked)",
418
418
  "update.lockfile_failed": "Failed to regenerate lockfile: {{error}}",
419
- "update.protected_file_updated": "⟳ Protected file {{file}} in {{component}} updated: {{hint}}",
419
+ "update.protected_file_updated": "⟳ Protected file {{file}} in {{component}} selected for update: The protected-file policy allows this template update.",
420
420
  "update.namespace_synced": "Namespace synced: {{file}} ({{component}})",
421
421
  "update.hook_commands_migrated": "Migrated {{count}} hook/statusLine command(s) to CLAUDE_PROJECT_DIR-anchored paths",
422
422
  "update.hook_commands_migration_failed": "Failed to migrate hook commands in {{path}}",
@@ -452,8 +452,8 @@ var MESSAGES = {
452
452
  "update.entry_doc_force_updated": "Entry document force-updated: {{path}}",
453
453
  "update.entry_merge_warning": "Entry document merge warning: {{warning}}",
454
454
  "update.entry_template_not_found": "Entry document template not found: {{template}}",
455
- "update.protected_file_force_overwrite": "Protected file {{file}} in {{component}} overwritten: {{hint}}",
456
- "update.protected_file_skipped": "Protected file {{file}} in {{component}} skipped: {{hint}}",
455
+ "update.protected_file_force_overwrite": "Protected file {{file}} in {{component}} selected for overwrite: The --force-overwrite-all option bypasses the protected-file preservation policy.",
456
+ "update.protected_file_skipped": "Protected file {{file}} in {{component}} skipped: The protected-file preservation policy skips this update. Use --force-overwrite-all to override.",
457
457
  "update.settings_local_backfill_failed": "Failed to backfill statusLine refreshInterval in {{path}}",
458
458
  "general.done": "Done!",
459
459
  "general.failed": "Failed",
@@ -529,7 +529,7 @@ var MESSAGES = {
529
529
  "update.file_applied": "{{path}} 업데이트 적용",
530
530
  "update.lockfile_regenerated": "잠금 파일 재생성 완료 ({{files}}개 파일 추적)",
531
531
  "update.lockfile_failed": "잠금 파일 재생성 실패: {{error}}",
532
- "update.protected_file_updated": "⟳ 보호 파일 {{file}} ({{component}}) 업데이트됨: {{hint}}",
532
+ "update.protected_file_updated": "⟳ 보호 파일 {{file}} ({{component}}) 업데이트 대상: 보호 파일 정책에 따라 템플릿 업데이트가 허용됩니다.",
533
533
  "update.namespace_synced": "네임스페이스 동기화: {{file}} ({{component}})",
534
534
  "update.hook_commands_migrated": "CLAUDE_PROJECT_DIR 기준 경로로 훅/상태줄 명령 {{count}}개를 마이그레이션했습니다",
535
535
  "update.hook_commands_migration_failed": "{{path}}의 훅 명령 마이그레이션에 실패했습니다",
@@ -565,8 +565,8 @@ var MESSAGES = {
565
565
  "update.entry_doc_force_updated": "진입 문서를 강제로 업데이트했습니다: {{path}}",
566
566
  "update.entry_merge_warning": "진입 문서 병합 경고: {{warning}}",
567
567
  "update.entry_template_not_found": "진입 문서 템플릿을 찾을 수 없습니다: {{template}}",
568
- "update.protected_file_force_overwrite": "{{component}}의 보호 파일 {{file}}을(를) 덮어썼습니다: {{hint}}",
569
- "update.protected_file_skipped": "{{component}}의 보호 파일 {{file}}을(를) 건너뜁니다: {{hint}}",
568
+ "update.protected_file_force_overwrite": "{{component}}의 보호 파일 {{file}} 덮어쓰기 대상: --force-overwrite-all 옵션에 따라 보호 파일 보존 정책을 적용하지 않습니다.",
569
+ "update.protected_file_skipped": "{{component}}의 보호 파일 {{file}}을(를) 건너뜁니다: 보호 파일 보존 정책에 따라 업데이트를 건너뜁니다. 덮어쓰려면 --force-overwrite-all 옵션을 사용하십시오.",
570
570
  "update.settings_local_backfill_failed": "{{path}}의 statusLine refreshInterval 보충에 실패했습니다",
571
571
  "general.done": "완료!",
572
572
  "general.failed": "실패",
@@ -2906,7 +2906,7 @@ var package_default = {
2906
2906
  workspaces: [
2907
2907
  "packages/*"
2908
2908
  ],
2909
- version: "2.0.0",
2909
+ version: "2.0.2",
2910
2910
  description: "Batteries-included agent harness for Claude Code",
2911
2911
  type: "module",
2912
2912
  bin: {
@@ -5901,6 +5901,29 @@ async function runFullUpdatePostProcessing(options, result, config, customizatio
5901
5901
  });
5902
5902
  }
5903
5903
  }
5904
+ async function getUpdateVersionDecision(installedVersion, cliVersion) {
5905
+ if (!(installedVersion !== "0.0.0" && compareSemver(installedVersion, cliVersion) > 0)) {
5906
+ return "standard";
5907
+ }
5908
+ if (installedVersion !== "2.0.0") {
5909
+ return "blocked";
5910
+ }
5911
+ const match = /^1\.1\.(0|[1-9]\d*)$/.exec(cliVersion);
5912
+ if (!match) {
5913
+ return "blocked";
5914
+ }
5915
+ const patch = Number(match[1]);
5916
+ if (!Number.isSafeInteger(patch) || patch < 107) {
5917
+ return "blocked";
5918
+ }
5919
+ const manifestPath = resolveTemplatePath(getProviderLayout().manifestFile);
5920
+ if (!await fileExists(manifestPath)) {
5921
+ return "blocked";
5922
+ }
5923
+ const manifest = await readJsonFile(manifestPath);
5924
+ const correctionAllowed = typeof manifest === "object" && manifest !== null && !Array.isArray(manifest) && "version" in manifest && manifest.version === cliVersion;
5925
+ return correctionAllowed ? "correction" : "blocked";
5926
+ }
5904
5927
  function compareSemver(a, b) {
5905
5928
  const pa = a.split(".").map(Number);
5906
5929
  const pb = b.split(".").map(Number);
@@ -5961,7 +5984,8 @@ async function update(options) {
5961
5984
  const config = await loadConfig(options.targetDir);
5962
5985
  result.previousVersion = config.version;
5963
5986
  const cliVersion = package_default.version;
5964
- if (result.previousVersion !== "0.0.0" && compareSemver(result.previousVersion, cliVersion) > 0) {
5987
+ const versionDecision = await getUpdateVersionDecision(result.previousVersion, cliVersion);
5988
+ if (versionDecision === "blocked") {
5965
5989
  result.success = false;
5966
5990
  result.error = `Downgrade prevented: project has v${result.previousVersion} but CLI is v${cliVersion}. Update the CLI first: npm install -g oh-my-customcode@latest`;
5967
5991
  return result;
@@ -5970,6 +5994,11 @@ async function update(options) {
5970
5994
  return result;
5971
5995
  }
5972
5996
  const updateCheck = await checkForUpdates(options.targetDir);
5997
+ if (versionDecision === "correction" && updateCheck.latestVersion !== cliVersion) {
5998
+ result.success = false;
5999
+ result.error = `Version correction prevented: CLI is v${cliVersion} but templates are v${updateCheck.latestVersion}. Reinstall the CLI: npm install -g oh-my-customcode@latest`;
6000
+ return result;
6001
+ }
5973
6002
  result.newVersion = updateCheck.latestVersion;
5974
6003
  if (!updateCheck.hasUpdates && !options.force) {
5975
6004
  info("update.no_updates");
@@ -6164,22 +6193,19 @@ async function updateComponent(targetDir, component, customizations, options, co
6164
6193
  if (options.forceOverwriteAll) {
6165
6194
  warn("update.protected_file_force_overwrite", {
6166
6195
  file: protectedPath,
6167
- component,
6168
- hint: "File contains AI behavioral constraints. Overwriting because --force-overwrite-all was set."
6196
+ component
6169
6197
  });
6170
6198
  } else {
6171
6199
  warn("update.protected_file_skipped", {
6172
6200
  file: protectedPath,
6173
- component,
6174
- hint: "File was modified by user and preserved. Use --force-overwrite-all to override."
6201
+ component
6175
6202
  });
6176
6203
  }
6177
6204
  }
6178
6205
  for (const updatedPath of protectedUpdatedPaths) {
6179
6206
  info("update.protected_file_updated", {
6180
6207
  file: updatedPath,
6181
- component,
6182
- hint: "Protected file updated (unmodified by user, matches lockfile hash)."
6208
+ component
6183
6209
  });
6184
6210
  }
6185
6211
  skipPaths.push(...protectedSkipPaths);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "2.0.0",
6
+ "version": "2.0.2",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -38,7 +38,13 @@ fi
38
38
 
39
39
  source_field=$(printf '%s' "$input" | jq -r '.source // "unknown"' 2>/dev/null) || source_field="unknown"
40
40
 
41
- # #1770: honor CLAUDE_PROJECT_DIR first; fallback git toplevel (or pwd), not script location.
41
+ # #1770 / B-L1: nonempty CLAUDE_PROJECT_DIR selects the anchor. When manually
42
+ # running inside a Git monorepo package, set it to that package to select its
43
+ # CLAUDE.md; unset/empty instead selects the Git toplevel, then pwd if Git fails.
44
+ # The script location is never the fallback project anchor.
45
+ # B-L2: a nonempty invalid anchor is authoritative; there is no Git/cwd retry.
46
+ # If its CLAUDE.md is missing (including a missing directory or a file anchor),
47
+ # the file check below returns silently with exit 0, preserving advisory behavior.
42
48
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
43
49
  CLAUDE_MD="$PROJECT_ROOT/CLAUDE.md"
44
50
 
@@ -29,22 +29,67 @@ if ! command -v jq >/dev/null 2>&1; then
29
29
  exit 0
30
30
  fi
31
31
 
32
- # ── session_id 추출 ──
33
- session_id=$(printf '%s\n' "$input" | jq -r '.session_id // empty' 2>/dev/null)
32
+ # ── 부모 필드 추출: 한 jq 생산자, 독립 오류 fallback 유지 ──
33
+ # jq -j는 session_id의 원래 raw/pretty 출력을 유지한다. 문자열의 실제 NUL만
34
+ # 제거하여 필드 구분자와 충돌하지 않게 하고, 각 필드의 끝 LF는 기존 command
35
+ # substitution처럼 제거한다. JSON 문자열 안의 escaped NUL은 그대로 유지한다.
36
+ # slurp 후에도 입력별 출력 순서와 오류 전 부분 출력 + fallback을 유지한다.
37
+ # process substitution의 jq 실패를 잃지 않도록 별도 종료 상태 필드를 보낸다.
38
+ parent_fields=()
39
+ while IFS= read -r -d '' parent_field; do
40
+ parent_fields[${#parent_fields[@]}]="$parent_field"
41
+ done < <(
42
+ if printf '%s\n' "$input" | jq -sj '
43
+ . as $inputs
44
+ | [try ($inputs[] | .background_tasks // [] | tojson) catch "[]"] as $bg
45
+ | [try ($inputs[] | .session_crons // [] | tojson) catch "[]"] as $crons
46
+ | [try ($bg[] | fromjson | length) catch 0] as $bg_count
47
+ | [try ($crons[] | fromjson | length) catch 0] as $cron_count
48
+ | [try ($bg[] | fromjson
49
+ | [.[] | select(.status? == "running" or .status? == "pending" or .status? == "in_progress")]
50
+ | length) catch 0] as $dangling
51
+ | ($inputs[] | .session_id // empty
52
+ | if type == "string" then gsub("\u0000"; "") else . end
53
+ | (., "\n")),
54
+ "\u0000",
55
+ ($bg | join("\n")), "\u0000",
56
+ ($crons | join("\n")), "\u0000",
57
+ ($bg_count | map(tostring) | join("\n")), "\u0000",
58
+ ($cron_count | map(tostring) | join("\n")), "\u0000",
59
+ ($dangling | map(tostring) | join("\n")), "\u0000"
60
+ ' 2>/dev/null; then
61
+ parent_status=0
62
+ else
63
+ parent_status=$?
64
+ fi
65
+ # An extra separator terminates partial last fields from a failed producer.
66
+ printf '\0%s\0' "$parent_status"
67
+ )
68
+ parent_status="${parent_fields[${#parent_fields[@]}-1]}"
69
+ if [ "$parent_status" -ne 0 ]; then
70
+ exit "$parent_status"
71
+ fi
72
+ if [ "${#parent_fields[@]}" -ne 8 ] || [ -n "${parent_fields[6]}" ]; then
73
+ exit 1
74
+ fi
75
+ for parent_index in 0 1 2 3 4 5; do
76
+ while [[ "${parent_fields[$parent_index]}" == *$'\n' ]]; do
77
+ parent_fields[$parent_index]="${parent_fields[$parent_index]%$'\n'}"
78
+ done
79
+ done
80
+ session_id="${parent_fields[0]}"
34
81
  if [ -z "$session_id" ]; then
35
82
  printf '%s\n' "$input"
36
83
  exit 0
37
84
  fi
38
85
 
39
- # ── Phase 2 (#1196): background_tasks / session_crons 추출 (CC v2.1.145+) ──
40
- # 신규 필드 미존재 시 빈 배열로 처리 (graceful degrade).
41
- bg_tasks_json=$(printf '%s\n' "$input" | jq -c '.background_tasks // []' 2>/dev/null || echo '[]')
42
- session_crons_json=$(printf '%s\n' "$input" | jq -c '.session_crons // []' 2>/dev/null || echo '[]')
43
- bg_tasks_count=$(printf '%s\n' "$bg_tasks_json" | jq 'length' 2>/dev/null || echo 0)
44
- session_crons_count=$(printf '%s\n' "$session_crons_json" | jq 'length' 2>/dev/null || echo 0)
45
-
46
- # 미완료 백그라운드 태스크: running/pending/in_progress 상태 카운트
47
- bg_dangling_count=$(printf '%s\n' "$bg_tasks_json" | jq '[.[] | select(.status? == "running" or .status? == "pending" or .status? == "in_progress")] | length' 2>/dev/null || echo 0)
86
+ # ── Phase 2 (#1196): background_tasks / session_crons (CC v2.1.145+) ──
87
+ # Missing/null/false fields retain [], and each count retains its own fallback.
88
+ bg_tasks_json="${parent_fields[1]}"
89
+ session_crons_json="${parent_fields[2]}"
90
+ bg_tasks_count="${parent_fields[3]}"
91
+ session_crons_count="${parent_fields[4]}"
92
+ bg_dangling_count="${parent_fields[5]}"
48
93
 
49
94
  # ── 경로 결정 (환경변수 override 지원) ──
50
95
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -239,7 +284,8 @@ WORKER_EOF
239
284
  chmod +x "$WORKER_SCRIPT"
240
285
 
241
286
  # ── 백그라운드로 실행 (Stop hook 시간 예산 <3s 준수) ──
242
- # setsid로 부모 종료 후에도 실행 지속; 완료 후 임시 파일 자정리
287
+ # 전체 subshell의 표준 입출력을 분리하여 부모의 파이프를 붙들지 않는다.
288
+ # 분석은 비동기이며 완료 후 임시 파일을 제거한다. setsid/session 생존 보장은 없다.
243
289
  WORKER_ERR_LOG="/tmp/.claude-reflection-err-${PPID}.log"
244
290
 
245
291
  (
@@ -250,10 +296,9 @@ WORKER_ERR_LOG="/tmp/.claude-reflection-err-${PPID}.log"
250
296
  "$SCRIPT_DIR" \
251
297
  "$bg_tasks_json" \
252
298
  "$session_crons_json" \
253
- "$bg_dangling_count" \
254
- 2>>"$WORKER_ERR_LOG"
299
+ "$bg_dangling_count"
255
300
  rm -f "$WORKER_SCRIPT"
256
- ) &
301
+ ) </dev/null >/dev/null 2>>"$WORKER_ERR_LOG" &
257
302
  disown $! 2>/dev/null || true
258
303
 
259
304
  # ── 즉시 stdin pass-through 후 exit 0 (Stop hook 체인 유지) ──
@@ -536,8 +536,92 @@ input=$(cat)
536
536
  # Swallow it instead: exit 0 with no stdout, exactly as if the hook had not
537
537
  # run. "jq -e" exits non-zero both for a parse error and for a false/null
538
538
  # result, so one guard covers every non-object shape.
539
- printf '%s' "$input" | jq -e 'type == "object"' >/dev/null 2>&1 || exit 0
539
+ # Common string fields can share one jq producer. Unusual types and embedded
540
+ # NUL retain the original extraction path: command substitution has platform-
541
+ # specific NUL handling, and independent jq failures are part of the contract.
542
+ extraction_mode=$(printf '%s' "$input" | jq -er '
543
+ if type != "object" then false
544
+ elif any(.. | strings; contains("\u0000")) then "legacy"
545
+ elif ((.tool_name // "unknown" | type) != "string") then "legacy"
546
+ elif ((.tool_input != null) and ((.tool_input | type) != "object")) then "legacy"
547
+ elif ((.tool_output != null) and ((.tool_output | type) != "object")) then "legacy"
548
+ elif ((.tool_input.file_path // "" | type) != "string")
549
+ or ((.tool_input.command // "" | type) != "string")
550
+ or ((.tool_input.old_string // .tool_input.content // .tool_input.new_string // "" | type) != "string")
551
+ then "legacy" else "batched" end
552
+ ' 2>/dev/null) || exit 0
553
+
554
+ # NUL-delimited fields are safe only after the guard above excludes embedded
555
+ # NUL. A final producer-status record makes jq failures observable; process
556
+ # substitution alone would discard its exit status. jq -j preserves -r scalar
557
+ # rendering without an added newline; trim actual trailing LF just as $(...) did.
558
+ _read_stuck_fields() {
559
+ local filter="$1" field status
560
+ STUCK_FIELDS=()
561
+ while IFS= read -r -d '' field; do
562
+ while [[ "$field" == *$'\n' ]]; do field="${field%$'\n'}"; done
563
+ STUCK_FIELDS[${#STUCK_FIELDS[@]}]="$field"
564
+ done < <(
565
+ if printf '%s' "$input" | jq -j "$filter"; then
566
+ printf '%s\0' '__OMCUSTOM_JQ_STATUS__0'
567
+ else
568
+ printf '__OMCUSTOM_JQ_STATUS__%s\0' "$?"
569
+ fi
570
+ )
571
+ status="${STUCK_FIELDS[${#STUCK_FIELDS[@]}-1]:-}"
572
+ case "$status" in
573
+ __OMCUSTOM_JQ_STATUS__*) status="${status#__OMCUSTOM_JQ_STATUS__}" ;;
574
+ *) return 1 ;;
575
+ esac
576
+ unset "STUCK_FIELDS[$((${#STUCK_FIELDS[@]} - 1))]"
577
+ return "$status"
578
+ }
579
+
580
+ TARGET_KEY_CAP=1000
581
+
582
+ truncate_key() {
583
+ local v="$1"
584
+ if [ "${#v}" -gt "$TARGET_KEY_CAP" ]; then
585
+ printf '%s' "${v:0:$TARGET_KEY_CAP}#len=${#v}"
586
+ else
587
+ printf '%s' "$v"
588
+ fi
589
+ }
540
590
 
591
+ # Batched extraction changes no classifier or detector inputs. Preview retains
592
+ # its original byte-cap pipeline, including broken-pipe tolerance.
593
+ if [ "$extraction_mode" = "batched" ]; then
594
+ _read_stuck_fields '(.tool_name // "unknown"), "\u0000",
595
+ (if (.tool_name // "unknown") == "Bash"
596
+ then (.tool_input.command // .tool_input.file_path // "")
597
+ else (.tool_input.file_path // "") end), "\u0000",
598
+ (.tool_output.is_error // false), "\u0000"'
599
+ [ "${#STUCK_FIELDS[@]}" -eq 3 ] || exit 1
600
+ tool_name="${STUCK_FIELDS[0]}"
601
+ if [ "$tool_name" = "Bash" ]; then
602
+ file_path=""
603
+ target_key=$(truncate_key "${STUCK_FIELDS[1]}")
604
+ else
605
+ file_path_full="${STUCK_FIELDS[1]}"
606
+ file_path=$(printf '%s' "$file_path_full" | head -c "$TARGET_KEY_CAP" || true)
607
+ target_key=$(truncate_key "$file_path_full")
608
+ fi
609
+ is_error="${STUCK_FIELDS[2]}"
610
+ output_preview=$(printf '%s' "$input" \
611
+ | jq -r '(.tool_output.output // "" | tostring)[0:200]' \
612
+ | head -c 200 || true)
613
+ _read_stuck_fields '(.tool_input.command // ""), "\u0000",
614
+ (if (.tool_name == "Edit" or .tool_name == "Write")
615
+ then ((.tool_input.old_string // .tool_input.content // .tool_input.new_string // "")
616
+ | gsub("[[:space:]]+"; " ") | ltrimstr(" ") | rtrimstr(" ")
617
+ | (length as $l | if $l <= 120 then "\($l)#\(.)"
618
+ else ((explode | add // 0) as $sum
619
+ | "\($l)#\($sum)#\(.[0:80])#\(.[-40:])") end))
620
+ else "" end), "\u0000"'
621
+ [ "${#STUCK_FIELDS[@]}" -eq 2 ] || exit 1
622
+ raw_command="${STUCK_FIELDS[0]}"
623
+ edit_hash="${STUCK_FIELDS[1]}"
624
+ else
541
625
  # Extract tool info
542
626
  tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
543
627
  # Bash commands are NOT files. Feeding tool_input.command into file_path made
@@ -552,16 +636,6 @@ tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
552
636
  # remove that collision class, so truncate_key() ALSO appends the ORIGINAL
553
637
  # length: two values that share the capped prefix but differ in total length
554
638
  # no longer produce the same key (M-3).
555
- TARGET_KEY_CAP=1000
556
-
557
- truncate_key() {
558
- local v="$1"
559
- if [ "${#v}" -gt "$TARGET_KEY_CAP" ]; then
560
- printf '%s' "${v:0:$TARGET_KEY_CAP}#len=${#v}"
561
- else
562
- printf '%s' "$v"
563
- fi
564
- }
565
639
 
566
640
  if [ "$tool_name" = "Bash" ]; then
567
641
  file_path=""
@@ -627,13 +701,35 @@ if [ "$tool_name" = "Edit" ] || [ "$tool_name" = "Write" ]; then
627
701
  end)')
628
702
  fi
629
703
 
704
+ fi
705
+
630
706
  # History entries are written by jq, so their "path" values are JSON-ENCODED.
631
707
  # Matching them requires the SAME encoding, not the raw text: a raw value
632
708
  # containing a quote (common in Bash commands) can never match the encoded
633
709
  # form. The previous BRE escaping was doubly broken — in POSIX BRE "( ) + ? { |"
634
710
  # are literals, so escaping them turned them INTO operators and silently
635
711
  # stopped matching. Encode once here and match with grep -F (fixed string).
636
- target_key_json=$(jq -n --arg v "$target_key" '$v')
712
+ # JSON-encoded literals contain no raw NUL or LF, including legacy fields.
713
+ encoded_matches=()
714
+ while IFS= read -r -d '' encoded_match; do
715
+ encoded_matches[${#encoded_matches[@]}]="$encoded_match"
716
+ done < <(
717
+ if jq -nj --arg path "$target_key" --arg edit "$edit_hash" \
718
+ '($path | tojson), "\u0000", ($edit | tojson), "\u0000"'; then
719
+ printf '%s\0' '__OMCUSTOM_JQ_STATUS__0'
720
+ else
721
+ printf '__OMCUSTOM_JQ_STATUS__%s\0' "$?"
722
+ fi
723
+ )
724
+ encoded_status="${encoded_matches[${#encoded_matches[@]}-1]:-}"
725
+ case "$encoded_status" in
726
+ __OMCUSTOM_JQ_STATUS__0) ;;
727
+ __OMCUSTOM_JQ_STATUS__*) exit "${encoded_status#__OMCUSTOM_JQ_STATUS__}" ;;
728
+ *) exit 1 ;;
729
+ esac
730
+ [ "${#encoded_matches[@]}" -eq 3 ] || exit 1
731
+ target_key_json="${encoded_matches[0]}"
732
+ edit_hash_json="${encoded_matches[1]}"
637
733
  path_match="\"path\":${target_key_json}"
638
734
 
639
735
  # #1649 follow-up: edit_hash now preserves punctuation (see the edit_hash
@@ -642,7 +738,7 @@ path_match="\"path\":${target_key_json}"
642
738
  # (--arg ehash "$edit_hash" -> jq serializes it), so Check 1 / Check 3 below
643
739
  # must match it with the SAME JSON-encoded literal, not the raw string — same
644
740
  # fix as path_match above, applied to edit_hash.
645
- edit_hash_json=$(jq -n --arg v "$edit_hash" '$v')
741
+ # edit_hash_json was encoded together with target_key_json above.
646
742
  edit_hash_match="\"edit_hash\":${edit_hash_json}"
647
743
 
648
744
  is_readonly="false"
@@ -651,7 +747,7 @@ if [ "$tool_name" = "Bash" ]; then
651
747
  fi
652
748
 
653
749
  # Session-scoped history
654
- HISTORY_FILE="/tmp/.claude-tool-history-${PPID}"
750
+ HISTORY_FILE="${OMCUSTOM_STUCK_HISTORY_FILE:-/tmp/.claude-tool-history-${PPID}}"
655
751
 
656
752
  # Create entry
657
753
  timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -86,7 +86,7 @@ zsh는 미인용 `$var`를 단어분할하지 않습니다(#1683 #5) — 분할
86
86
 
87
87
  도구 이름 ≠ 그 프로그램(#1590): 사용 전 `type <tool>`로 실체를 확인합니다 — 이 저장소 Bash의 `grep`은 `.gitignore`를 존중하는 셸 함수이므로 저장소 전수조사는 `git grep`을 표준으로 합니다.
88
88
 
89
- Bash `cd`는 세션 cwd로 남습니다(#1778, #1767): 훅 명령은 `CLAUDE_PROJECT_DIR`로 고정되어 지속 `cd`에 영향받지 않지만, 상대 경로를 내부에서 읽는 스크립트·도구와 이후 명령의 상대 경로는 어긋날 수 있습니다 — 절대 경로나 서브셸 `( cd <dir> && … )`만 사용하고 지속 `cd`는 남기지 않습니다.
89
+ Bash `cd`는 세션 cwd로 남습니다(#1778, #1767): 훅 명령은 `CLAUDE_PROJECT_DIR`로 고정되어 지속 `cd`에 영향받지 않지만, 상대 경로를 내부에서 읽는 스크립트·도구와 이후 명령의 상대 경로는 어긋날 수 있습니다 — 절대 경로나 서브셸 `( cd <dir> && … )`만 사용하고 지속 `cd`는 남기지 않습니다. `cd X && ( … )`도 `cd`가 서브셸 밖에 놓인 지속 `cd`이므로 금지합니다(#1816) — 괄호는 `cd` 앞에서 열어야 합니다.
90
90
 
91
91
  <!-- DETAIL: tool name vs program
92
92
  > **도구 이름 ≠ 그 프로그램 (#1590)**: 도구를 쓰기 전에 `type <tool>`로 실체를 확인한다. Bash 도구의 `grep`은 `~/.claude/shell-snapshots/snapshot-zsh-*.sh`의 **셸 함수**이며 `ugrep --ignore-files`에 위임한다. 그 결과 `.gitignore`의 리터럴 `CLAUDE.md` 패턴을 존중해, **force-tracked 파일을 재귀 탐색에서 조용히 누락**한다(에러 없이 exit 0). 명시 경로를 준 grep은 정상 동작하므로 **traversal만 영향**을 받는다. 실측(2026-08-15): 동일 패턴·동일 대상에 대해 셸 함수 36 / `command grep` 43 / `git grep` 38 히트 — 셸 함수만 `CLAUDE.md`를 0 히트로 놓쳤다. 진단 함정: `git check-ignore`는 **index-aware**라 tracked 파일에 "not ignored"(exit 1)를 반환한다 — 원인을 보려면 `git check-ignore --no-index`를 써야 한다. 처방: 저장소 전수 조사는 `git grep`을 표준으로 한다(R017 Count Sync cross-ref). Origin: #1590.
@@ -47,8 +47,9 @@ Tier를 섞으면(예: frontmatter에 Tier-3 전용 값을 쓰거나 그 반대)
47
47
  | Full ID | Use Case |
48
48
  |---------|----------|
49
49
  | `claude-haiku-4-5` | Fast, cheap tasks |
50
+ | `claude-haiku-5-5` | Default Haiku model on the Anthropic API (v2.1.293+); 1M context |
50
51
  | `claude-sonnet-5` | Native 1M context; previous-generation Sonnet (v2.1.197+; on the Anthropic API, `claude-sonnet-5-5` is now the default Sonnet model, v2.1.284+) |
51
- | `claude-sonnet-5-5` | Now the default Sonnet model on the Anthropic API (v2.1.284+); 1M context, $2/$10 per Mtok, $0.20/Mtok cache reads |
52
+ | `claude-sonnet-5-5` | Now the default Sonnet model on the Anthropic API (v2.1.284+); 1M context, $2/$10 per Mtok, $0.10/Mtok cache reads in CC cost figures (v2.1.296; was $0.20) |
52
53
  | `claude-opus-4-6` | Opus, previous generation |
53
54
  | `claude-opus-4-8` | Opus, previous generation; supports xhigh effort |
54
55
  | `claude-opus-5` | Opus, previous generation (GA); native 1M context, fast mode at $10/$50 per Mtok |
@@ -164,7 +165,7 @@ Settings-level 가용성/과부하 failover — per-agent `model:` frontmatter
164
165
  ### Safe Mode & Bundled Skill Control (CC v2.1.169+)
165
166
 
166
167
  <!-- ARCHIVED CC version note (historical):
167
- > **v2.1.169+**: `--safe-mode` (and `CLAUDE_CODE_SAFE_MODE`) starts Claude Code with ALL customizations disabled (CLAUDE.md, plugins, skills, hooks, MCP servers) — use it to isolate whether a project customization (agent/skill/hook) causes a regression. The `disableBundledSkills` setting (and `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS` env) hides bundled skills, workflows, and built-in slash commands from the model — useful when bundled skills conflict with or duplicate project skills (R006 skill-surface management). Note: `disableBundledSkills` hides skills from the model but is a CC platform setting, distinct from the advisory `skills:` frontmatter field (which is documentation metadata, not a runtime allowlist).
168
+ > **v2.1.169+**: `--safe-mode` (and `CLAUDE_CODE_SAFE_MODE`) starts Claude Code with ALL customizations disabled (CLAUDE.md, plugins, skills, hooks, MCP servers) — use it to isolate whether a project customization (agent/skill/hook) causes a regression. The `disableBundledSkills` setting (and `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS` env) hides bundled skills, workflows, and built-in slash commands from the model — useful when bundled skills conflict with or duplicate project skills (R006 skill-surface management). Note: `disableBundledSkills` hides skills from the model but is a CC platform setting, distinct from the `skills:` frontmatter field (which preloads the listed skills into the subagent but is not a runtime allowlist).
168
169
  -->
169
170
 
170
171
  <!-- ARCHIVED CC version notes (historical):
@@ -193,9 +194,9 @@ Key optional fields: `memory`, `effort`, `skills`, `soul`, `isolation`, `backgro
193
194
  ### Note on `skills:` field
194
195
 
195
196
  <!-- DETAIL: skills: field full rationale
196
- The `skills:` frontmatter field is **advisory metadata** consumed by oh-my-customcode tooling (graph-builder, mgr-sauron) for documentation and validation. It is **NOT a runtime allowlist** — Claude Code does not filter the available skills based on this field, and subagents can invoke any registered skill regardless of what `skills:` declares. Use it to document a subagent's intended skill dependencies; do not rely on it for access control.
197
+ The `skills:` frontmatter field is read by oh-my-customcode tooling (graph-builder, mgr-sauron) for documentation and validation, and Claude Code itself PRELOADS the listed skills into the subagent (context cost; v2.1.295: "Changed subagents to preload at most 32 skills from the `skills` field, each once; a subagent with the Skill tool can still invoke the rest" — preloading predates 2.1.295: the 2.0.43 CHANGELOG entry is the primary source — "Added skills frontmatter field to declare skills to auto-load for subagents" — and the 2.1.265 entry also refers to "preloaded skills"). It is **NOT a runtime allowlist** — Claude Code does not restrict skill access based on this field, so do not rely on it for access control.
197
198
  -->
198
- `skills:` frontmatter는 **advisory metadata일 뿐 runtime allowlist가 아닙니다** — CC는 이 필드로 스킬 접근을 제한하지 않으므로, access control로 의존하지 마세요(문서화 용도로만 사용).
199
+ `skills:` frontmatter는 **runtime allowlist가 아닙니다** — CC는 이 필드로 스킬 접근을 제한하지 않으므로, access control로 의존하지 마세요(나열 스킬은 사전 로드됩니다, 최대 32개).
199
200
 
200
201
  <!-- DETAIL: skills: field issue reference
201
202
  Reference: research findings on issue #1055 (closed not-planned).
@@ -259,7 +259,7 @@ MEMORY.md supports an optional `## User Model` section (DISTINCT from `## Behavi
259
259
  - Focus: AI agent orchestration, CLI tooling
260
260
 
261
261
  ### Override Decisions [confidence: low]
262
- - Overrode /scout SKIP verdict → INTEGRATE for RTK (#756) (과거 결정 예시입니다. RTK 프로젝트 통합은 v2.0.0에서 퇴역하며 현재 실행 추천으로 해석하지 마십시오.)
262
+ - Overrode /scout SKIP verdict → INTEGRATE for RTK (#756) (과거 결정 예시입니다. RTK 프로젝트 통합은 v1.1.107에서 퇴역하며 현재 실행 추천으로 해석하지 마십시오.)
263
263
 
264
264
  ### Categories
265
265
 
@@ -101,6 +101,15 @@ Fable 5 실행 에이전트/스킬은 지시(instruction) 장문화가 오히려
101
101
  - [ ] Tier 1에서 잡을 수 있는 문제를 다루는가? (상위 tier 대신 시프트 권고)
102
102
  - [ ] Ladder 순서를 문서화했는가? (어떤 검사를 먼저 실행하는지)
103
103
 
104
+ ### 문구 작성·정정
105
+
106
+ 새 주석/JSDoc/문서의 문구 작성·정정 시 확인합니다(#1816 H1):
107
+
108
+ - [ ] 새 문구의 사실 절마다 코드 앵커 또는 실제 관측 출력을 대응표에 기록했는가?
109
+ - [ ] 독립 문구 검토로 사실 절과 근거의 대응을 확인했는가?
110
+
111
+ lint·typecheck·테스트 통과만으로 문구의 사실 정확성을 확정하지 않습니다. pipeline auto-dev의 문구 검토 요구와 함께 확인합니다.
112
+
104
113
  ## Safety-Signal Rule Authoring — Carve-Out Pre-Check (shift-left)
105
114
 
106
115
  <!-- DETAIL: Origin note