@cheongfish/bouncer 0.0.0-stage → 0.1.13

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 (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
package/dist/guards.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ // 판별 helper 단일 정의. 이 모듈은 import가 없어야 한다 — cli 모듈이 로드될 때
3
+ // symbol-index·intent-* 같은 무거운 모듈이 미리 올라오지 않는 lazy-load 경계를
4
+ // 공용 helper가 깨뜨리면 안 된다.
5
+ /**
6
+ * 값이 배열이 아닌 일반 객체(null 제외)인지 좁힌다.
7
+ *
8
+ * @param {unknown} value - 검사할 값
9
+ * @returns {boolean} 배열·null·원시값이면 false
10
+ */
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ /**
15
+ * catch 변수에서 `message`를 읽는다.
16
+ * 예전 error.message 접근과 같다. extra null 가드를 두면 throw null이
17
+ * TypeError 대신 빈 메시지(또는 undefined)가 되어 종료 코드·unavailable reason
18
+ * 경로가 바뀌므로 의도적으로 가드를 두지 않는다.
19
+ *
20
+ * @param {unknown} error - catch로 잡은 값
21
+ * @returns {string} error.message. null/undefined면 TypeError가 난다
22
+ */
23
+ function catchMessage(error) {
24
+ return error.message;
25
+ }
26
+ /**
27
+ * catch 변수에서 메시지를 읽고, 없으면 String(error)로 폴백한다.
28
+ * 예전 `e && e.message ? e.message : String(e)` 와 같다. extra null 가드를
29
+ * 넣으면 throw null이 TypeError 대신 'null' 문자열이 된다.
30
+ *
31
+ * @param {unknown} error - catch로 잡은 값
32
+ * @returns {string} 비어 있지 않은 message 문자열, 아니면 String(error)
33
+ */
34
+ function catchMessageOrString(error) {
35
+ const message = error && error.message;
36
+ return message ? String(message) : String(error);
37
+ }
38
+ /**
39
+ * 오류가 지정한 fs `code`를 가졌는지 본다.
40
+ * catch 변수는 strict에서 unknown이라 code를 읽기 전에 객체인지 좁힌다.
41
+ * 그러지 않으면 권한 오류와 파일 부재를 같은 분기로 합치게 된다.
42
+ *
43
+ * @param {unknown} error - catch로 잡은 값
44
+ * @param {string} code - 비교할 errno 코드(예: 'ENOENT')
45
+ * @returns {boolean} code가 일치하면 true
46
+ */
47
+ function isFsCode(error, code) {
48
+ return typeof error === 'object'
49
+ && error !== null
50
+ && 'code' in error
51
+ && error.code === code;
52
+ }
53
+ /**
54
+ * 파일 부재(ENOENT) 오류인지 본다.
55
+ *
56
+ * @param {unknown} error - catch로 잡은 값
57
+ * @returns {boolean} ENOENT면 true
58
+ */
59
+ function isEnoentError(error) {
60
+ return isFsCode(error, 'ENOENT');
61
+ }
62
+ /**
63
+ * 순회 중 건너뛰어도 안전한 fs 오류인지 본다(부재·권한·루프·비디렉터리).
64
+ *
65
+ * @param {unknown} error - catch로 잡은 값
66
+ * @returns {boolean} 건너뛸 수 있는 코드면 true
67
+ */
68
+ function isSkippableFsError(error) {
69
+ return isFsCode(error, 'ENOENT')
70
+ || isFsCode(error, 'EACCES')
71
+ || isFsCode(error, 'EPERM')
72
+ || isFsCode(error, 'ELOOP')
73
+ || isFsCode(error, 'ENOTDIR');
74
+ }
75
+ module.exports = {
76
+ isRecord,
77
+ catchMessage,
78
+ catchMessageOrString,
79
+ isEnoentError,
80
+ isFsCode,
81
+ isSkippableFsError,
82
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const contextIds = require("./context-ids");
3
+ const { parseContextPath } = contextIds;
4
+ const LOG_FORMAT = '%H%x1f%s%x1f%aI%x1f%an';
5
+ /**
6
+ * epic 디렉터리 이름에서 번호 형식의 세 자리 id를 꺼낸다. slug 형식 이름과 해석 불가 이름은 번호가 없다.
7
+ * 세그먼트 해석은 context-ids 한 곳에 맡긴다.
8
+ *
9
+ * @param {string} dirName - `epics/` 바로 아래 디렉터리 이름
10
+ * @returns {string | null} 번호 형식이면 세 자리 id, 아니면 null
11
+ */
12
+ function numericEpicIdOf(dirName) {
13
+ const ids = parseContextPath(`epics/${dirName}`);
14
+ return ids !== null && ids.format === 'numeric' ? ids.epicKey : null;
15
+ }
16
+ /** 제목에서 ASCII 슬러그를 뽑고, 한글·기호만 남으면 축약 sha로 떨어뜨린다. */
17
+ function slugFromSubject(subject, sha) {
18
+ const slug = String(subject || '')
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, '-')
21
+ .replace(/^-+|-+$/g, '');
22
+ return slug || sha.slice(0, 7);
23
+ }
24
+ function parseLogOutput(text) {
25
+ const out = [];
26
+ for (const line of String(text || '').split('\n')) {
27
+ if (!line.trim())
28
+ continue;
29
+ const parts = line.split('\x1f');
30
+ if (parts.length < 4)
31
+ continue;
32
+ const [sha, subject, date, author] = parts;
33
+ if (!sha)
34
+ continue;
35
+ out.push({
36
+ sha,
37
+ subject: subject || '',
38
+ date: date || '',
39
+ author: author || '',
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+ function gitLogArgs(kind, since) {
45
+ const args = kind === 'merges'
46
+ ? ['log', '--merges', '--reverse', `--format=${LOG_FORMAT}`]
47
+ : ['log', '--reverse', `--format=${LOG_FORMAT}`];
48
+ // --since <ref> 는 날짜 해석이 아니라 <ref>..HEAD 범위다.
49
+ if (typeof since === 'string' && since)
50
+ args.push(`${since}..HEAD`);
51
+ return args;
52
+ }
53
+ function listChangedFiles(execFileSync, repoRoot, source, sha) {
54
+ // merge 커밋은 부모가 둘이라 `git show --name-only <sha>`가 결합 diff를
55
+ // 내지 않고 파일 목록이 비어 버린다. 첫 부모(^1, 병합 대상 브랜치)와의
56
+ // diff 로만 이번 머지가 가져온 경로가 보인다. 일반 커밋은 부모가 하나라
57
+ // show 한 번으로 그 커밋이 건드린 파일을 얻는다.
58
+ const args = source === 'merges'
59
+ ? ['diff', '--name-only', `${sha}^1`, sha]
60
+ : ['show', '--name-only', '--format=', sha];
61
+ const out = execFileSync('git', args, {
62
+ cwd: repoRoot,
63
+ encoding: 'utf8',
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ });
66
+ return String(out || '')
67
+ .split('\n')
68
+ .map((l) => l.trim())
69
+ .filter(Boolean);
70
+ }
71
+ module.exports = {
72
+ LOG_FORMAT,
73
+ numericEpicIdOf,
74
+ slugFromSubject,
75
+ parseLogOutput,
76
+ gitLogArgs,
77
+ listChangedFiles,
78
+ };
@@ -0,0 +1,392 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync: realExecFileSync } = require('node:child_process');
5
+ const layout = require("./layout");
6
+ const { CONTEXT_ROOT } = layout;
7
+ const paths = require("./paths");
8
+ const { isNumericContextId } = paths;
9
+ const epicIndex = require("./epic-index");
10
+ const { listEpicDirNames, ensureEpicIndexEntry } = epicIndex;
11
+ const contextIds = require("./context-ids");
12
+ const { parseContextPath, validateSlug, SLUG_MAX } = contextIds;
13
+ const epicRegistry = require("./epic-registry");
14
+ const { readEpicRegistry } = epicRegistry;
15
+ const runtimeState = require("./runtime-state");
16
+ const { readRuntimeCurrent, isWorktreeDirty } = runtimeState;
17
+ const time = require("./time");
18
+ const { nowIsoKst } = time;
19
+ const importGit = require("./import-git");
20
+ const { numericEpicIdOf, slugFromSubject, parseLogOutput, gitLogArgs, listChangedFiles, } = importGit;
21
+ const importRender = require("./import-render");
22
+ const { renderEpicBody, renderBlueprintBody, writeImportDoc, } = importRender;
23
+ const preCommitHook = require("./pre-commit-hook");
24
+ const { internalCommitEnv } = preCommitHook;
25
+ // 계획·거절·적용 + 공개 배럴. git 파싱과 문서 렌더는 형제가 담당한다.
26
+ // CLI 는 이 파일의 planImport / applyImport 만 본다.
27
+ const DEFAULT_LIMIT = 200;
28
+ const DEFAULT_EPIC_NAME = 'imported-history';
29
+ function emptyPlan(partial) {
30
+ return {
31
+ ok: false,
32
+ fellBack: false,
33
+ total: 0,
34
+ entries: [],
35
+ refusals: [],
36
+ ...partial,
37
+ };
38
+ }
39
+ /**
40
+ * 사용 중인 epic 번호 중 가장 작은 빈 세 자리를 고른다.
41
+ * 연속이면 max+1, 구멍이 있으면 그 구멍 — "다음 빈"이 둘 다 커버된다.
42
+ */
43
+ function nextEpicId(repoRoot) {
44
+ const used = new Set();
45
+ // slug 형식 epic은 번호가 없어 번호 공간을 쓰지 않는다.
46
+ for (const name of listEpicDirNames(repoRoot)) {
47
+ const id = numericEpicIdOf(name);
48
+ if (id !== null)
49
+ used.add(Number(id));
50
+ }
51
+ for (let n = 1; n <= 999; n += 1) {
52
+ if (!used.has(n))
53
+ return String(n).padStart(3, '0');
54
+ }
55
+ // 이론상만 도달 — 세 자리 공간이 가득 찬 상태.
56
+ throw new Error('no free epic id in 001..999');
57
+ }
58
+ /**
59
+ * 커밋 제목 slug 목록을 slug 형식 blueprint 디렉터리 이름으로 바꾼다. 번호 형식은 번호 접두가 이름을 구분했지만
60
+ * slug 형식은 이름 자체가 식별자라, 40자 제한·숫자만/숫자 접두 금지(번호 세그먼트와 구분 불가)를 맞추고
61
+ * 같은 이름이 겹치면 `-2`, `-3`을 붙여 유일하게 만든다. 입력 순서(오래된 커밋 먼저)를 유지한다.
62
+ *
63
+ * @param {string[]} slugs - slugFromSubject 결과(소문자 영숫자와 `-`)
64
+ * @returns {string[]} 입력과 같은 길이의 유효하고 서로 다른 slug
65
+ */
66
+ function toBlueprintSlugs(slugs) {
67
+ const used = new Set();
68
+ const clip = (value, max) => value.slice(0, max).replace(/-+$/, '');
69
+ return slugs.map((raw) => {
70
+ let base = clip(raw, SLUG_MAX);
71
+ // 숫자만이거나 `ddd-`로 시작하면 번호 세그먼트와 구분되지 않는다. 접두 `bp-`로 피한다.
72
+ if (validateSlug(base) !== null)
73
+ base = clip(`bp-${base}`, SLUG_MAX);
74
+ if (validateSlug(base) !== null)
75
+ base = 'bp';
76
+ let candidate = base;
77
+ for (let n = 2; used.has(candidate); n += 1) {
78
+ const suffix = `-${n}`;
79
+ candidate = `${clip(base, SLUG_MAX - suffix.length)}${suffix}`;
80
+ }
81
+ used.add(candidate);
82
+ return candidate;
83
+ });
84
+ }
85
+ function collectRefusals({ repoRoot, epicDir, execFileSync, }) {
86
+ const refusals = [];
87
+ // 거절은 전부 첫 write 앞에 모은다. apply 중간에 멈추면 epic 디렉터리만
88
+ // 생기고 context/index.md 행이 없는 반쪽이 남고, 그 상태는 저장소 전체
89
+ // validate 를 S13 으로 깨뜨린다. 여기서 ok 를 뒤집지 않는 이유: 거절은
90
+ // apply 입구에서 refusals 배열로 처리하고, plan 자체는 후보 목록을 돌려준다.
91
+ if (isWorktreeDirty(repoRoot, execFileSync)) {
92
+ refusals.push({
93
+ code: 'IMPORT_WORKTREE_DIRTY',
94
+ message: 'worktree has uncommitted changes; commit or stash before apply',
95
+ });
96
+ }
97
+ const current = readRuntimeCurrent({
98
+ repoRoot,
99
+ deps: { execFileSync },
100
+ });
101
+ if (current) {
102
+ refusals.push({
103
+ code: 'IMPORT_POINTER_ACTIVE',
104
+ message: `active pointer exists at blueprint ${current.blueprint}`,
105
+ });
106
+ }
107
+ if (fs.existsSync(path.join(repoRoot, epicDir))) {
108
+ refusals.push({
109
+ code: 'IMPORT_EPIC_DIR_EXISTS',
110
+ message: `epic directory already exists: ${epicDir}`,
111
+ });
112
+ }
113
+ const indexRel = `${CONTEXT_ROOT}/index.md`;
114
+ if (!fs.existsSync(path.join(repoRoot, indexRel))) {
115
+ refusals.push({
116
+ code: 'IMPORT_CONTEXT_INDEX_MISSING',
117
+ message: `missing ${indexRel}; run bouncer init before import`,
118
+ });
119
+ }
120
+ return refusals;
121
+ }
122
+ function planImport({ repoRoot, source, since, limit, epicId, epicName, deps, }) {
123
+ const d = deps || {};
124
+ const execFileSync = d.execFileSync || realExecFileSync;
125
+ const resolvedLimit = typeof limit === 'number' && Number.isFinite(limit)
126
+ ? limit
127
+ : DEFAULT_LIMIT;
128
+ const resolvedName = typeof epicName === 'string' && epicName
129
+ ? epicName
130
+ : DEFAULT_EPIC_NAME;
131
+ // source 미지정 = 자동(머지 우선). 명시값만 merges|commits 화이트리스트.
132
+ const sourceSpecified = source !== undefined && source !== null && source !== '';
133
+ if (sourceSpecified && source !== 'merges' && source !== 'commits') {
134
+ const id = typeof epicId === 'string' && epicId ? epicId : '000';
135
+ return emptyPlan({
136
+ ok: false,
137
+ source: 'merges',
138
+ epicId: id,
139
+ epicName: resolvedName,
140
+ epicDir: `${CONTEXT_ROOT}/epics/${id}-${resolvedName}`,
141
+ limit: resolvedLimit,
142
+ error: {
143
+ code: 'IMPORT_SOURCE_INVALID',
144
+ message: `source must be merges or commits, got ${JSON.stringify(source)}`,
145
+ },
146
+ });
147
+ }
148
+ // 새 epic의 형식: 번호를 명시하면 번호 형식, 아니면 등록부 버전이 정한다(v2면 slug epic, 그 밖에는 기존
149
+ // 번호 배정). 경로가 아직 없는 새 epic에만 등록부 버전을 쓴다 — 이미 있는 epic의 형식은 경로가 정한다.
150
+ const explicitId = typeof epicId === 'string' && epicId !== '';
151
+ const registryRead = explicitId ? null : readEpicRegistry(repoRoot);
152
+ const slugEpic = registryRead !== null && registryRead.status === 'ok' && registryRead.registry.version === 2;
153
+ if (slugEpic) {
154
+ const violation = validateSlug(resolvedName);
155
+ if (violation !== null) {
156
+ return emptyPlan({
157
+ ok: false,
158
+ source: (sourceSpecified ? source : 'merges'),
159
+ epicId: resolvedName,
160
+ epicName: resolvedName,
161
+ epicDir: `${CONTEXT_ROOT}/epics/${resolvedName}`,
162
+ limit: resolvedLimit,
163
+ error: {
164
+ code: 'IMPORT_EPIC_NAME_INVALID',
165
+ message: `epicName must be a valid slug for a v2 registry: ${violation}`,
166
+ },
167
+ });
168
+ }
169
+ }
170
+ let resolvedEpicId;
171
+ if (slugEpic) {
172
+ resolvedEpicId = resolvedName;
173
+ }
174
+ else if (typeof epicId === 'string' && epicId) {
175
+ if (!isNumericContextId(epicId)) {
176
+ return emptyPlan({
177
+ ok: false,
178
+ source: (sourceSpecified ? source : 'merges'),
179
+ epicId,
180
+ epicName: resolvedName,
181
+ epicDir: `${CONTEXT_ROOT}/epics/${epicId}-${resolvedName}`,
182
+ limit: resolvedLimit,
183
+ error: {
184
+ code: 'IMPORT_EPIC_ID_INVALID',
185
+ message: `epicId must be a zero-padded three-digit id (\\d{3}), got ${JSON.stringify(epicId)}`,
186
+ },
187
+ });
188
+ }
189
+ resolvedEpicId = epicId;
190
+ }
191
+ else {
192
+ resolvedEpicId = nextEpicId(repoRoot);
193
+ }
194
+ const epicDir = slugEpic
195
+ ? `${CONTEXT_ROOT}/epics/${resolvedName}`
196
+ : `${CONTEXT_ROOT}/epics/${resolvedEpicId}-${resolvedName}`;
197
+ const baseFields = {
198
+ epicId: resolvedEpicId,
199
+ epicName: resolvedName,
200
+ epicDir,
201
+ limit: resolvedLimit,
202
+ };
203
+ let resolvedSource;
204
+ let fellBack = false;
205
+ let raw;
206
+ if (sourceSpecified && source === 'commits') {
207
+ resolvedSource = 'commits';
208
+ raw = parseLogOutput(execFileSync('git', gitLogArgs('commits', since), {
209
+ cwd: repoRoot,
210
+ encoding: 'utf8',
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ }));
213
+ }
214
+ else if (sourceSpecified && source === 'merges') {
215
+ // 명시 merges 는 0건이어도 일반 커밋으로 폴백하지 않는다.
216
+ resolvedSource = 'merges';
217
+ raw = parseLogOutput(execFileSync('git', gitLogArgs('merges', since), {
218
+ cwd: repoRoot,
219
+ encoding: 'utf8',
220
+ stdio: ['ignore', 'pipe', 'pipe'],
221
+ }));
222
+ }
223
+ else {
224
+ const merges = parseLogOutput(execFileSync('git', gitLogArgs('merges', since), {
225
+ cwd: repoRoot,
226
+ encoding: 'utf8',
227
+ stdio: ['ignore', 'pipe', 'pipe'],
228
+ }));
229
+ if (merges.length > 0) {
230
+ resolvedSource = 'merges';
231
+ raw = merges;
232
+ }
233
+ else {
234
+ resolvedSource = 'commits';
235
+ fellBack = true;
236
+ raw = parseLogOutput(execFileSync('git', gitLogArgs('commits', since), {
237
+ cwd: repoRoot,
238
+ encoding: 'utf8',
239
+ stdio: ['ignore', 'pipe', 'pipe'],
240
+ }));
241
+ }
242
+ }
243
+ const total = raw.length;
244
+ const refusals = collectRefusals({ repoRoot, epicDir, execFileSync });
245
+ // 상한 초과는 앞에서 잘라 일부만 돌리지 않는다 — 전체 개수와 상한을 보고 중단.
246
+ if (total > resolvedLimit) {
247
+ return {
248
+ ok: false,
249
+ source: resolvedSource,
250
+ fellBack,
251
+ ...baseFields,
252
+ total,
253
+ entries: [],
254
+ refusals,
255
+ error: {
256
+ code: 'IMPORT_LIMIT_EXCEEDED',
257
+ message: `import candidate count ${total} exceeds limit ${resolvedLimit}`,
258
+ },
259
+ };
260
+ }
261
+ const subjectSlugs = raw.map((c) => slugFromSubject(c.subject, c.sha));
262
+ // slug epic은 순서가 번호가 아니라 apply가 쓰는 order(목록 위치)이고 이름이 곧 식별자다.
263
+ const blueprintSlugs = slugEpic ? toBlueprintSlugs(subjectSlugs) : null;
264
+ const entries = raw.map((c, i) => {
265
+ const slug = blueprintSlugs ? blueprintSlugs[i] : subjectSlugs[i];
266
+ const blueprintId = blueprintSlugs ? slug : String(i + 1).padStart(3, '0');
267
+ return {
268
+ sha: c.sha,
269
+ subject: c.subject,
270
+ date: c.date,
271
+ author: c.author,
272
+ files: listChangedFiles(execFileSync, repoRoot, resolvedSource, c.sha),
273
+ blueprintId,
274
+ slug,
275
+ blueprintDir: blueprintSlugs ? slug : `${blueprintId}-${slug}`,
276
+ };
277
+ });
278
+ return {
279
+ ok: true,
280
+ source: resolvedSource,
281
+ fellBack,
282
+ ...baseFields,
283
+ total,
284
+ entries,
285
+ refusals,
286
+ };
287
+ }
288
+ function failResult(error) {
289
+ return { ok: false, created: [], committed: false, error };
290
+ }
291
+ /**
292
+ * 계획 → imported 문서 트리 → 단일 커밋.
293
+ * 차단 판정은 전부 첫 파일 쓰기 전 — 부분 생성은 S13으로 저장소 전체를 깨뜨린다.
294
+ */
295
+ function applyImport({ repoRoot, plan, message, deps, }) {
296
+ const d = deps || {};
297
+ const execFileSync = d.execFileSync || realExecFileSync;
298
+ if (!plan || plan.ok === false) {
299
+ return failResult(plan && plan.error
300
+ ? plan.error
301
+ : { code: 'IMPORT_PLAN_INVALID', message: 'import plan is not ok' });
302
+ }
303
+ if (Array.isArray(plan.refusals) && plan.refusals.length > 0) {
304
+ const first = plan.refusals[0];
305
+ return failResult({ code: first.code, message: first.message });
306
+ }
307
+ const trimmed = typeof message === 'string' ? message.trim() : '';
308
+ if (!trimmed) {
309
+ return failResult({
310
+ code: 'IMPORT_MESSAGE_REQUIRED',
311
+ message: 'commit message is required to apply import',
312
+ });
313
+ }
314
+ if (!Array.isArray(plan.entries) || plan.entries.length === 0) {
315
+ return { ok: true, created: [], committed: false };
316
+ }
317
+ const timestamp = nowIsoKst();
318
+ // 쓸 형식은 계획의 epicDir 경로로 읽는다. 계획은 dry-run 출력이라 사람이 거치는 값이고, 같은 경로가
319
+ // 곧 디스크에 쓰일 위치이므로 경로와 문서 안 식별자가 어긋나지 않게 경로를 정본으로 삼는다.
320
+ const epicPath = parseContextPath(plan.epicDir);
321
+ const slugEpic = epicPath !== null && epicPath.format === 'slug';
322
+ const epicKey = epicPath !== null ? epicPath.epicKey : plan.epicId;
323
+ const epicRel = `${plan.epicDir}/index.md`;
324
+ const epicDescription = `Imported history (${plan.source})`;
325
+ const created = [];
326
+ // 렌더 → 쓰기 → 목록 등록 → 스테이징 → 커밋. 중간에 멈추면 안 되므로
327
+ // 거절은 위에서 전부 끝냈다.
328
+ created.push(writeImportDoc(repoRoot, epicRel, {
329
+ type: 'bouncer.epic',
330
+ title: slugEpic ? plan.epicName : `${plan.epicId} ${plan.epicName}`,
331
+ description: epicDescription,
332
+ resource: epicRel,
333
+ tags: ['bouncer', 'epic'],
334
+ timestamp,
335
+ bouncer: {
336
+ id: epicKey,
337
+ epic_id: epicKey,
338
+ status: 'imported',
339
+ },
340
+ }, renderEpicBody(plan)));
341
+ plan.entries.forEach((entry, index) => {
342
+ const bpRel = `${plan.epicDir}/blueprints/${entry.blueprintDir}/index.md`;
343
+ created.push(writeImportDoc(repoRoot, bpRel, {
344
+ type: 'bouncer.blueprint',
345
+ title: slugEpic ? entry.slug : `${entry.blueprintId} ${entry.slug}`,
346
+ description: entry.subject || `Imported ${entry.sha.slice(0, 7)}`,
347
+ resource: bpRel,
348
+ tags: ['bouncer', 'blueprint'],
349
+ timestamp,
350
+ bouncer: {
351
+ id: entry.blueprintId,
352
+ epic_id: epicKey,
353
+ blueprint_id: entry.blueprintId,
354
+ status: 'imported',
355
+ // slug 형식은 디렉터리에 번호가 없다. 계획 순서(오래된 커밋 먼저)를 1부터 order로 남긴다.
356
+ ...(slugEpic ? { order: index + 1 } : {}),
357
+ },
358
+ }, renderBlueprintBody(plan, entry)));
359
+ });
360
+ const indexRel = ensureEpicIndexEntry({
361
+ repoRoot,
362
+ epicId: slugEpic ? undefined : plan.epicId,
363
+ name: plan.epicName,
364
+ description: epicDescription,
365
+ });
366
+ if (indexRel)
367
+ created.push(indexRel);
368
+ // 생성한 경로만 스테이징 — git add -A 는 쓰지 않는다.
369
+ execFileSync('git', ['add', '--', ...created], {
370
+ cwd: repoRoot,
371
+ encoding: 'utf8',
372
+ stdio: ['ignore', 'pipe', 'pipe'],
373
+ });
374
+ // 커밋 메시지는 --message 인자 그대로. .gitmessage/문서 필드에서 조립하지 않는다.
375
+ // 내부 커밋이라 hook의 commit-guard만 건너뛴다. 사용자 hook은 그대로 돈다.
376
+ execFileSync('git', ['commit', '-m', message], {
377
+ cwd: repoRoot,
378
+ encoding: 'utf8',
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ env: internalCommitEnv(),
381
+ });
382
+ return {
383
+ ok: true,
384
+ created,
385
+ committed: true,
386
+ message: message,
387
+ };
388
+ }
389
+ module.exports = {
390
+ planImport,
391
+ applyImport,
392
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const render = require("./render");
5
+ const { renderDoc } = render;
6
+ // 문서 본문 조립과 디스크 쓰기. 계획·거절 판정은 여기 두지 않는다 —
7
+ // 렌더가 거절을 알면 쓰기 직전에만 막히고, 그 앞의 부분 생성을 막지 못한다.
8
+ // import-history 를 require 하지 않는다(render → plan 순환 금지).
9
+ /** 임포트 epic 본문. Success criteria 헤딩은 context digest 화이트리스트라 넣지 않는다. */
10
+ function renderEpicBody(plan) {
11
+ const lines = [
12
+ `# ${plan.epicId} ${plan.epicName}`,
13
+ '',
14
+ '## Intent',
15
+ `- Imported from git ${plan.source}${plan.fellBack ? ' (fell back from merges)' : ''}.`,
16
+ `- ${plan.entries.length} blueprint(s) transcribed from history.`,
17
+ '',
18
+ '## Blueprints',
19
+ ];
20
+ for (const e of plan.entries) {
21
+ const title = e.subject || e.slug;
22
+ lines.push(`* [${e.blueprintId} ${e.slug}](blueprints/${e.blueprintDir}/index.md) - ${title}`);
23
+ }
24
+ lines.push('');
25
+ return lines.join('\n');
26
+ }
27
+ function renderBlueprintBody(plan, entry) {
28
+ const changeLines = entry.files.length
29
+ ? entry.files.map((f) => `- ${f}`)
30
+ : ['- (no files)'];
31
+ return [
32
+ `# ${entry.blueprintId} ${entry.slug}`,
33
+ '',
34
+ `Epic: [${plan.epicId}](../../index.md)`,
35
+ '',
36
+ '## Source',
37
+ `- sha: \`${entry.sha}\``,
38
+ `- date: ${entry.date}`,
39
+ `- author: ${entry.author}`,
40
+ '',
41
+ '## Message',
42
+ entry.subject || '(empty)',
43
+ '',
44
+ '## Changes',
45
+ ...changeLines,
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ function writeImportDoc(repoRoot, rel, data, body) {
50
+ const abs = path.join(repoRoot, rel);
51
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
52
+ fs.writeFileSync(abs, renderDoc(data, body));
53
+ return rel;
54
+ }
55
+ module.exports = {
56
+ renderEpicBody,
57
+ renderBlueprintBody,
58
+ writeImportDoc,
59
+ };
@@ -0,0 +1,2 @@
1
+ 'use strict';
2
+ Object.defineProperty(exports, "__esModule", { value: true });