simt-dev 0.6.2 → 0.7.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/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// A zero-dependency scan of the staged diff for secrets (plan 0003 R52).
|
|
2
|
+
//
|
|
3
|
+
// Two finding sources, deliberately both. A pattern library catches the shapes
|
|
4
|
+
// vendors publish — an AWS key id, a private key header, a token prefix — and
|
|
5
|
+
// Shannon entropy catches the high-entropy blobs that carry no prefix at all:
|
|
6
|
+
// a 32-character random base64 string is a secret whether or not a vendor named
|
|
7
|
+
// it. Neither needs a network call or an install step, which is the point. A
|
|
8
|
+
// gate that needs an install step is a gate a fresh clone does not have
|
|
9
|
+
// (ADR-012, zero runtime dependencies).
|
|
10
|
+
//
|
|
11
|
+
// Three boundaries, each load-bearing:
|
|
12
|
+
//
|
|
13
|
+
// - Added lines only. The scan reads `git diff --cached`, so a secret already
|
|
14
|
+
// sitting in history is not this gate's finding: the commit about to be made
|
|
15
|
+
// is. Flagging context lines would block commits for work the author did not
|
|
16
|
+
// stage, which is the failure `format`'s staged-only rule exists to prevent.
|
|
17
|
+
// - Findings never print the matched bytes. The hook output lands in a
|
|
18
|
+
// terminal, a CI log and often a transcript an agent reads back: echoing the
|
|
19
|
+
// secret into a report about the secret is the leak with extra steps. A
|
|
20
|
+
// finding carries a four-character prefix (the part a vendor publishes as a
|
|
21
|
+
// type — `AKIA`, `sk-`, `ghp_`) and nothing else.
|
|
22
|
+
// - Placeholders are not secrets. `password = "changeme-example"` in a README
|
|
23
|
+
// is documentation, and a gate that blocks it teaches the exact `--no-verify`
|
|
24
|
+
// habit the dispatcher's fail-closed rule exists to prevent.
|
|
25
|
+
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { promisify } from 'node:util';
|
|
28
|
+
|
|
29
|
+
const exec = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
/** The length at which a token is worth measuring, and the entropy it must reach. */
|
|
32
|
+
const ENTROPY_MIN_LENGTH = 32;
|
|
33
|
+
export const ENTROPY_MIN_BITS = 4.5;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A value is a placeholder when the WHOLE value is placeholder-shaped, not when
|
|
37
|
+
* it contains a placeholder word: a real secret may contain "example" and still
|
|
38
|
+
* be a secret, and the substring test blocked exactly that commit (review 0003 Q24).
|
|
39
|
+
*/
|
|
40
|
+
const PLACEHOLDER_WORD = /^(?:example|sample|placeholder|changeme|change[-_]?me|dummy|fake|redacted|notasecret|undefined|null|none|true|false|x+)$/i;
|
|
41
|
+
const PLACEHOLDER_SLOT = /^(?:<[^<>]*>|\{\{?[a-z0-9_.-]+\}\}?|your[-_][a-z0-9_-]*|my[-_][a-z0-9_-]*)$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} value the candidate secret
|
|
45
|
+
* @returns {boolean} true when the whole value is documentation, not a leak
|
|
46
|
+
*/
|
|
47
|
+
function isPlaceholder(value) {
|
|
48
|
+
const v = String(value).trim();
|
|
49
|
+
if (v === '') return true;
|
|
50
|
+
if (PLACEHOLDER_SLOT.test(v)) return true;
|
|
51
|
+
const words = v.split(/[^a-zA-Z]+/).filter(Boolean);
|
|
52
|
+
if (words.length === 0) return true; // punctuation only: `…`, `-`
|
|
53
|
+
return words.every((w) => PLACEHOLDER_WORD.test(w));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The pattern library. Each row names itself so the finding tells the reader
|
|
58
|
+
* WHICH shape matched — "a secret was found" sends them hunting for it;
|
|
59
|
+
* "aws-access-key-id on line 12" says what to revoke.
|
|
60
|
+
*
|
|
61
|
+
* `groups`, when set, lists the captures that may hold the VALUE — what the
|
|
62
|
+
* placeholder check and the redaction run on; the first defined capture wins.
|
|
63
|
+
* Without it the whole match is the value.
|
|
64
|
+
*/
|
|
65
|
+
const PATTERNS = [
|
|
66
|
+
{ id: 'aws-access-key-id', re: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
|
|
67
|
+
// The type word is not the discriminator, "PRIVATE KEY" is: RSA, EC, DSA,
|
|
68
|
+
// OPENSSH, PGP and ENCRYPTED all carry the same header shape (review 0003 Q24).
|
|
69
|
+
{ id: 'private-key-block', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g },
|
|
70
|
+
{ id: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
|
|
71
|
+
{ id: 'gitlab-token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
72
|
+
{ id: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
73
|
+
{ id: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
74
|
+
{ id: 'stripe-live-key', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g },
|
|
75
|
+
{ id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
76
|
+
{ id: 'openai-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
77
|
+
{ id: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
78
|
+
// The key name is prefix-aware — DB_PASSWORD, aws_secret_access_key — and the
|
|
79
|
+
// value may be quoted or bare, because .env and YAML do not quote (review 0003 Q24).
|
|
80
|
+
{
|
|
81
|
+
id: 'assigned-secret',
|
|
82
|
+
re: /\b[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([A-Za-z0-9+/=_-]{8,}))/gi,
|
|
83
|
+
groups: [1, 2, 3],
|
|
84
|
+
},
|
|
85
|
+
// The password half of https://user:pw@host: the only place a URL carries one.
|
|
86
|
+
{
|
|
87
|
+
id: 'url-userinfo',
|
|
88
|
+
re: /\b[a-z][a-z0-9+.-]*:\/\/[^:/\s@]+:([A-Za-z0-9._~+-]{8,})@/g,
|
|
89
|
+
groups: [1],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** Longest base64ish run worth measuring for entropy. */
|
|
94
|
+
const ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{32,}/g;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Subresource-integrity hashes: `sha256-<base64>` and kin. A deterministic
|
|
98
|
+
* digest of file content, present in every lockfile and build manifest —
|
|
99
|
+
* high-entropy bytes that are never a secret (review 0003 Q24).
|
|
100
|
+
*/
|
|
101
|
+
const SRI_HASH = /^sha(1|256|384|512)-[A-Za-z0-9+/]+={0,2}$/;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Shannon entropy of a string, in bits per character.
|
|
105
|
+
*
|
|
106
|
+
* The detector's whole discriminator in one function: a uniformly random token
|
|
107
|
+
* approaches log2(alphabet size) — near 6 bits for base64 — while English,
|
|
108
|
+
* hex digests and repeated identifiers fall below 4.5. A sha256 hex digest tops
|
|
109
|
+
* out at 4.0 over sixteen symbols, which is exactly why the threshold sits above
|
|
110
|
+
* it: a commit hash is not a secret, and a gate that calls it one gets disabled.
|
|
111
|
+
*/
|
|
112
|
+
export function entropy(text) {
|
|
113
|
+
const freq = new Map();
|
|
114
|
+
for (const ch of text) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
115
|
+
let bits = 0;
|
|
116
|
+
for (const n of freq.values()) {
|
|
117
|
+
const p = n / text.length;
|
|
118
|
+
bits -= p * Math.log2(p);
|
|
119
|
+
}
|
|
120
|
+
return bits;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The four characters a finding may show: the prefix a vendor publishes as a type. */
|
|
124
|
+
function redact(value) {
|
|
125
|
+
return `${value.slice(0, 4)}…`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Every secret span in the text: pattern matches in library order, then entropy
|
|
130
|
+
* over whatever no pattern claimed. One function because the gate report and
|
|
131
|
+
* the redactor must agree on what a secret is: two matchers drift, and the
|
|
132
|
+
* drift is a token one of them stores (review 0003 Q36).
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {{start: number, end: number, kind: string, preview: string, entropy?: number}[]}
|
|
136
|
+
*/
|
|
137
|
+
function findSpans(text) {
|
|
138
|
+
const found = [];
|
|
139
|
+
const claimed = [];
|
|
140
|
+
|
|
141
|
+
for (const pattern of PATTERNS) {
|
|
142
|
+
pattern.re.lastIndex = 0;
|
|
143
|
+
for (const match of text.matchAll(pattern.re)) {
|
|
144
|
+
const groups = pattern.groups ?? [0];
|
|
145
|
+
let value = null;
|
|
146
|
+
for (const g of groups) {
|
|
147
|
+
if (match[g] !== undefined) {
|
|
148
|
+
value = match[g];
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (value === null) continue;
|
|
153
|
+
if (isPlaceholder(value)) continue;
|
|
154
|
+
const at = match.index + match[0].indexOf(value);
|
|
155
|
+
// Overlap, not equality: a token a pattern named is one finding, and the
|
|
156
|
+
// entropy reading of the same bytes would double the count a gate reports.
|
|
157
|
+
if (claimed.some(([a, b]) => at < b && a < at + value.length)) continue;
|
|
158
|
+
claimed.push([at, at + value.length]);
|
|
159
|
+
found.push({ start: at, end: at + value.length, kind: pattern.id, preview: redact(value) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
ENTROPY_CANDIDATE.lastIndex = 0;
|
|
164
|
+
for (const match of text.matchAll(ENTROPY_CANDIDATE)) {
|
|
165
|
+
const value = match[0];
|
|
166
|
+
if (value.length < ENTROPY_MIN_LENGTH) continue;
|
|
167
|
+
if (SRI_HASH.test(value)) continue;
|
|
168
|
+
if (isPlaceholder(value)) continue;
|
|
169
|
+
if (claimed.some(([a, b]) => match.index < b && a < match.index + value.length)) continue;
|
|
170
|
+
const bits = entropy(value);
|
|
171
|
+
if (bits < ENTROPY_MIN_BITS) continue;
|
|
172
|
+
found.push({
|
|
173
|
+
start: match.index,
|
|
174
|
+
end: match.index + value.length,
|
|
175
|
+
kind: 'high-entropy',
|
|
176
|
+
preview: redact(value),
|
|
177
|
+
entropy: Number(bits.toFixed(1)),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scan one line's worth of added text.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} text the added line, without the leading `+`
|
|
188
|
+
* @returns {{kind: string, preview: string, entropy?: number}[]} findings in
|
|
189
|
+
* pattern order, then entropy over whatever no pattern claimed
|
|
190
|
+
*/
|
|
191
|
+
export function scanText(text) {
|
|
192
|
+
return findSpans(text).map(({ kind, preview, entropy }) =>
|
|
193
|
+
entropy === undefined ? { kind, preview } : { kind, preview, entropy },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Replace every secret span with `[redacted:<kind>]`, leaving the rest of the
|
|
199
|
+
* text byte-identical. Lesson candidates store the commands that failed, and a
|
|
200
|
+
* command can carry a token (`curl -H "Authorization: Bearer …"`); storing it
|
|
201
|
+
* verbatim writes the secret into a JSONL file that is then read, rendered and
|
|
202
|
+
* injected into prompts (review 0003 Q36).
|
|
203
|
+
*
|
|
204
|
+
* @param {string} text
|
|
205
|
+
* @returns {string} the text with secret values replaced
|
|
206
|
+
*/
|
|
207
|
+
export function redactText(text) {
|
|
208
|
+
const spans = findSpans(String(text ?? '')).sort((a, b) => b.start - a.start);
|
|
209
|
+
let out = String(text ?? '');
|
|
210
|
+
for (const span of spans) {
|
|
211
|
+
out = `${out.slice(0, span.start)}[redacted:${span.kind}]${out.slice(span.end)}`;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Lockfiles are skipped by default (review 0003 Q24): their high-entropy bytes
|
|
218
|
+
* are integrity hashes and version pins, and a dependency bump is a commit the
|
|
219
|
+
* gate must not block. The SRI allow-list above covers the same shapes wherever
|
|
220
|
+
* they appear outside a lockfile.
|
|
221
|
+
*/
|
|
222
|
+
const LOCKFILES = new Set([
|
|
223
|
+
'package-lock.json',
|
|
224
|
+
'npm-shrinkwrap.json',
|
|
225
|
+
'yarn.lock',
|
|
226
|
+
'bun.lockb',
|
|
227
|
+
'pnpm-lock.yaml',
|
|
228
|
+
'Cargo.lock',
|
|
229
|
+
'go.sum',
|
|
230
|
+
'composer.lock',
|
|
231
|
+
'Gemfile.lock',
|
|
232
|
+
'Pipfile.lock',
|
|
233
|
+
'poetry.lock',
|
|
234
|
+
'uv.lock',
|
|
235
|
+
'pubspec.lock',
|
|
236
|
+
'mix.lock',
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
/** A line carrying this marker is the author's explicit exemption for that line. */
|
|
240
|
+
const PRAGMA = 'simt:allow-secret';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One glob to one anchored regex: `**` crosses separators, `*` and `?` do not.
|
|
244
|
+
* The same small matcher the ignore lists render with, because a repository
|
|
245
|
+
* that writes `vendor/**` in `.simtignore` expects the same reading here.
|
|
246
|
+
*/
|
|
247
|
+
function globToRegExp(glob) {
|
|
248
|
+
let re = '';
|
|
249
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
250
|
+
const c = glob[i];
|
|
251
|
+
if (c === '*') {
|
|
252
|
+
if (glob[i + 1] === '*') {
|
|
253
|
+
re += '.*';
|
|
254
|
+
i += 1;
|
|
255
|
+
} else {
|
|
256
|
+
re += '[^/]*';
|
|
257
|
+
}
|
|
258
|
+
} else if (c === '?') {
|
|
259
|
+
re += '[^/]';
|
|
260
|
+
} else {
|
|
261
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return new RegExp(`^${re}$`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Scan the staged diff: the added lines of what the next commit would record.
|
|
269
|
+
*
|
|
270
|
+
* File attribution is a hunk state machine rather than a prefix test, because
|
|
271
|
+
* the two are indistinguishable by prefix alone: an added line whose content
|
|
272
|
+
* starts with `++ ` renders as `+++ something`, which looks exactly like a
|
|
273
|
+
* `+++ b/…` file header. Headers appear only outside hunks and content only
|
|
274
|
+
* inside them, so the `diff --git` boundary and the `@@` header decide which
|
|
275
|
+
* one a line is.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} root repository root
|
|
278
|
+
* @param {{ignore?: string[]}} [options] `ignore` is the manifest's
|
|
279
|
+
* `gates.secretScan.ignore`: globs of repository paths to skip (review 0003 Q24)
|
|
280
|
+
* @returns {Promise<{findings: {file: string, line: number, kind: string,
|
|
281
|
+
* preview: string, entropy?: number}[], lines: number, error: string|null}>}
|
|
282
|
+
* `error` is set only when git itself could not answer; callers fail closed on
|
|
283
|
+
* it rather than reading silence as a clean diff.
|
|
284
|
+
*/
|
|
285
|
+
export async function scanStaged(root, options = {}) {
|
|
286
|
+
const ignore = (Array.isArray(options.ignore) ? options.ignore : [])
|
|
287
|
+
.filter((g) => typeof g === 'string' && g !== '')
|
|
288
|
+
.map(globToRegExp);
|
|
289
|
+
|
|
290
|
+
let stdout;
|
|
291
|
+
try {
|
|
292
|
+
// Every flag here defeats a way a repository can keep the scan from seeing its
|
|
293
|
+
// own staged bytes (review 0003 Q02): `diff.external` routes the diff to a
|
|
294
|
+
// program that prints nothing, `-diff` in `.gitattributes` and genuinely binary
|
|
295
|
+
// files make git print no text at all, a textconv rewrites the bytes on the way
|
|
296
|
+
// out, `diff.mnemonicPrefix` renames the header prefixes the attribution below
|
|
297
|
+
// reads, and C-quoting mangles a non-ASCII name. The scan asks git for the staged
|
|
298
|
+
// bytes with its own flags and ignores what the repository asked for.
|
|
299
|
+
({ stdout } = await exec(
|
|
300
|
+
'git',
|
|
301
|
+
[
|
|
302
|
+
'-c', 'core.quotePath=false',
|
|
303
|
+
'diff',
|
|
304
|
+
'--cached',
|
|
305
|
+
'--no-ext-diff',
|
|
306
|
+
'--no-textconv',
|
|
307
|
+
'--text',
|
|
308
|
+
'--unified=0',
|
|
309
|
+
'--no-color',
|
|
310
|
+
'--src-prefix=a/',
|
|
311
|
+
'--dst-prefix=b/',
|
|
312
|
+
],
|
|
313
|
+
{ cwd: root, maxBuffer: 32 * 1024 * 1024 },
|
|
314
|
+
));
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
317
|
+
const reason = (stderr || String(error.message ?? error)).split('\n')[0];
|
|
318
|
+
return { findings: [], lines: 0, error: reason };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const findings = [];
|
|
322
|
+
let lines = 0;
|
|
323
|
+
let file = null;
|
|
324
|
+
let lineNo = 0;
|
|
325
|
+
let inHunk = false;
|
|
326
|
+
let fileSkipped = false;
|
|
327
|
+
|
|
328
|
+
for (const raw of stdout.split('\n')) {
|
|
329
|
+
if (raw.startsWith('diff --git ')) {
|
|
330
|
+
inHunk = false;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (raw.startsWith('@@ ')) {
|
|
334
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
335
|
+
if (hunk) {
|
|
336
|
+
lineNo = Number(hunk[1]);
|
|
337
|
+
inHunk = true;
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (!inHunk) {
|
|
342
|
+
// Outside a hunk: headers only. `+++ /dev/null` is the removal side of a
|
|
343
|
+
// deletion; nothing is added under it.
|
|
344
|
+
if (raw.startsWith('+++ ')) {
|
|
345
|
+
const path = raw.slice(4).trim();
|
|
346
|
+
file = path === '/dev/null' ? null : path.replace(/^b\//, '');
|
|
347
|
+
const name = file === null ? '' : file.slice(file.lastIndexOf('/') + 1);
|
|
348
|
+
fileSkipped = file !== null && (LOCKFILES.has(name) || ignore.some((re) => re.test(file)));
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!raw.startsWith('+')) continue; // -U0 has no context; this is `\ No newline…`
|
|
353
|
+
lines += 1;
|
|
354
|
+
if (file !== null && !fileSkipped && !raw.includes(PRAGMA)) {
|
|
355
|
+
for (const hit of scanText(raw.slice(1))) {
|
|
356
|
+
findings.push({ file, line: lineNo, ...hit });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
lineNo += 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { findings, lines, error: null };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The report a failing gate prints: one finding per line, redacted. */
|
|
366
|
+
export function renderFindings(findings) {
|
|
367
|
+
return findings
|
|
368
|
+
.map(
|
|
369
|
+
(f) =>
|
|
370
|
+
` ${f.file}:${f.line} ${f.kind} ${f.preview}` +
|
|
371
|
+
(f.entropy !== undefined ? ` (entropy ${f.entropy} bits)` : ''),
|
|
372
|
+
)
|
|
373
|
+
.join('\n');
|
|
374
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Machine-local state lives here, not in the tree (review 0003 Q19).
|
|
2
|
+
//
|
|
3
|
+
// Everything this tool writes for itself — the ledger, the check cache, the
|
|
4
|
+
// index, leases, the journal, briefs, gate logs, bench runs — used to live
|
|
5
|
+
// directly under `.simt/`, so `simt check` changed the tree id its own
|
|
6
|
+
// evidence was measured against, and a slice worktree landed in the main
|
|
7
|
+
// checkout as an embedded gitlink. The fix is one directory: local state
|
|
8
|
+
// goes under `.simt/local/`, which is ignored and excluded from the tree,
|
|
9
|
+
// while committed content (`packs/`, `bin/`, `bench/tasks/`, profiles) stays
|
|
10
|
+
// where it was.
|
|
11
|
+
//
|
|
12
|
+
// Readers try the new path first and fall back to the old one for one
|
|
13
|
+
// release, so a repository whose state predates the move keeps working until
|
|
14
|
+
// the next write migrates it. Writers always write the new path.
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { STATE_DIR } from './name.mjs';
|
|
20
|
+
|
|
21
|
+
/** The ignored directory every machine-local write goes under. */
|
|
22
|
+
export const LOCAL_DIR = join(STATE_DIR, 'local');
|
|
23
|
+
|
|
24
|
+
/** A machine-local path, relative to the repository root. */
|
|
25
|
+
export function localRel(...parts) {
|
|
26
|
+
return join(LOCAL_DIR, ...parts);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where a machine-local file is read from: the new path first, then each
|
|
31
|
+
* legacy path in order. Writers use only the first entry.
|
|
32
|
+
*/
|
|
33
|
+
function readCandidates(root, rel, legacy = []) {
|
|
34
|
+
return [join(root, rel), ...legacy.map((l) => join(root, l))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The first readable candidate, or null when none exists. */
|
|
38
|
+
export async function readStateFile(root, rel, legacy = []) {
|
|
39
|
+
for (const path of readCandidates(root, rel, legacy)) {
|
|
40
|
+
try {
|
|
41
|
+
return { text: await readFile(path, 'utf8'), path };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT') throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { text: null, path: join(root, rel) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every machine-local path the tree id must exclude: the whole local area
|
|
51
|
+
* plus the legacy paths a pre-move checkout may still carry, so an old
|
|
52
|
+
* `check.json` or worktree can neither change the id nor be committed.
|
|
53
|
+
*/
|
|
54
|
+
export const TREE_EXCLUDES = [
|
|
55
|
+
LOCAL_DIR,
|
|
56
|
+
join(STATE_DIR, 'ledger.jsonl'),
|
|
57
|
+
join(STATE_DIR, 'lessons.jsonl'),
|
|
58
|
+
join(STATE_DIR, 'leases.json'),
|
|
59
|
+
join(STATE_DIR, 'check.json'),
|
|
60
|
+
join(STATE_DIR, 'index.sqlite'),
|
|
61
|
+
join(STATE_DIR, 'index.jsonl'),
|
|
62
|
+
join(STATE_DIR, 'brief.md'),
|
|
63
|
+
join(STATE_DIR, 'journal'),
|
|
64
|
+
join(STATE_DIR, 'worktrees'),
|
|
65
|
+
join(STATE_DIR, 'gate'),
|
|
66
|
+
join(STATE_DIR, 'distilled'),
|
|
67
|
+
join(STATE_DIR, 'reviews'),
|
|
68
|
+
join(STATE_DIR, 'proposals'),
|
|
69
|
+
join(STATE_DIR, 'compiled'),
|
|
70
|
+
join(STATE_DIR, 'critic-labels.jsonl'),
|
|
71
|
+
join(STATE_DIR, 'bench', 'runs'),
|
|
72
|
+
join(STATE_DIR, 'bench', 'vault'),
|
|
73
|
+
join(STATE_DIR, 'bench', 'prices.json'),
|
|
74
|
+
join(STATE_DIR, 'bench', 'ablation.json'),
|
|
75
|
+
join(STATE_DIR, 'bench', 'probes'),
|
|
76
|
+
];
|
package/src/status.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The `--line` status cache (plan 0003 R48).
|
|
2
|
+
//
|
|
3
|
+
// `status --line` is for shell prompts, tmux and agent status lines: it must
|
|
4
|
+
// answer in well under 50 ms, which rules out everything `check` does (rule
|
|
5
|
+
// loading, projection, gate probing). So `check` — the one command that
|
|
6
|
+
// already measures all of it — writes `.simt/check.json` on every run, and
|
|
7
|
+
// `--line` reads only that file plus one `git rev-parse HEAD` to notice new
|
|
8
|
+
// commits. No fingerprint, no rule reads, no capability probe.
|
|
9
|
+
//
|
|
10
|
+
// Three honesties, each a failure this module refuses to be:
|
|
11
|
+
//
|
|
12
|
+
// - The cache is local state, never content (the same standing as the
|
|
13
|
+
// journal, the ledger and the index). Writers that change files `check`
|
|
14
|
+
// reads (`init`, `sync`, `upgrade`, `hooks install`, `undo`) delete it
|
|
15
|
+
// when they apply, so the line goes back to "no cache" instead of
|
|
16
|
+
// reporting the tree as it was. Hand edits between checks are the
|
|
17
|
+
// documented blind spot: the line is the gate state *as of the last
|
|
18
|
+
// check*, and says `stale` only when HEAD moved.
|
|
19
|
+
// - A blocking finding that fits no slot still fails the line. The version
|
|
20
|
+
// pin, an invalid rule, a bad gate cwd — none of these is rules, hooks
|
|
21
|
+
// or gates-count, and a line that showed all slots green while the gate
|
|
22
|
+
// fails would be the lie prompts are for. So PASS/FAIL is carried
|
|
23
|
+
// explicitly rather than inferred from the symbols.
|
|
24
|
+
// - The write is atomic (tmp + rename) and fixed-size: only counts and
|
|
25
|
+
// booleans are stored, never findings, so a huge gate still leaves a
|
|
26
|
+
// ~200-byte file and a killed `check` never leaves half a document.
|
|
27
|
+
|
|
28
|
+
import { execFile } from 'node:child_process';
|
|
29
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
|
|
33
|
+
import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
|
|
34
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
35
|
+
|
|
36
|
+
const exec = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
const CACHE_FILE = join(LOCAL_DIR, 'check.json');
|
|
39
|
+
const CACHE_FILE_LEGACY = '.simt/check.json';
|
|
40
|
+
const CACHE_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The commit the cached state was measured at, or null when git cannot say
|
|
44
|
+
* (not a repository, no commits yet, git missing). Null is trust, not failure:
|
|
45
|
+
* without a HEAD there is nothing to compare, and refusing the line would
|
|
46
|
+
* make exported trees — the ones with no `.git` at all — unreportable.
|
|
47
|
+
*/
|
|
48
|
+
export async function currentHead(root) {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await exec('git', ['rev-parse', 'HEAD'], { cwd: root });
|
|
51
|
+
return stdout.trim() || null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cachePath(root) {
|
|
58
|
+
return join(root, CACHE_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Add the when and the where to a check-measured summary. The summary itself
|
|
63
|
+
* is built by `check`, which is the only command that measures all of it;
|
|
64
|
+
* this only stamps it.
|
|
65
|
+
*/
|
|
66
|
+
export async function buildCacheRecord(root, summary) {
|
|
67
|
+
return {
|
|
68
|
+
version: CACHE_VERSION,
|
|
69
|
+
at: Date.now(),
|
|
70
|
+
head: await currentHead(root),
|
|
71
|
+
rulesOk: summary.rulesOk,
|
|
72
|
+
hooksOk: summary.hooksOk,
|
|
73
|
+
gatesRun: summary.gatesRun,
|
|
74
|
+
gatesTotal: summary.gatesTotal,
|
|
75
|
+
blocking: summary.blocking,
|
|
76
|
+
warnings: summary.warnings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Atomically replace the cache. A killed `check` leaves the previous record, not half of one. */
|
|
81
|
+
export async function writeCheckCache(root, record) {
|
|
82
|
+
const path = cachePath(root);
|
|
83
|
+
await mkdir(join(root, LOCAL_DIR), { recursive: true });
|
|
84
|
+
const body = `${JSON.stringify(record)}\n`;
|
|
85
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
86
|
+
await writeFile(tmp, body, 'utf8');
|
|
87
|
+
await rename(tmp, path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The last record `check` wrote, or null when it never ran (or a writer invalidated it). */
|
|
91
|
+
export async function readCheckCache(root) {
|
|
92
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(cachePath(root), 'utf8').catch(async (error) => {
|
|
96
|
+
if (error.code !== 'ENOENT') throw error;
|
|
97
|
+
return readFile(join(root, CACHE_FILE_LEGACY), 'utf8');
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT') return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const record = JSON.parse(raw);
|
|
105
|
+
if (record.version !== CACHE_VERSION) return null;
|
|
106
|
+
return record;
|
|
107
|
+
} catch {
|
|
108
|
+
// A damaged cache is no cache: the next `check` rewrites it, and a prompt
|
|
109
|
+
// that refused to render over a corrupt byte would stay broken until then.
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Forget the cached state. Called by writers that change files `check` reads,
|
|
116
|
+
* after they apply — never on preview or dry-run, where nothing changed and
|
|
117
|
+
* the record is still true.
|
|
118
|
+
*/
|
|
119
|
+
export async function invalidateCheckCache(root) {
|
|
120
|
+
try {
|
|
121
|
+
await unlink(cachePath(root));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error.code === 'ENOENT') return;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The hooks slot of the line: every declared gate routed through a current
|
|
130
|
+
* shim. A repository that declares no gate has nothing to enforce, so the
|
|
131
|
+
* slot is vacuously true rather than forever red (the same answer `init`
|
|
132
|
+
* prints in prose). Gates run/total is counted by `check` from the runnable
|
|
133
|
+
* verdicts, not here: routing and runnability are different slots.
|
|
134
|
+
*/
|
|
135
|
+
export async function hooksSummary(root, manifest, exists) {
|
|
136
|
+
const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
|
|
137
|
+
const evidence = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
|
|
138
|
+
if (declared.length === 0 && !evidence) return { ok: true };
|
|
139
|
+
const state = await readHooksState(root, { declaresGate: (event) => wantsShim(manifest, exists, event) });
|
|
140
|
+
return { ok: state.complete };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One line, always: `simt PASS ✓rules ✓hooks 3/3gates`, longest honest case
|
|
145
|
+
* `simt FAIL ✗rules ✗hooks 0/12gates ⚠9 stale`. Counts stay small because they
|
|
146
|
+
* count gates and warnings, never findings; the ≤ 60-char budget is asserted
|
|
147
|
+
* on a synthetic worst case, not on the hope that gates stay few.
|
|
148
|
+
*/
|
|
149
|
+
export function renderStatusLine(record, stale) {
|
|
150
|
+
const outcome = record.blocking === 0 ? 'PASS' : 'FAIL';
|
|
151
|
+
const rules = record.rulesOk ? '✓rules' : '✗rules';
|
|
152
|
+
const hooks = record.hooksOk ? '✓hooks' : '✗hooks';
|
|
153
|
+
let line = `simt ${outcome} ${rules} ${hooks} ${record.gatesRun}/${record.gatesTotal}gates`;
|
|
154
|
+
if (record.warnings > 0) line += ` ⚠${record.warnings}`;
|
|
155
|
+
if (stale) line += ' stale';
|
|
156
|
+
return line;
|
|
157
|
+
}
|
package/src/toolwords.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { VARIANTS_MARKER } from './kernel/variants.mjs';
|
|
|
22
22
|
import { LESSON_KEYS } from './memory/lessons.mjs';
|
|
23
23
|
import { DECISION_KEYS } from './memory/decide.mjs';
|
|
24
24
|
import { PROMPT_WORDS } from './mcp/prompts.mjs';
|
|
25
|
+
import { AGENT_STEM, SKILL_STEM } from './agents/render.mjs';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* `simt-` names that belong to this tool and never to a repository.
|
|
@@ -45,6 +46,10 @@ export const TOOL_WORDS = new Set([
|
|
|
45
46
|
'simt-dev',
|
|
46
47
|
'simt-dev-check',
|
|
47
48
|
'simt-hooks',
|
|
49
|
+
// The one agent this tool ships, named by the stem every harness's agents file shares.
|
|
50
|
+
AGENT_STEM,
|
|
51
|
+
// The companion skill the agent reads on demand; its stem is the directory name.
|
|
52
|
+
SKILL_STEM,
|
|
48
53
|
]);
|
|
49
54
|
|
|
50
55
|
/**
|