simt-dev 0.6.3 → 0.7.1
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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.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-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- 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/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- 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 +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- 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 +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- 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 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- 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 +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- 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 +31 -14
- package/src/projection/manifest.mjs +89 -53
- 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 +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Repo-local skills as resolver nodes.
|
|
2
|
+
//
|
|
3
|
+
// The registry is what simt-dev ships; the skill directories a repo holds are what
|
|
4
|
+
// that repo owns. After plan 0005 S2 the registry is small and generic, so a skill
|
|
5
|
+
// that lives only in a repo was invisible to `skills find` and `skills brief`, and
|
|
6
|
+
// the NOT SELECTED block points a reader at `skills show` by name - a remedy that
|
|
7
|
+
// 404s for a name the registry does not declare.
|
|
8
|
+
//
|
|
9
|
+
// Presence in the repo's skill directory is the eligibility signal: a skill the repo
|
|
10
|
+
// installed is about the repo, and the stack it belongs to is the stack the repo has.
|
|
11
|
+
// The node's `stack` is therefore the repo's own signals, which makes the stack gate
|
|
12
|
+
// a self-overlap that always passes, and the stack score reads "more this repo's"
|
|
13
|
+
// rather than "compatible with a stranger".
|
|
14
|
+
|
|
15
|
+
import { stat } from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
import { BODY_BYTE_BUDGET } from '../graph/compile.mjs';
|
|
18
|
+
import { installedSkills } from '../projection/disclosure.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The repo's own skills, as nodes the resolver can rank.
|
|
22
|
+
*
|
|
23
|
+
* Names the registry declares are excluded: the registry entry is canonical and the
|
|
24
|
+
* local file is its installed copy, so keeping both would rank one skill twice.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} root the repository
|
|
27
|
+
* @param {object} registry the loaded registry
|
|
28
|
+
* @param {string[]} repoStack the repo's signals (stack ∪ facts)
|
|
29
|
+
* @returns {Promise<object[]>} resolver-ready nodes, name-sorted
|
|
30
|
+
*/
|
|
31
|
+
export async function localSkillNodes(root, registry, repoStack) {
|
|
32
|
+
const installed = await installedSkills(root);
|
|
33
|
+
const known = new Set(registry.skills.map((s) => s.name));
|
|
34
|
+
const nodes = [];
|
|
35
|
+
for (const { name, trigger, file } of installed) {
|
|
36
|
+
if (known.has(name)) continue;
|
|
37
|
+
let size;
|
|
38
|
+
try {
|
|
39
|
+
size = (await stat(file)).size;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT') throw error;
|
|
42
|
+
continue; // gone between listing and measuring
|
|
43
|
+
}
|
|
44
|
+
nodes.push({
|
|
45
|
+
name,
|
|
46
|
+
summary: trigger,
|
|
47
|
+
keywords: [],
|
|
48
|
+
capabilities: [],
|
|
49
|
+
stack: [...repoStack],
|
|
50
|
+
paths: [],
|
|
51
|
+
bytes: size,
|
|
52
|
+
deferred: size > BODY_BYTE_BUDGET,
|
|
53
|
+
localFile: file,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
57
|
+
return nodes;
|
|
58
|
+
}
|
package/src/review/critic.mjs
CHANGED
|
@@ -32,9 +32,12 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
|
|
|
32
32
|
|
|
33
33
|
const exec = promisify(execFile);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
36
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
37
|
+
|
|
38
|
+
// Locked on-disk beneath machine-local state, like the distilled cache (R38) —
|
|
36
39
|
// proposals keyed by content digest, never projected and never committed.
|
|
37
|
-
const REVIEW_DIR = '
|
|
40
|
+
const REVIEW_DIR = join(LOCAL_DIR, 'reviews');
|
|
38
41
|
const ENGINE_REVIEW_LOCAL = 'review-local';
|
|
39
42
|
|
|
40
43
|
/** The diff brief's byte cap; `--budget` overrides it, `0` means unlimited. */
|
|
@@ -214,7 +217,7 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
|
|
|
214
217
|
if (args.length > 0) {
|
|
215
218
|
return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
|
|
216
219
|
}
|
|
217
|
-
const root = resolve(flags
|
|
220
|
+
const root = resolve(commandRoot(flags));
|
|
218
221
|
const listed = await tracked(root);
|
|
219
222
|
if (listed.error) return listed.error;
|
|
220
223
|
|
package/src/secrets.mjs
CHANGED
|
@@ -33,39 +33,73 @@ const ENTROPY_MIN_LENGTH = 32;
|
|
|
33
33
|
export const ENTROPY_MIN_BITS = 4.5;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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).
|
|
38
39
|
*/
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
+
}
|
|
41
55
|
|
|
42
56
|
/**
|
|
43
57
|
* The pattern library. Each row names itself so the finding tells the reader
|
|
44
58
|
* WHICH shape matched — "a secret was found" sends them hunting for it;
|
|
45
59
|
* "aws-access-key-id on line 12" says what to revoke.
|
|
46
60
|
*
|
|
47
|
-
* `
|
|
48
|
-
* check and the redaction run on
|
|
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.
|
|
49
64
|
*/
|
|
50
65
|
const PATTERNS = [
|
|
51
66
|
{ id: 'aws-access-key-id', re: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
|
|
52
|
-
|
|
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 },
|
|
53
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 },
|
|
54
73
|
{ id: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
55
74
|
{ id: 'stripe-live-key', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g },
|
|
56
75
|
{ id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
57
76
|
{ id: 'openai-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
58
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).
|
|
59
80
|
{
|
|
60
81
|
id: 'assigned-secret',
|
|
61
|
-
re: /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["'
|
|
62
|
-
|
|
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],
|
|
63
90
|
},
|
|
64
91
|
];
|
|
65
92
|
|
|
66
93
|
/** Longest base64ish run worth measuring for entropy. */
|
|
67
94
|
const ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{32,}/g;
|
|
68
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
|
+
|
|
69
103
|
/**
|
|
70
104
|
* Shannon entropy of a string, in bits per character.
|
|
71
105
|
*
|
|
@@ -92,27 +126,37 @@ function redact(value) {
|
|
|
92
126
|
}
|
|
93
127
|
|
|
94
128
|
/**
|
|
95
|
-
*
|
|
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).
|
|
96
133
|
*
|
|
97
|
-
* @param {string} text
|
|
98
|
-
* @returns {{kind: string, preview: string, entropy?: number}[]}
|
|
99
|
-
* pattern order, then entropy over whatever no pattern claimed
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {{start: number, end: number, kind: string, preview: string, entropy?: number}[]}
|
|
100
136
|
*/
|
|
101
|
-
|
|
137
|
+
function findSpans(text) {
|
|
102
138
|
const found = [];
|
|
103
139
|
const claimed = [];
|
|
104
140
|
|
|
105
141
|
for (const pattern of PATTERNS) {
|
|
106
142
|
pattern.re.lastIndex = 0;
|
|
107
143
|
for (const match of text.matchAll(pattern.re)) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
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);
|
|
111
155
|
// Overlap, not equality: a token a pattern named is one finding, and the
|
|
112
156
|
// entropy reading of the same bytes would double the count a gate reports.
|
|
113
157
|
if (claimed.some(([a, b]) => at < b && a < at + value.length)) continue;
|
|
114
158
|
claimed.push([at, at + value.length]);
|
|
115
|
-
found.push({ kind: pattern.id, preview: redact(value) });
|
|
159
|
+
found.push({ start: at, end: at + value.length, kind: pattern.id, preview: redact(value) });
|
|
116
160
|
}
|
|
117
161
|
}
|
|
118
162
|
|
|
@@ -120,16 +164,106 @@ export function scanText(text) {
|
|
|
120
164
|
for (const match of text.matchAll(ENTROPY_CANDIDATE)) {
|
|
121
165
|
const value = match[0];
|
|
122
166
|
if (value.length < ENTROPY_MIN_LENGTH) continue;
|
|
123
|
-
if (
|
|
167
|
+
if (SRI_HASH.test(value)) continue;
|
|
168
|
+
if (isPlaceholder(value)) continue;
|
|
124
169
|
if (claimed.some(([a, b]) => match.index < b && a < match.index + value.length)) continue;
|
|
125
170
|
const bits = entropy(value);
|
|
126
171
|
if (bits < ENTROPY_MIN_BITS) continue;
|
|
127
|
-
found.push({
|
|
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
|
+
});
|
|
128
179
|
}
|
|
129
180
|
|
|
130
181
|
return found;
|
|
131
182
|
}
|
|
132
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
|
+
|
|
133
267
|
/**
|
|
134
268
|
* Scan the staged diff: the added lines of what the next commit would record.
|
|
135
269
|
*
|
|
@@ -141,12 +275,18 @@ export function scanText(text) {
|
|
|
141
275
|
* one a line is.
|
|
142
276
|
*
|
|
143
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)
|
|
144
280
|
* @returns {Promise<{findings: {file: string, line: number, kind: string,
|
|
145
281
|
* preview: string, entropy?: number}[], lines: number, error: string|null}>}
|
|
146
282
|
* `error` is set only when git itself could not answer; callers fail closed on
|
|
147
283
|
* it rather than reading silence as a clean diff.
|
|
148
284
|
*/
|
|
149
|
-
export async function scanStaged(root) {
|
|
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
|
+
|
|
150
290
|
let stdout;
|
|
151
291
|
try {
|
|
152
292
|
// Every flag here defeats a way a repository can keep the scan from seeing its
|
|
@@ -183,6 +323,7 @@ export async function scanStaged(root) {
|
|
|
183
323
|
let file = null;
|
|
184
324
|
let lineNo = 0;
|
|
185
325
|
let inHunk = false;
|
|
326
|
+
let fileSkipped = false;
|
|
186
327
|
|
|
187
328
|
for (const raw of stdout.split('\n')) {
|
|
188
329
|
if (raw.startsWith('diff --git ')) {
|
|
@@ -203,12 +344,14 @@ export async function scanStaged(root) {
|
|
|
203
344
|
if (raw.startsWith('+++ ')) {
|
|
204
345
|
const path = raw.slice(4).trim();
|
|
205
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)));
|
|
206
349
|
}
|
|
207
350
|
continue;
|
|
208
351
|
}
|
|
209
352
|
if (!raw.startsWith('+')) continue; // -U0 has no context; this is `\ No newline…`
|
|
210
353
|
lines += 1;
|
|
211
|
-
if (file !== null) {
|
|
354
|
+
if (file !== null && !fileSkipped && !raw.includes(PRAGMA)) {
|
|
212
355
|
for (const hit of scanText(raw.slice(1))) {
|
|
213
356
|
findings.push({ file, line: lineNo, ...hit });
|
|
214
357
|
}
|
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
CHANGED
|
@@ -31,11 +31,12 @@ import { join } from 'node:path';
|
|
|
31
31
|
import { promisify } from 'node:util';
|
|
32
32
|
|
|
33
33
|
import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
|
|
34
|
-
import {
|
|
34
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
35
35
|
|
|
36
36
|
const exec = promisify(execFile);
|
|
37
37
|
|
|
38
|
-
const CACHE_FILE = join(
|
|
38
|
+
const CACHE_FILE = join(LOCAL_DIR, 'check.json');
|
|
39
|
+
const CACHE_FILE_LEGACY = '.simt/check.json';
|
|
39
40
|
const CACHE_VERSION = 1;
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -79,7 +80,7 @@ export async function buildCacheRecord(root, summary) {
|
|
|
79
80
|
/** Atomically replace the cache. A killed `check` leaves the previous record, not half of one. */
|
|
80
81
|
export async function writeCheckCache(root, record) {
|
|
81
82
|
const path = cachePath(root);
|
|
82
|
-
await mkdir(join(root,
|
|
83
|
+
await mkdir(join(root, LOCAL_DIR), { recursive: true });
|
|
83
84
|
const body = `${JSON.stringify(record)}\n`;
|
|
84
85
|
const tmp = `${path}.tmp-${process.pid}`;
|
|
85
86
|
await writeFile(tmp, body, 'utf8');
|
|
@@ -88,9 +89,13 @@ export async function writeCheckCache(root, record) {
|
|
|
88
89
|
|
|
89
90
|
/** The last record `check` wrote, or null when it never ran (or a writer invalidated it). */
|
|
90
91
|
export async function readCheckCache(root) {
|
|
92
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
91
93
|
let raw;
|
|
92
94
|
try {
|
|
93
|
-
raw = await readFile(cachePath(root), 'utf8')
|
|
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
|
+
});
|
|
94
99
|
} catch (error) {
|
|
95
100
|
if (error.code === 'ENOENT') return null;
|
|
96
101
|
throw error;
|
package/src/toolwords.mjs
CHANGED
|
@@ -22,6 +22,8 @@ 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';
|
|
26
|
+
import { CORPUS_KIND } from './bench/corpus.mjs';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* `simt-` names that belong to this tool and never to a repository.
|
|
@@ -45,6 +47,12 @@ export const TOOL_WORDS = new Set([
|
|
|
45
47
|
'simt-dev',
|
|
46
48
|
'simt-dev-check',
|
|
47
49
|
'simt-hooks',
|
|
50
|
+
// The one agent this tool ships, named by the stem every harness's agents file shares.
|
|
51
|
+
AGENT_STEM,
|
|
52
|
+
// The companion skill the agent reads on demand; its stem is the directory name.
|
|
53
|
+
SKILL_STEM,
|
|
54
|
+
// The kind stamp this tool writes into every sealed bench corpus document it exports.
|
|
55
|
+
CORPUS_KIND,
|
|
48
56
|
]);
|
|
49
57
|
|
|
50
58
|
/**
|