claude-novice 0.2.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.
- package/.claude-plugin/marketplace.json +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/ARCHITECTURE.md +59 -0
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/config/bootstrap-manifests/github-cli.json +80 -0
- package/config/bootstrap-manifests/supabase.json +71 -0
- package/config/bootstrap-manifests/vercel.json +69 -0
- package/config/levels.json +33 -0
- package/config/safety-rules.json +263 -0
- package/config/service-capabilities.json +51 -0
- package/config/terms.json +198 -0
- package/hooks/hooks.json +107 -0
- package/package.json +45 -0
- package/scripts/bootstrap-engine.js +240 -0
- package/scripts/lib/capability-router.js +170 -0
- package/scripts/lib/capsule.js +178 -0
- package/scripts/lib/fingerprint.js +17 -0
- package/scripts/lib/grammar.js +287 -0
- package/scripts/lib/hookio.js +49 -0
- package/scripts/lib/manifest.js +154 -0
- package/scripts/lib/safety.js +370 -0
- package/scripts/lib/secrets.js +104 -0
- package/scripts/lib/state.js +256 -0
- package/scripts/post-tool-batch.js +108 -0
- package/scripts/post-tool-use-failure.js +48 -0
- package/scripts/post-tool-use.js +114 -0
- package/scripts/pre-tool-use.js +58 -0
- package/scripts/session-end.js +21 -0
- package/scripts/session-start.js +48 -0
- package/scripts/stop.js +69 -0
- package/scripts/user-prompt-expansion.js +66 -0
- package/scripts/user-prompt-submit.js +158 -0
- package/scripts/verify-docs.mjs +93 -0
- package/skills/mode/SKILL.md +48 -0
- package/skills/novice/SKILL.md +41 -0
- package/skills/setup-service/SKILL.md +88 -0
package/scripts/stop.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stop: incrementally count canonical "용어(설명)" exposures in the final assistant
|
|
3
|
+
// message and update the per-session fade counters. Defensive by contract:
|
|
4
|
+
// missing last_assistant_message or stop_hook_active=true leaves counters untouched,
|
|
5
|
+
// and a repeated Stop for the same message (same hash) is a no-op.
|
|
6
|
+
// Learning hook: fail open (exit 0, no output) on any internal error.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { readStdinJson, failOpen } from './lib/hookio.js';
|
|
9
|
+
import { loadSession, saveSession } from './lib/state.js';
|
|
10
|
+
import { loadTerms } from './lib/capsule.js';
|
|
11
|
+
|
|
12
|
+
function stripCode(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
15
|
+
.replace(/`[^`\n]*`/g, ' ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function escapeRegExp(s) {
|
|
19
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Canonical exposure: the real term immediately followed by "(" + a parenthetical
|
|
23
|
+
// that begins with the first characters of the canonical explanation. This shape is
|
|
24
|
+
// generated by the novice glossary rule, so bare mentions and user quotes don't count.
|
|
25
|
+
function countsInMessage(message, terms) {
|
|
26
|
+
const text = stripCode(message);
|
|
27
|
+
const counted = [];
|
|
28
|
+
for (const t of terms) {
|
|
29
|
+
const prefix = t.explanation.slice(0, 6);
|
|
30
|
+
const gloss = (t.aliases || []).find((a) => /[가-힣]/.test(a));
|
|
31
|
+
const pattern = new RegExp(
|
|
32
|
+
`${escapeRegExp(t.term)}\\s?\\((?:${escapeRegExp(gloss ?? '')}[::]?\\s*)?${escapeRegExp(prefix)}`,
|
|
33
|
+
'i',
|
|
34
|
+
);
|
|
35
|
+
if (pattern.test(text)) counted.push(t.term);
|
|
36
|
+
}
|
|
37
|
+
return counted;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function main() {
|
|
41
|
+
const input = readStdinJson();
|
|
42
|
+
if (input.stop_hook_active === true) return;
|
|
43
|
+
const message = input.last_assistant_message;
|
|
44
|
+
if (typeof message !== 'string' || message === '') return;
|
|
45
|
+
const sessionId = input.session_id;
|
|
46
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
47
|
+
|
|
48
|
+
const hash = crypto.createHash('sha256').update(message).digest('hex');
|
|
49
|
+
const session = loadSession(sessionId);
|
|
50
|
+
if (session.last_message_hash === hash) return;
|
|
51
|
+
session.last_message_hash = hash;
|
|
52
|
+
|
|
53
|
+
const counted = countsInMessage(message, loadTerms().terms);
|
|
54
|
+
for (const term of counted) {
|
|
55
|
+
session.term_counts[term] = (session.term_counts[term] ?? 0) + 1;
|
|
56
|
+
// A reset term that gets explained again starts a fresh fade cycle.
|
|
57
|
+
if (Array.isArray(session.reset_terms) && session.reset_terms.includes(term)) {
|
|
58
|
+
session.reset_terms = session.reset_terms.filter((x) => x !== term);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
saveSession(sessionId, session);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
main();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
} catch {
|
|
68
|
+
failOpen();
|
|
69
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptExpansion: deterministic handler for the /novice:mode slash command.
|
|
3
|
+
// Valid args update project state and inject the fresh capsule (or OFF tombstone).
|
|
4
|
+
// Invalid args block the expansion (state untouched) and show the allowed values.
|
|
5
|
+
// Empty args are a read-only status query.
|
|
6
|
+
// Learning hook: fail open (exit 0, no output) on any internal error.
|
|
7
|
+
import { readStdinJson, emitAdditionalContext, emitBlock, failOpen } from './lib/hookio.js';
|
|
8
|
+
import { getProjectConfig, setProjectMode, loadSession, saveSession } from './lib/state.js';
|
|
9
|
+
// setProjectMode preserves muted_terms; re-read via getProjectConfig for the capsule.
|
|
10
|
+
import { buildTombstone, capsuleForState } from './lib/capsule.js';
|
|
11
|
+
|
|
12
|
+
const VALID_ARGS = new Set(['1', '2', '3', 'off']);
|
|
13
|
+
|
|
14
|
+
function statusText(config) {
|
|
15
|
+
const state = config.enabled ? `Level ${config.level}` : 'off';
|
|
16
|
+
return [
|
|
17
|
+
`[novice 상태] 현재 mode: ${state}`,
|
|
18
|
+
'적용 범위: 현재 프로젝트 (프로젝트별로 저장된다)',
|
|
19
|
+
'안전 게이트: novice off와 무관하게 플러그인이 활성화된 동안 항상 유지된다.',
|
|
20
|
+
'전환: /novice:mode 1|2|3|off',
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function main() {
|
|
25
|
+
const input = readStdinJson();
|
|
26
|
+
if (input.command_name !== 'novice:mode') return;
|
|
27
|
+
|
|
28
|
+
const sessionId = input.session_id;
|
|
29
|
+
const cwd = input.cwd || process.cwd();
|
|
30
|
+
const args = String(input.command_args ?? '').trim();
|
|
31
|
+
|
|
32
|
+
// Status query — read-only.
|
|
33
|
+
if (args === '') {
|
|
34
|
+
emitAdditionalContext('UserPromptExpansion', statusText(getProjectConfig(cwd)));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!VALID_ARGS.has(args)) {
|
|
39
|
+
emitBlock('사용할 수 있는 값: /novice:mode 1|2|3|off — 예: /novice:mode 2');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const next = setProjectMode(cwd, args);
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
45
|
+
|
|
46
|
+
const session = loadSession(sessionId);
|
|
47
|
+
if (args === 'off') {
|
|
48
|
+
emitAdditionalContext('UserPromptExpansion', buildTombstone());
|
|
49
|
+
session.off_tombstone_emitted = true;
|
|
50
|
+
session.capsule_revision = null;
|
|
51
|
+
} else {
|
|
52
|
+
const { revision, capsule } = capsuleForState(next.level, session, next.muted_terms || []);
|
|
53
|
+
emitAdditionalContext('UserPromptExpansion', capsule);
|
|
54
|
+
session.capsule_revision = revision;
|
|
55
|
+
session.off_tombstone_emitted = false;
|
|
56
|
+
}
|
|
57
|
+
session.skip_next_submit = false;
|
|
58
|
+
saveSession(sessionId, session);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
main();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
} catch {
|
|
65
|
+
failOpen();
|
|
66
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptSubmit: natural-language mode switching, persistent reset, and duplicate-capsule
|
|
3
|
+
// suppression. Exact /novice:mode slash prompts are handed to UserPromptExpansion (we emit
|
|
4
|
+
// nothing so old and new capsules never coexist in one turn).
|
|
5
|
+
// Learning hook: fail open (exit 0, no output) on any internal error.
|
|
6
|
+
import { readStdinJson, emitAdditionalContext, failOpen } from './lib/hookio.js';
|
|
7
|
+
import { getProjectConfig, setProjectMode, muteProjectTerm, unmuteProjectTerm, loadSession, saveSession } from './lib/state.js';
|
|
8
|
+
import { loadTerms, buildTombstone, capsuleForState } from './lib/capsule.js';
|
|
9
|
+
|
|
10
|
+
const SLASH_MODE = /^\/novice:mode(\s[\s\S]*)?$/;
|
|
11
|
+
const RESET_ALL = 'novice reset all';
|
|
12
|
+
const RESET_ONE = /^novice reset (.+)$/;
|
|
13
|
+
const MUTE_ONE = /^novice mute (.+)$/;
|
|
14
|
+
const UNMUTE_ONE = /^novice unmute (.+)$/;
|
|
15
|
+
const MODE_ALIAS = { 'novice 1': '1', 'novice 2': '2', 'novice 3': '3', 'novice off': 'off' };
|
|
16
|
+
|
|
17
|
+
// trim → strip ONE trailing period (ASCII '.' or Korean '。') → collapse internal whitespace.
|
|
18
|
+
function normalizeForAlias(prompt) {
|
|
19
|
+
let s = String(prompt ?? '').trim();
|
|
20
|
+
s = s.replace(/[.。]$/, '');
|
|
21
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve a term name or alias (case-insensitive) to its canonical term name.
|
|
26
|
+
function resolveTerm(raw) {
|
|
27
|
+
const key = String(raw).trim().toLowerCase();
|
|
28
|
+
for (const t of loadTerms().terms) {
|
|
29
|
+
if (t.term.toLowerCase() === key) return t.term;
|
|
30
|
+
if ((t.aliases || []).some((a) => a.toLowerCase() === key)) return t.term;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Emit the current capsule (enabled) or nothing, honoring the skip_next_submit handshake.
|
|
36
|
+
function emitCurrentOrSkip(sessionId, config, session) {
|
|
37
|
+
if (config.enabled) {
|
|
38
|
+
const { revision, capsule } = capsuleForState(config.level, session, config.muted_terms);
|
|
39
|
+
if (session.skip_next_submit === true && revision === session.capsule_revision) {
|
|
40
|
+
session.skip_next_submit = false;
|
|
41
|
+
saveSession(sessionId, session);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
emitAdditionalContext('UserPromptSubmit', capsule);
|
|
45
|
+
session.capsule_revision = revision;
|
|
46
|
+
session.skip_next_submit = false;
|
|
47
|
+
saveSession(sessionId, session);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Disabled: one-shot tombstone if capsules were injected earlier this session.
|
|
51
|
+
if (session.off_tombstone_emitted !== true && session.capsule_revision != null) {
|
|
52
|
+
emitAdditionalContext('UserPromptSubmit', buildTombstone());
|
|
53
|
+
session.off_tombstone_emitted = true;
|
|
54
|
+
session.capsule_revision = null;
|
|
55
|
+
session.skip_next_submit = false;
|
|
56
|
+
saveSession(sessionId, session);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Apply a mode change the same way the slash/expansion path does, then inject fresh state.
|
|
61
|
+
function applyModeChange(sessionId, cwd, session, mode) {
|
|
62
|
+
const next = setProjectMode(cwd, mode);
|
|
63
|
+
if (mode === 'off') {
|
|
64
|
+
emitAdditionalContext('UserPromptSubmit', buildTombstone());
|
|
65
|
+
session.off_tombstone_emitted = true;
|
|
66
|
+
session.capsule_revision = null;
|
|
67
|
+
session.skip_next_submit = false;
|
|
68
|
+
saveSession(sessionId, session);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const { revision, capsule } = capsuleForState(next.level, session, next.muted_terms || []);
|
|
72
|
+
emitAdditionalContext('UserPromptSubmit', capsule);
|
|
73
|
+
session.capsule_revision = revision;
|
|
74
|
+
session.off_tombstone_emitted = false;
|
|
75
|
+
session.skip_next_submit = false;
|
|
76
|
+
saveSession(sessionId, session);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function main() {
|
|
80
|
+
const input = readStdinJson();
|
|
81
|
+
const sessionId = input.session_id;
|
|
82
|
+
const cwd = input.cwd || process.cwd();
|
|
83
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
84
|
+
|
|
85
|
+
const rawTrimmed = String(input.prompt ?? '').trim();
|
|
86
|
+
|
|
87
|
+
// (b) Exact slash command → expansion owns this turn.
|
|
88
|
+
if (SLASH_MODE.test(rawTrimmed)) return;
|
|
89
|
+
|
|
90
|
+
const session = loadSession(sessionId);
|
|
91
|
+
const normalized = normalizeForAlias(input.prompt);
|
|
92
|
+
const key = normalized.toLowerCase();
|
|
93
|
+
|
|
94
|
+
// (c) Natural-language mode aliases → same writer as the slash path.
|
|
95
|
+
if (Object.prototype.hasOwnProperty.call(MODE_ALIAS, key)) {
|
|
96
|
+
applyModeChange(sessionId, cwd, session, MODE_ALIAS[key]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// (c) Persistent reset — reset all.
|
|
101
|
+
if (key === RESET_ALL) {
|
|
102
|
+
session.term_counts = {};
|
|
103
|
+
session.reset_terms = [];
|
|
104
|
+
saveSession(sessionId, session);
|
|
105
|
+
emitCurrentOrSkip(sessionId, getProjectConfig(cwd), session);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// (c) Persistent reset — reset one term (only when it resolves to a known term/alias).
|
|
110
|
+
const one = key.match(RESET_ONE);
|
|
111
|
+
if (one) {
|
|
112
|
+
const term = resolveTerm(one[1]);
|
|
113
|
+
if (term) {
|
|
114
|
+
if (session.term_counts) delete session.term_counts[term];
|
|
115
|
+
const rs = new Set(session.reset_terms || []);
|
|
116
|
+
rs.add(term);
|
|
117
|
+
session.reset_terms = [...rs];
|
|
118
|
+
saveSession(sessionId, session);
|
|
119
|
+
emitCurrentOrSkip(sessionId, getProjectConfig(cwd), session);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Unknown target → treat as an ordinary prompt (fall through).
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// (c) Mute one term — force-fade it permanently across sessions (project-scoped).
|
|
126
|
+
const mute = key.match(MUTE_ONE);
|
|
127
|
+
if (mute) {
|
|
128
|
+
const term = resolveTerm(mute[1]);
|
|
129
|
+
if (term) {
|
|
130
|
+
muteProjectTerm(cwd, term);
|
|
131
|
+
emitCurrentOrSkip(sessionId, getProjectConfig(cwd), session);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Unknown target → treat as an ordinary prompt (fall through).
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// (c) Unmute one term — undo a previous mute (explanations resume per the fade rule).
|
|
138
|
+
const unmute = key.match(UNMUTE_ONE);
|
|
139
|
+
if (unmute) {
|
|
140
|
+
const term = resolveTerm(unmute[1]);
|
|
141
|
+
if (term) {
|
|
142
|
+
unmuteProjectTerm(cwd, term);
|
|
143
|
+
emitCurrentOrSkip(sessionId, getProjectConfig(cwd), session);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Unknown target → treat as an ordinary prompt (fall through).
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// (d)/(e) Ordinary prompt.
|
|
150
|
+
emitCurrentOrSkip(sessionId, getProjectConfig(cwd), session);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
main();
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch {
|
|
157
|
+
failOpen();
|
|
158
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// verify-docs: 문서-코드 일치 + 레이어 종속성 정적 검사.
|
|
3
|
+
// ESLint 대체(zero-dep 원칙). 실패 시 exit 1. pretest에 연결되어 매 테스트 전 실행된다.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const errors = [];
|
|
10
|
+
const ok = (cond, msg) => { if (!cond) errors.push(msg); };
|
|
11
|
+
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
|
12
|
+
const exists = (p) => fs.existsSync(path.join(root, p));
|
|
13
|
+
|
|
14
|
+
// 모든 import/export 지정자 수집 (static·side-effect·re-export·dynamic 전부).
|
|
15
|
+
function collectSpecifiers(src) {
|
|
16
|
+
const specs = [];
|
|
17
|
+
for (const re of [
|
|
18
|
+
/(?:^|\n)\s*import\s[^;]*?from\s*['"]([^'"]+)['"]/g, // import x from 'y'
|
|
19
|
+
/(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g, // import 'y' (side-effect)
|
|
20
|
+
/(?:^|\n)\s*export\s[^;]*?from\s*['"]([^'"]+)['"]/g, // export … from 'y'
|
|
21
|
+
/import\(\s*['"]([^'"]+)['"]\s*\)/g, // import('y') (dynamic)
|
|
22
|
+
]) {
|
|
23
|
+
for (const m of src.matchAll(re)) specs.push(m[1]);
|
|
24
|
+
}
|
|
25
|
+
return specs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 1) AGENTS.md가 참조하는 상대 경로가 실존하는지 (http·앵커 제외, ./ 유무 무관).
|
|
29
|
+
const agents = read('AGENTS.md');
|
|
30
|
+
for (const m of agents.matchAll(/\]\((?!https?:)([^)]+)\)/g)) {
|
|
31
|
+
const target = m[1].split('#')[0].replace(/^\.\//, '');
|
|
32
|
+
if (target) ok(exists(target), `AGENTS.md가 참조하는 경로가 없음: ${target}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2) CLAUDE.md는 @AGENTS.md 참조여야 함 (SSOT 정규화).
|
|
36
|
+
ok(read('CLAUDE.md').trim() === '@AGENTS.md', 'CLAUDE.md는 "@AGENTS.md" 참조여야 함');
|
|
37
|
+
|
|
38
|
+
// 3) plugin.json은 hooks 키를 넣지 않는다 (중복 로드 실패 방지).
|
|
39
|
+
const plugin = JSON.parse(read('.claude-plugin/plugin.json'));
|
|
40
|
+
ok(!('hooks' in plugin), 'plugin.json에 hooks 키가 있으면 중복 로드 실패 — 제거하세요');
|
|
41
|
+
for (const [, v] of Object.entries(plugin.userConfig ?? {})) {
|
|
42
|
+
ok(typeof v.title === 'string', 'userConfig 항목에 title 필수');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 4a) 레이어 규칙: scripts/lib/*.js(L2)는 node 내장 또는 같은 lib만 import.
|
|
46
|
+
const libDir = path.join(root, 'scripts', 'lib');
|
|
47
|
+
for (const f of fs.readdirSync(libDir).filter((x) => x.endsWith('.js'))) {
|
|
48
|
+
for (const spec of collectSpecifiers(fs.readFileSync(path.join(libDir, f), 'utf8'))) {
|
|
49
|
+
const isNode = spec.startsWith('node:');
|
|
50
|
+
// 같은 lib 디렉토리: "./name.js" (./ 이후에 추가 경로 구분자 없음).
|
|
51
|
+
const isSameLib = spec.startsWith('./') && !spec.slice(2).includes('/');
|
|
52
|
+
ok(isNode || isSameLib, `레이어 위반: scripts/lib/${f} 가 '${spec}' import (lib는 node 내장 또는 같은 lib만 허용)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4b) 레이어 규칙: scripts/*.js(L3 hook)는 node 내장 또는 ./lib/*만 import (hook→hook 금지).
|
|
57
|
+
const scriptsDir = path.join(root, 'scripts');
|
|
58
|
+
for (const f of fs.readdirSync(scriptsDir).filter((x) => x.endsWith('.js'))) {
|
|
59
|
+
for (const spec of collectSpecifiers(fs.readFileSync(path.join(scriptsDir, f), 'utf8'))) {
|
|
60
|
+
const isNode = spec.startsWith('node:');
|
|
61
|
+
const isLib = spec.startsWith('./lib/');
|
|
62
|
+
ok(isNode || isLib, `레이어 위반: scripts/${f} 가 '${spec}' import (hook은 node 내장 또는 ./lib/*만 허용 — hook→hook·외부 금지)`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 5) 문서 수치 일치: config/terms.json 용어 수 == 문서 표기(32).
|
|
67
|
+
const terms = JSON.parse(read('config/terms.json')).terms.length;
|
|
68
|
+
ok(terms === 32, `terms.json 용어 수 ${terms} != 문서 표기 32`);
|
|
69
|
+
|
|
70
|
+
// 6) bootstrap manifest 3종 실존 (service-capabilities 참조와 일치).
|
|
71
|
+
const caps = JSON.parse(read('config/service-capabilities.json'));
|
|
72
|
+
for (const svc of Object.values(caps.services ?? {})) {
|
|
73
|
+
ok(exists(path.join('config', svc.manifest)), `manifest 없음: ${svc.manifest}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 7) docs/harness 필수 5종 존재.
|
|
77
|
+
for (const f of ['principles.md', 'maturity-framework.md', 'fix-catalog.md', 'gc-history.md', 'harness-setup.md']) {
|
|
78
|
+
ok(exists(path.join('docs', 'harness', f)), `docs/harness/${f} 누락`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 8) lib 레지스트리 일치: 실제 scripts/lib/*.js 모듈이 전부 ARCHITECTURE.md 모듈맵에 등재됐는지
|
|
82
|
+
// (Search Before Building — 레지스트리 drift 방지). *.mjs·helper 제외.
|
|
83
|
+
const arch = read('ARCHITECTURE.md');
|
|
84
|
+
for (const f of fs.readdirSync(path.join(root, 'scripts', 'lib')).filter((x) => x.endsWith('.js'))) {
|
|
85
|
+
const mod = f.replace(/\.js$/, '');
|
|
86
|
+
ok(arch.includes(`\`${mod}.js\``), `ARCHITECTURE.md lib 모듈맵에 scripts/lib/${f} 누락 (레지스트리 drift)`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (errors.length) {
|
|
90
|
+
console.error('verify-docs FAILED:\n' + errors.map((e) => ` ✗ ${e}`).join('\n'));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('verify-docs OK');
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mode
|
|
3
|
+
description: novice 레벨 확인·전환 — /novice:mode 1|2|3|off
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /novice:mode — novice 레벨 확인·전환
|
|
8
|
+
|
|
9
|
+
인자 없이 실행하면 현재 상태를 보여 주고, `1|2|3|off` 인자로 mode를 전환한다.
|
|
10
|
+
mode는 **현재 프로젝트에** 저장되어 세션이 바뀌어도 유지된다.
|
|
11
|
+
|
|
12
|
+
## 레벨별 동작
|
|
13
|
+
|
|
14
|
+
| 축 | Level 1 (기본) | Level 2 | Level 3 | off |
|
|
15
|
+
|---|---|---|---|---|
|
|
16
|
+
| 용어 설명 | 세션 첫 노출부터 3회까지 병기 | 3회까지 병기 | 요청 시만 설명 | novice 설명 없음 |
|
|
17
|
+
| 행동 해설 | 실행 전 무엇·왜, 실행 후 변경 결과 | 핵심 결정만 | 아키텍처·유저플로우 중심 | novice 지시 억제 |
|
|
18
|
+
| 시각화 | 3단계 이상 작업·분기·복구 시 표 | 중요 분기에서 표 | 요청 또는 위험 시 표 | novice 시각화 없음 |
|
|
19
|
+
| 안전 게이트 | 유지 | 유지 | 유지 | **유지** (플러그인 활성 동안) |
|
|
20
|
+
|
|
21
|
+
- 용어는 순화하지 않는다. `commit(현재 변경을 하나의 저장 지점으로 기록하는 것)`처럼
|
|
22
|
+
실제 용어 뒤에 설명을 병기하고, 충분히 노출된 용어는 설명을 걷어낸다.
|
|
23
|
+
- `off`는 novice 톤·설명·시각화만 끈다. 파괴·시크릿·외부 부작용 안전 게이트는
|
|
24
|
+
플러그인이 활성화된 동안 계속 동작한다. 플러그인 자체를 disable하면 게이트도 사라진다.
|
|
25
|
+
|
|
26
|
+
## 사용 예
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/novice:mode ← 현재 level, 적용 범위, 안전 게이트 상태 표시
|
|
30
|
+
/novice:mode 2 ← Level 2로 전환
|
|
31
|
+
/novice:mode off ← novice 학습층 끄기
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 자연어 별칭 (프롬프트 전체가 정확히 일치할 때만)
|
|
35
|
+
|
|
36
|
+
- `novice 1` / `novice 2` / `novice 3` / `novice off` — mode 전환
|
|
37
|
+
- `novice reset all` — 모든 용어 설명 카운터 초기화
|
|
38
|
+
- `novice reset <용어>` — 해당 용어만 초기화 (예: `novice reset commit`) — 다시 처음부터 카운트
|
|
39
|
+
- `novice mute <용어>` — 해당 용어를 영구 제외 (노출 횟수와 무관하게 설명 중단, 예: `novice mute commit`)
|
|
40
|
+
- `novice unmute <용어>` — mute 해제 (다시 fade 규칙에 따라 설명)
|
|
41
|
+
|
|
42
|
+
reset과 mute의 차이: **reset**은 카운터를 0으로 되돌려 다시 N회 설명하게 하고,
|
|
43
|
+
**mute**는 지금 즉시 설명을 끊고 계속 끊어 둔다. `novice mute`는 용어(alias 포함)를 인식하며,
|
|
44
|
+
사전에 없는 용어는 무시한다. mute는 **프로젝트 단위로 저장되어 세션이 바뀌어도 유지**된다
|
|
45
|
+
(reset·용어 카운터는 세션 스코프).
|
|
46
|
+
|
|
47
|
+
"더 쉽게 설명해 줘" 같은 일반 문장은 현재 답변에만 영향을 주고 mode를 바꾸지 않는다.
|
|
48
|
+
잘못 전환했다면 같은 명령으로 즉시 되돌릴 수 있다.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: novice
|
|
3
|
+
description: novice 학습 동반자 현황 + 하위 명령 안내 (front door) — /novice:mode, /novice:setup-service
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /novice — 현황 + 하위 명령
|
|
8
|
+
|
|
9
|
+
이 플러그인의 front door다. 인자 없이 현재 상태를 한눈에 보여 주고, 실제 작업은
|
|
10
|
+
하위 명령으로 넘긴다. (플러그인 명령은 항상 `/novice:` 로 namespace된다.)
|
|
11
|
+
|
|
12
|
+
## 할 일
|
|
13
|
+
|
|
14
|
+
1. 현재 프로젝트의 novice 설정을 읽어 아래 표로 보여 준다. 상태는 다음 명령으로 읽는다
|
|
15
|
+
(`CLAUDE_PLUGIN_DATA`는 런타임이 주입하므로 인자로 넘기지 않는다):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
node -e "import(process.env.CLAUDE_PLUGIN_ROOT + '/scripts/lib/state.js').then(m=>{const c=m.getProjectConfig(process.cwd());console.log(JSON.stringify({level:c.level,enabled:c.enabled,muted:c.muted_terms}))})"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| 항목 | 값 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| novice 레벨 | `level` (enabled=false면 "off"로 표시) |
|
|
24
|
+
| 학습층 | enabled=true → 켜짐 / false → 꺼짐 |
|
|
25
|
+
| 안전 게이트 | 최소 deny-only 코어 — 파괴 비가역 명령·노출된 시크릿만 차단. 플러그인 활성 동안 유지 |
|
|
26
|
+
| mute된 용어 | `muted` 목록 (없으면 "없음") |
|
|
27
|
+
|
|
28
|
+
2. 이어서 하위 명령을 안내한다:
|
|
29
|
+
|
|
30
|
+
| 명령 | 용도 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `/novice:mode 1\|2\|3\|off` | 레벨 확인·전환 (상세: mode 스킬) |
|
|
33
|
+
| `/novice:setup-service` | 외부 서비스 CLI 부트스트랩 (탐지·설치·로그인·인증) |
|
|
34
|
+
| `novice mute/unmute/reset <용어>` | 용어 설명 제어 (자연어 별칭) |
|
|
35
|
+
|
|
36
|
+
## 하지 말 것
|
|
37
|
+
|
|
38
|
+
- 상태만 보여 주고 임의로 mode를 바꾸지 않는다. 전환은 `/novice:mode`가 담당한다.
|
|
39
|
+
- 안전 게이트는 **최소 deny-only 코어**다: 파괴 비가역 작업(`rm -rf ~` 등)과 노출된 시크릿만
|
|
40
|
+
차단하고, 그 외(파싱 불가한 파이프·체인 포함)는 Claude Code 네이티브 권한에 위임한다.
|
|
41
|
+
"항상 모든 걸 막는다"고 단정하지 않는다 (README 안전 게이트 위협 모델 참조).
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup-service
|
|
3
|
+
description: 외부 서비스 CLI 부트스트랩 — 탐지·설치·로그인·인증 확인까지 2-tier 승인 흐름
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# setup-service — 외부 서비스 CLI 부트스트랩
|
|
7
|
+
|
|
8
|
+
manifest 기반 상태 머신 `resolve → preflight → plan → approve → apply → verify → recover`로
|
|
9
|
+
외부 서비스 CLI를 준비한다. 엔진은 `scripts/bootstrap-engine.js` 라이브러리이고,
|
|
10
|
+
provider별 차이는 전부 `config/bootstrap-manifests/*.json` 데이터에 있다.
|
|
11
|
+
|
|
12
|
+
## 자동화 경계 (절대 넘지 않는다)
|
|
13
|
+
|
|
14
|
+
- 자동화하는 것: **CLI 탐지 → 설치 → 로그인 실행 → 인증 상태 확인**까지.
|
|
15
|
+
- 자동화하지 않는 것: 리소스 생성·연결, env/secret 값 입력, 배포, 결제, 삭제, 약관 동의.
|
|
16
|
+
이후 단계는 무엇을·왜·어느 명령으로 하는지 단계별로 안내하고 **사용자가 직접 실행**한다.
|
|
17
|
+
- credential 값을 절대 요청·보관·전달·자동입력하지 않는다. 브라우저·device flow·MFA는
|
|
18
|
+
사용자가 직접 완료한다. 값을 대화·명령 인자·shell history에 넣지 않도록 경고한다.
|
|
19
|
+
|
|
20
|
+
## 2-tier 규칙
|
|
21
|
+
|
|
22
|
+
| | Tier 1 | Tier 2 |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| 대상 | 검토된 manifest (vercel, gh, supabase) | 그 외 모든 CLI |
|
|
25
|
+
| 근거 | repo 동봉 version 관리 manifest | engine이 조사한 공식 docs URL·package coordinate |
|
|
26
|
+
| 실행 조건 | 표준 승인 (설치 1회 + 로그인 1회) | **근거 출처와 실행 argv를 화면에 그대로 제시**하고 사용자가 승인한 경우에만 |
|
|
27
|
+
| 근거 미확인 시 | — | guided manual로 낮춤 (설치·로그인 진행 안 함) |
|
|
28
|
+
| 위험 verb 차단 | provider-aware (manifest 등재분) | generic grammar 보증만 — 이 차이를 승인 UI에 표시 |
|
|
29
|
+
|
|
30
|
+
## 진행 절차 (모델 지침)
|
|
31
|
+
|
|
32
|
+
1. **resolve**: 서비스명을 Tier 1 manifest에 매핑. 미등재면 공식 문서·registry를 조사해
|
|
33
|
+
ad-hoc manifest(같은 schema, `tier: 2`)를 만들고 근거를 표로 제시한다.
|
|
34
|
+
2. **preflight** (read-only): 설치 여부, version, 기존 인증, credential 저장 방식, 대화형 여부.
|
|
35
|
+
이미 인증돼 있으면 로그인을 재실행하지 않는다.
|
|
36
|
+
3. **plan/approve**: 설치 위치·전역 변경·인증 저장 위치·실행 argv·되돌리기(logout/uninstall)를
|
|
37
|
+
표로 보여 주고 **설치와 로그인 각각 1회씩** 승인받는다. 승인은 해당 실행 1회에만 유효하다.
|
|
38
|
+
4. **apply**: 승인된 manifest argv만 exec-form으로 한 명령씩 실행. `curl | bash`·shell 문자열 조립 금지.
|
|
39
|
+
5. **verify**: version check와 auth status로 각각 검증한다.
|
|
40
|
+
6. **recover** (실패 시): 완료 단계, 남은 전역 파일·credential 위치, 재시도·logout·uninstall
|
|
41
|
+
명령을 보고만 한다. **자동 logout/uninstall은 하지 않는다.**
|
|
42
|
+
|
|
43
|
+
## capability 라우터 (CLI → MCP → Chrome → guided manual)
|
|
44
|
+
|
|
45
|
+
경로 선택·검증·다운그레이드는 `scripts/lib/capability-router.js`가 결정한다.
|
|
46
|
+
`scripts/bootstrap-engine.js`의 `setupService(engine, serviceId, capability, ctx)`가 진입점이며,
|
|
47
|
+
`cli` 경로만 manifest 엔진을 직접 실행하고 나머지는 **plan(안내)만 반환**한다.
|
|
48
|
+
|
|
49
|
+
- **경로 결정**: `resolveCapability`가 `capability_priority`(CLI→MCP→Chrome→guided manual) 순으로
|
|
50
|
+
각 경로의 가용성을 검사해 첫 사용 가능 경로를 고른다. provisioning·deploy·env_setup처럼
|
|
51
|
+
service-capabilities에서 `guided_manual`로 고정된 capability는 즉시 guided manual로 간다.
|
|
52
|
+
- **MCP 검증(`validateMcpCandidate`)**: 두 경로. (1) 정적 `mcp_allowlist`에 server·transport·
|
|
53
|
+
publisher가 정확히 일치 + provenance verified + 요청 tool이 전부 allowlist 안 + 해당 capability
|
|
54
|
+
담당 엔트리 → 사전 검토분이라 재확인 없이 사용. (2) 사용자가 Claude runtime에 **이미 등록한 서버**
|
|
55
|
+
(`registered:true` — 등록이 곧 provenance) + 이번 작업에 **명시적 동의**(`userConsent:true`) →
|
|
56
|
+
동의한 tool 범위 안에서 사용. 둘 다 아니면 경로를 건너뛴다. **allowlist 기본값은 비어 있어,
|
|
57
|
+
등록·동의가 없으면 아무 MCP도 자동 실행되지 않는다.** 어느 경로든 MCP tool 실제 호출은 모델이
|
|
58
|
+
하고 PreToolUse 안전 게이트가 계속 검사하며, MCP 서버를 자동 설치하지 않는다.
|
|
59
|
+
- **CLI 동의(`cliUserConsent`)**: Tier 1 검토 manifest 외에, 사용자가 근거(docs URL·coordinate·argv)를
|
|
60
|
+
확인하고 명시적으로 동의한 Tier 2 CLI도 사용한다. 동의 없는 설치·로그인은 하지 않는다.
|
|
61
|
+
- **Chrome 판정(`chromeDecision`)**: 공식 Claude in Chrome이 연결되고 제3자 provider가 아닐 때만
|
|
62
|
+
visible mode로 사용한다. login/CAPTCHA/MFA·최종 submit은 항상 사용자가 직접 완료한다.
|
|
63
|
+
- **한계(정직 표기)**: 라우터는 경로 결정·검증·다운그레이드·plan 생성까지다. 플러그인은 MCP 서버를
|
|
64
|
+
spawn하거나 MCP tool을 호출하거나 Chrome을 조작하지 않는다(비목표: 임의 MCP 자동 설치 금지).
|
|
65
|
+
|
|
66
|
+
## 중단·다운그레이드 조건
|
|
67
|
+
|
|
68
|
+
- secure credential storage가 없어 **평문 저장**으로 떨어지는 환경(manifest
|
|
69
|
+
`credential_store.abort_auto_login_on_plaintext: true`) → 자동 로그인 중단,
|
|
70
|
+
저장 위치·위험·logout/삭제 방법 안내.
|
|
71
|
+
- 비대화형 환경(`claude -p`, CI) → manifest `noninteractive_policy.login`에 따라
|
|
72
|
+
deny 또는 guided manual (예: `GH_TOKEN`/`SUPABASE_ACCESS_TOKEN`은 사용자가 직접 설정).
|
|
73
|
+
- 사용자가 CLI 설치를 거부하거나 preflight 실패 → 위 라우터가 allowlisted 공식 MCP →
|
|
74
|
+
visible Chrome → guided manual 순으로 낮춘다. login/CAPTCHA/MFA·최종 submit은 항상 사용자 몫.
|
|
75
|
+
|
|
76
|
+
## GitHub OAuth App (guided manual)
|
|
77
|
+
|
|
78
|
+
GitHub OAuth App 생성·callback 등록은 자동화하지 않고 콘솔 단계로 안내한다.
|
|
79
|
+
OAuth App은 callback URL을 하나만 지원하므로 local·preview·production 동시 등록을 약속하지 않고,
|
|
80
|
+
환경별 app 분리 또는 GitHub App 전환을 옵션으로 제시한다. Client ID/Secret 값은 사용자가
|
|
81
|
+
직접 콘솔에서 확인·입력하며 플러그인은 값을 받지 않는다.
|
|
82
|
+
|
|
83
|
+
## 부트스트랩 이후 (guided manual 예시)
|
|
84
|
+
|
|
85
|
+
- env 설정: 어떤 변수가 왜 필요한지 설명하고 `vercel env add NAME` 같은 **대화형 명령** 또는
|
|
86
|
+
콘솔 화면을 안내한다. 실제 값은 사용자가 그 프롬프트/화면에 직접 입력한다.
|
|
87
|
+
- 리소스 생성·배포·삭제: 명령과 영향을 안내하고 사용자가 실행한다. 유료·production·삭제는
|
|
88
|
+
특히 사용자 실행 원칙.
|