simt-dev 0.6.3 → 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 +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- 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-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 +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- 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 +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- 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 +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- 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 +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- 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 +296 -77
- 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 +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +69 -19
- 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/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 +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// One renderer per harness: the payload in, the decision out, and the config written.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Six harnesses read a hook and no two agree on anything. The config path, the event
|
|
4
4
|
// name, the shape of the document, the tool name for a shell command, the key holding
|
|
5
5
|
// the command, and the JSON a hook answers with are all different, and none of them is
|
|
6
6
|
// the same fact. So this module owns the differences and `policy.mjs` owns none of
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// cursor .cursor/hooks.json preToolUse {"version":1,"hooks":{E:[h]}}
|
|
12
12
|
// antigravity .agents/hooks.json PreToolUse {"<name>":{E:[{matcher,hooks:[h]}]}}
|
|
13
13
|
// gemini .gemini/settings.json BeforeTool {"hooks":{E:[{matcher,hooks:[h]}]}}
|
|
14
|
+
// opencode .opencode/plugins/ execute.before a JS plugin the tool owns whole
|
|
14
15
|
//
|
|
15
16
|
// The prompt-submit event (R29) is a second table, and it is shorter because the vendors
|
|
16
|
-
// are:
|
|
17
|
+
// are: four of the six document a prompt event that can put text into the model's context,
|
|
17
18
|
// and a delivery is the only thing this tool does there. None takes a matcher — all
|
|
18
19
|
// document that field for tool events, and a matcher a harness ignores is a matcher the
|
|
19
20
|
// next person believes is filtering something.
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
// claude .claude/settings.json UserPromptSubmit hookSpecificOutput.additionalContext
|
|
22
23
|
// codex .codex/hooks.json UserPromptSubmit hookSpecificOutput.additionalContext
|
|
23
24
|
// gemini .gemini/settings.json BeforeAgent hookSpecificOutput.additionalContext
|
|
25
|
+
// opencode .opencode/plugins/ prompt event.prompt.text, prepended
|
|
24
26
|
// cursor .cursor/hooks.json beforeSubmitPrompt CANNOT INJECT: block-only
|
|
25
27
|
// antigravity .agents/hooks.json (no prompt event documented)
|
|
26
28
|
//
|
|
@@ -46,7 +48,9 @@
|
|
|
46
48
|
|
|
47
49
|
import { CLI } from '../name.mjs';
|
|
48
50
|
import { LOADERS } from '../projection/loaders.mjs';
|
|
51
|
+
import { renderOpencodePlugin } from './opencode.mjs';
|
|
49
52
|
import { EVENTS } from './policy.mjs';
|
|
53
|
+
import { AGENT_HOOK_SHIM } from './shim.mjs';
|
|
50
54
|
|
|
51
55
|
/** The seconds a hook is allowed before the harness gives up on it. */
|
|
52
56
|
const TIMEOUT_SECONDS = 10;
|
|
@@ -54,13 +58,15 @@ const TIMEOUT_SECONDS = 10;
|
|
|
54
58
|
/**
|
|
55
59
|
* The hook's own entry point, as it appears inside every harness's config.
|
|
56
60
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
61
|
+
* The shim, never the bare binary: a harness does not share the shell's PATH,
|
|
62
|
+
* so a config naming `simt` runs nothing on Claude and blocks everything on
|
|
63
|
+
* Cursor (review 0003 Q21). `--harness` stays explicit rather than detected
|
|
64
|
+
* from the payload: a hook that guessed would answer in the wrong harness's
|
|
65
|
+
* dialect when a payload arrived in an unexpected shape, and the harness would
|
|
60
66
|
* read a decision it does not understand as no decision at all.
|
|
61
67
|
*/
|
|
62
68
|
export function hookCommand(id, event = EVENTS[0]) {
|
|
63
|
-
return `${
|
|
69
|
+
return `${AGENT_HOOK_SHIM} ${event} --harness ${id}`;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** The path a config file lives at, from the reader table rather than a second list. */
|
|
@@ -79,6 +85,104 @@ export function hookRows() {
|
|
|
79
85
|
}));
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The files an apply_patch body writes, one per header line.
|
|
90
|
+
*
|
|
91
|
+
* `Update`/`Add`/`Delete` name their file on the header itself. A move is the
|
|
92
|
+
* one header whose target is not on its own line: `*** Move File: <old>` is
|
|
93
|
+
* followed by the destination on the `+++` line, and a body that spells the
|
|
94
|
+
* destination as `*** Move to: <new>` says it outright. A move with neither
|
|
95
|
+
* spelling still writes its source out of the way, so the source is the floor
|
|
96
|
+
* a move guards.
|
|
97
|
+
*/
|
|
98
|
+
function asString(value) {
|
|
99
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function editFields(input = {}) {
|
|
103
|
+
const edit = {};
|
|
104
|
+
const oldString = asString(input.old_string ?? input.oldString ?? input.OldText ?? input.old_text);
|
|
105
|
+
const newString = asString(input.new_string ?? input.newString ?? input.NewText ?? input.new_text);
|
|
106
|
+
const content = asString(input.content ?? input.CodeContent ?? input.file_content ?? input.new_source ?? input.newSource);
|
|
107
|
+
if (oldString !== null) edit.old_string = oldString;
|
|
108
|
+
if (newString !== null) edit.new_string = newString;
|
|
109
|
+
if (content !== null) edit.content = content;
|
|
110
|
+
if (Array.isArray(input.edits)) {
|
|
111
|
+
const edits = input.edits
|
|
112
|
+
.filter((e) => e && typeof e === 'object')
|
|
113
|
+
.map((e) => ({
|
|
114
|
+
old_string: asString(e.old_string ?? e.oldString),
|
|
115
|
+
new_string: asString(e.new_string ?? e.newString),
|
|
116
|
+
}))
|
|
117
|
+
.filter((e) => e.old_string !== null || e.new_string !== null);
|
|
118
|
+
if (edits.length > 0) edit.edits = edits;
|
|
119
|
+
}
|
|
120
|
+
return edit;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeOf(path, input) {
|
|
124
|
+
if (!path) return null;
|
|
125
|
+
return { path, ...editFields(input ?? {}) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The files an apply_patch body writes, one per header line.
|
|
130
|
+
*
|
|
131
|
+
* `Update`/`Add`/`Delete` name their file on the header itself. A move is the
|
|
132
|
+
* one header whose target is not on its own line: `*** Move File: <old>` is
|
|
133
|
+
* followed by the destination on the `+++` line, and a body that spells the
|
|
134
|
+
* destination as `*** Move to: <new>` says it outright. A move with neither
|
|
135
|
+
* spelling still writes its source out of the way, so the source is the floor
|
|
136
|
+
* a move guards.
|
|
137
|
+
*
|
|
138
|
+
* Each entry keeps its hunk text: without it the generated-region check cannot
|
|
139
|
+
* tell an edit outside the region from one inside it (review 0003 Q17), and
|
|
140
|
+
* every patch would deny as though it touched the region.
|
|
141
|
+
*/
|
|
142
|
+
function patchWrites(text) {
|
|
143
|
+
return patchFiles(text).map((f) => ({ path: f.path, patch: f.hunks }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function patchFiles(text) {
|
|
147
|
+
const lines = String(text ?? '').split('\n');
|
|
148
|
+
const files = [];
|
|
149
|
+
let current = null;
|
|
150
|
+
const push = () => {
|
|
151
|
+
if (current !== null) files.push(current);
|
|
152
|
+
current = null;
|
|
153
|
+
};
|
|
154
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
155
|
+
const line = lines[i].trim();
|
|
156
|
+
const direct = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/.exec(line);
|
|
157
|
+
if (direct !== null) {
|
|
158
|
+
push();
|
|
159
|
+
current = { path: direct[1].trim(), hunks: '' };
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const moveTo = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
163
|
+
if (moveTo !== null) {
|
|
164
|
+
push();
|
|
165
|
+
current = { path: moveTo[1].trim(), hunks: '' };
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const moveFile = /^\*\*\* Move File: (.+)$/.exec(line);
|
|
169
|
+
if (moveFile !== null) {
|
|
170
|
+
push();
|
|
171
|
+
const next = lines[i + 1]?.trim();
|
|
172
|
+
const dest = next !== undefined && next.startsWith('+++ ') ? next.slice(4).trim() : moveFile[1].trim();
|
|
173
|
+
current = { path: dest, hunks: '' };
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (/^\*\*\* (Begin Patch|End Patch)/.test(line)) {
|
|
177
|
+
if (line.includes('End Patch')) push();
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (current !== null) current.hunks += `${lines[i]}\n`;
|
|
181
|
+
}
|
|
182
|
+
push();
|
|
183
|
+
return files.filter((f) => f.path !== '');
|
|
184
|
+
}
|
|
185
|
+
|
|
82
186
|
const ADAPTERS = {
|
|
83
187
|
claude: {
|
|
84
188
|
id: 'claude',
|
|
@@ -89,9 +193,15 @@ const ADAPTERS = {
|
|
|
89
193
|
root: (p) => p.cwd,
|
|
90
194
|
extract: (p) => ({
|
|
91
195
|
command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
196
|
+
// NotebookEdit spells its target `notebook_path`, not `file_path`: a key list
|
|
197
|
+
// that misses it is a lease the notebook can walk straight through (review
|
|
198
|
+
// 0003 Q16). Edit details travel with the path so the generated-region
|
|
199
|
+
// check can tell an edit outside the region from one inside it (Q17).
|
|
200
|
+
writes: ['Edit', 'Write', 'MultiEdit'].includes(p.tool_name)
|
|
201
|
+
? [writeOf(p.tool_input?.file_path, p.tool_input)].filter(Boolean)
|
|
202
|
+
: p.tool_name === 'NotebookEdit'
|
|
203
|
+
? [writeOf(p.tool_input?.notebook_path, p.tool_input)].filter(Boolean)
|
|
204
|
+
: [],
|
|
95
205
|
}),
|
|
96
206
|
deny: (message) => ({
|
|
97
207
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
|
|
@@ -124,11 +234,13 @@ const ADAPTERS = {
|
|
|
124
234
|
root: (p) => p.cwd,
|
|
125
235
|
extract: (p) => ({
|
|
126
236
|
command: p.tool_name === 'Bash' ? (p.tool_input?.command ?? null) : null,
|
|
127
|
-
// apply_patch carries its targets inside the patch text, one header line per file
|
|
237
|
+
// apply_patch carries its targets inside the patch text, one header line per file;
|
|
238
|
+
// a move names its destination on the `+++` line after `*** Move File:`, and the
|
|
239
|
+
// destination is the file the move writes (review 0003 Q16).
|
|
128
240
|
writes:
|
|
129
241
|
p.tool_name === 'apply_patch'
|
|
130
|
-
?
|
|
131
|
-
: [],
|
|
242
|
+
? patchWrites(String(p.tool_input?.command ?? ''))
|
|
243
|
+
: [], // entries carry {path, patch} so the region check maps hunks (Q17)
|
|
132
244
|
}),
|
|
133
245
|
deny: (message) => ({
|
|
134
246
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: message },
|
|
@@ -155,7 +267,7 @@ const ADAPTERS = {
|
|
|
155
267
|
extract: (p) => ({
|
|
156
268
|
command: p.tool_name === 'Shell' ? (p.tool_input?.command ?? null) : null,
|
|
157
269
|
writes: ['Write', 'Edit'].includes(p.tool_name)
|
|
158
|
-
? [p.tool_input?.file_path ?? p.tool_input?.path].filter(Boolean)
|
|
270
|
+
? [writeOf(p.tool_input?.file_path ?? p.tool_input?.path, p.tool_input)].filter(Boolean)
|
|
159
271
|
: [],
|
|
160
272
|
}),
|
|
161
273
|
deny: (message) => ({ permission: 'deny', user_message: message, agent_message: message }),
|
|
@@ -181,8 +293,8 @@ const ADAPTERS = {
|
|
|
181
293
|
return {
|
|
182
294
|
command: call.name === 'run_command' ? (args.CommandLine ?? null) : null,
|
|
183
295
|
writes: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(call.name)
|
|
184
|
-
? [args.TargetFile].filter(Boolean)
|
|
185
|
-
: [],
|
|
296
|
+
? [writeOf(args.TargetFile, args)].filter(Boolean)
|
|
297
|
+
: [], // CodeContent/old/new travel with the path for the region check (Q17)
|
|
186
298
|
};
|
|
187
299
|
},
|
|
188
300
|
deny: (message) => ({ decision: 'deny', reason: message }),
|
|
@@ -207,7 +319,9 @@ const ADAPTERS = {
|
|
|
207
319
|
root: (p) => p.cwd,
|
|
208
320
|
extract: (p) => ({
|
|
209
321
|
command: p.tool_name === 'run_shell_command' ? (p.tool_input?.command ?? null) : null,
|
|
210
|
-
writes: ['write_file', 'replace'].includes(p.tool_name)
|
|
322
|
+
writes: ['write_file', 'replace'].includes(p.tool_name)
|
|
323
|
+
? [writeOf(p.tool_input?.file_path, p.tool_input)].filter(Boolean)
|
|
324
|
+
: [],
|
|
211
325
|
}),
|
|
212
326
|
deny: (message) => ({ decision: 'deny', reason: message }),
|
|
213
327
|
// BeforeAgent appends text to the prompt for this turn only — the same lifetime as a
|
|
@@ -225,9 +339,34 @@ const ADAPTERS = {
|
|
|
225
339
|
context: (text) => ({ hookSpecificOutput: { additionalContext: text } }),
|
|
226
340
|
},
|
|
227
341
|
},
|
|
342
|
+
opencode: {
|
|
343
|
+
id: 'opencode',
|
|
344
|
+
event: 'execute.before',
|
|
345
|
+
matcher: 'shell|write|edit',
|
|
346
|
+
// The plugin is a file the tool owns whole, not a config entry to merge:
|
|
347
|
+
// `file` routes `rendered` and `hookDocument` to its source (opencode.mjs).
|
|
348
|
+
file: true,
|
|
349
|
+
// A thrown error is the block; exit 2 is how the entry point signals it,
|
|
350
|
+
// and the reason the plugin throws is what `deny` prints.
|
|
351
|
+
denyExit: 2,
|
|
352
|
+
verified: 'opencode.ai/v2/docs/build/plugins/server, read 2026-10-29',
|
|
353
|
+
root: (p) => p.cwd,
|
|
354
|
+
extract: (p) => ({
|
|
355
|
+
command: p.tool === 'shell' ? (p.input?.command ?? null) : null,
|
|
356
|
+
writes: ['write', 'edit'].includes(p.tool) ? [writeOf(p.input?.path, p.input)].filter(Boolean) : [],
|
|
357
|
+
}),
|
|
358
|
+
deny: (message) => ({ reason: message }),
|
|
359
|
+
// session.hook('prompt') admits the prompt; the packet goes in front of it.
|
|
360
|
+
prompt: {
|
|
361
|
+
event: 'prompt',
|
|
362
|
+
verified: 'opencode.ai/v2/docs/build/plugins/server, read 2026-10-29',
|
|
363
|
+
context: (text) => ({ text }),
|
|
364
|
+
},
|
|
365
|
+
post: null,
|
|
366
|
+
},
|
|
228
367
|
};
|
|
229
368
|
|
|
230
|
-
/** The
|
|
369
|
+
/** The six adapters, in the order the reader table lists them. */
|
|
231
370
|
export function adapters() {
|
|
232
371
|
return hookRows()
|
|
233
372
|
.filter((r) => r.path !== null)
|
|
@@ -240,6 +379,17 @@ export function adapter(id) {
|
|
|
240
379
|
return row && hookPath(id) !== null ? row : null;
|
|
241
380
|
}
|
|
242
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The session a payload claims, when it names one. Harness hook processes rarely
|
|
384
|
+
* carry `SIMT_SESSION`, so an agent is session `''` and is blocked by its own
|
|
385
|
+
* lease — the payload's own session id is the fallback (review 0003 Q20).
|
|
386
|
+
*/
|
|
387
|
+
export function payloadSession(payload) {
|
|
388
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
389
|
+
const direct = payload.session_id ?? payload.sessionId ?? payload.session?.id ?? payload.tool_input?.session_id ?? null;
|
|
390
|
+
return typeof direct === 'string' && direct.trim() !== '' ? direct : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
243
393
|
/**
|
|
244
394
|
* The facts a payload carries, and the root it names. An unknown shape yields none.
|
|
245
395
|
*
|
|
@@ -252,7 +402,11 @@ export function adapter(id) {
|
|
|
252
402
|
*/
|
|
253
403
|
export function factsFrom(row, payload, { root } = {}) {
|
|
254
404
|
const override = typeof root === 'string' && root.trim() !== '' ? root : null;
|
|
255
|
-
return {
|
|
405
|
+
return {
|
|
406
|
+
root: override ?? row.root(payload) ?? process.cwd(),
|
|
407
|
+
session: payloadSession(payload),
|
|
408
|
+
...row.extract(payload ?? {}),
|
|
409
|
+
};
|
|
256
410
|
}
|
|
257
411
|
|
|
258
412
|
/** The document to print for a decision, or null when there is nothing to say. */
|
|
@@ -352,6 +506,9 @@ function group(row, event = EVENTS[0], command = hookCommand(row.id, event)) {
|
|
|
352
506
|
// failClosed is set because a hooks file this tool wrote should not silently
|
|
353
507
|
// degrade to fail-open on its own crash: the vendor's default turns a broken hook
|
|
354
508
|
// into an absent one, and the whole point of writing it is that it runs.
|
|
509
|
+
// It is set only because the command is the repo shim (review 0003 Q21):
|
|
510
|
+
// failClosed on a bare-PATH binary would block every tool call on the machines
|
|
511
|
+
// where the binary is not installed, which is exactly the machines the shim exists for.
|
|
355
512
|
return { command, timeout, matcher: row.matcher, failClosed: true };
|
|
356
513
|
}
|
|
357
514
|
return { matcher: row.matcher, hooks: [{ type: 'command', command, timeout }] };
|
|
@@ -359,7 +516,10 @@ function group(row, event = EVENTS[0], command = hookCommand(row.id, event)) {
|
|
|
359
516
|
|
|
360
517
|
/** Whether a group is this tool's, judged by the command it runs and not by position. */
|
|
361
518
|
function ours(entry) {
|
|
362
|
-
|
|
519
|
+
const text = JSON.stringify(entry);
|
|
520
|
+
// The shim spelling first; the bare-binary spelling is the pre-shim install,
|
|
521
|
+
// still recognised (and migrated on the next install) for one release.
|
|
522
|
+
return text.includes(AGENT_HOOK_SHIM) || text.includes(`${CLI} agent-hook`);
|
|
363
523
|
}
|
|
364
524
|
|
|
365
525
|
function entries(row, config, event = EVENTS[0]) {
|
|
@@ -399,6 +559,12 @@ function withHook(row, config, { prompt = false, post = false, command } = {}) {
|
|
|
399
559
|
|
|
400
560
|
/** The text `install` would write, or the reason the existing file cannot be read. */
|
|
401
561
|
export function rendered(row, existing, options = {}) {
|
|
562
|
+
if (row.file) {
|
|
563
|
+
// The tool owns this file whole, so there is nothing to merge: the text is
|
|
564
|
+
// the plugin source and `updated` means only that the bytes differ.
|
|
565
|
+
const text = renderOpencodePlugin({ command: options.command ?? hookCommand(row.id), prompt: options.prompt });
|
|
566
|
+
return { text, action: existing === null ? 'created' : text === existing ? 'unchanged' : 'updated' };
|
|
567
|
+
}
|
|
402
568
|
if (existing === null) return { text: `${JSON.stringify(withHook(row, {}, options), null, 2)}\n`, action: 'created' };
|
|
403
569
|
let config;
|
|
404
570
|
try {
|
|
@@ -422,6 +588,7 @@ export function rendered(row, existing, options = {}) {
|
|
|
422
588
|
export function hookDocument(id, { command, prompt = false, post = false } = {}) {
|
|
423
589
|
const row = adapter(id);
|
|
424
590
|
if (!row) return null;
|
|
591
|
+
if (row.file) return renderOpencodePlugin({ command: command ?? hookCommand(id), prompt });
|
|
425
592
|
const config = withHook(row, {}, { prompt, post, command });
|
|
426
593
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
427
594
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The agent-hook shim: how a harness runs this tool without it on PATH.
|
|
2
|
+
//
|
|
3
|
+
// A devDependency install puts `simt` in `node_modules/.bin`, not on a
|
|
4
|
+
// harness's PATH. A harness config naming the bare `simt` binary therefore
|
|
5
|
+
// runs nothing on Claude (a non-2 exit is silent: no enforcement) and blocks
|
|
6
|
+
// every tool call on Cursor (its hook fails closed). The fix is a shim beside
|
|
7
|
+
// the git shims, resolved the same way: `.simt-hooks/agent-hook` locates the
|
|
8
|
+
// dispatcher with the shared `renderLocator` block and hands over, so every
|
|
9
|
+
// harness config names the shim and never the bare binary (review 0003 Q21).
|
|
10
|
+
//
|
|
11
|
+
// The shim passes its own arguments through (`agent-hook "$@"`), so one file
|
|
12
|
+
// serves every event and every harness: the config entry decides the event and
|
|
13
|
+
// the `--harness` decides the dialect.
|
|
14
|
+
|
|
15
|
+
import { renderLocator } from '../locate.mjs';
|
|
16
|
+
import { BIN_ENV, CLI, PACKAGE_NAME } from '../name.mjs';
|
|
17
|
+
import { HOOKS_DIR } from '../hooks/shim.mjs';
|
|
18
|
+
import { localInstall } from '../distribute/release.mjs';
|
|
19
|
+
import { packageVersion } from '../package-version.mjs';
|
|
20
|
+
|
|
21
|
+
/** The shim inside the repository, as a harness config names it. */
|
|
22
|
+
export const AGENT_HOOK_SHIM = `${HOOKS_DIR}/agent-hook`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{npm?: boolean, version?: string}} [opts]
|
|
26
|
+
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
27
|
+
*/
|
|
28
|
+
export function renderAgentHookShim({ npm = true, version } = {}) {
|
|
29
|
+
const v = version ?? packageVersion();
|
|
30
|
+
const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
|
|
31
|
+
const D = '$';
|
|
32
|
+
return `#!/bin/sh
|
|
33
|
+
# Generated by simt-dev. Do not edit.
|
|
34
|
+
#
|
|
35
|
+
# The harness entry point: every harness config runs this file, never the bare
|
|
36
|
+
# \`${CLI}\` binary, because a harness does not share your shell's PATH. It only
|
|
37
|
+
# locates the dispatcher and hands over. Reinstall with:
|
|
38
|
+
# ${CLI} agent-hooks install --yes
|
|
39
|
+
#
|
|
40
|
+
# Resolution order is the git shims' order (src/locate.mjs):
|
|
41
|
+
# 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
|
|
42
|
+
# 2. node_modules/ the pinned project dependency
|
|
43
|
+
# 3. .simt/bin/ the release binary this repository downloaded
|
|
44
|
+
# 4. ${CLI} on PATH a global install
|
|
45
|
+
# If none resolves it REFUSES rather than exiting 0.
|
|
46
|
+
|
|
47
|
+
set -eu
|
|
48
|
+
|
|
49
|
+
ROOT=\"${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)\"
|
|
50
|
+
|
|
51
|
+
${renderLocator(`agent-hook \"${D}@\" --root \"${D}ROOT\"`, {
|
|
52
|
+
subject: 'the agent hook',
|
|
53
|
+
remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or remove the hook entry to opt out`,
|
|
54
|
+
})}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// One renderer per harness for the agent file, and the plan the install writes.
|
|
2
|
+
//
|
|
3
|
+
// Six harnesses read a subagent and no two agree on the file. Five write a
|
|
4
|
+
// markdown file with YAML frontmatter and a body that becomes the system prompt;
|
|
5
|
+
// Codex writes a TOML file whose `developer_instructions` holds the same body.
|
|
6
|
+
// The path, the extension, the frontmatter keys and the document shape are all
|
|
7
|
+
// different, and none of them is the same fact. So this module owns the
|
|
8
|
+
// differences and the command owns none of them:
|
|
9
|
+
//
|
|
10
|
+
// opencode .opencode/agents/simt-agent.md markdown description
|
|
11
|
+
// claude .claude/agents/simt-agent.md markdown name, description
|
|
12
|
+
// cursor .cursor/agents/simt-agent.md markdown name, description
|
|
13
|
+
// gemini .gemini/agents/simt-agent.md markdown name, description
|
|
14
|
+
// codex .codex/agents/simt-agent.toml TOML name, description, developer_instructions
|
|
15
|
+
// antigravity .agents/agents/simt-agent.md markdown name, description
|
|
16
|
+
//
|
|
17
|
+
// The source of truth is ONE file in this repository, `agents/simt-agent.md`:
|
|
18
|
+
// neutral frontmatter plus a harness-agnostic body. The renderer strips that
|
|
19
|
+
// frontmatter and re-emits the keys each harness documents, so the body — the
|
|
20
|
+
// part that says how to act — is written once and cannot drift between
|
|
21
|
+
// harnesses. An agent is a file the tool owns whole, not a config entry to
|
|
22
|
+
// merge, so `rendered` compares bytes and reports created, unchanged or updated.
|
|
23
|
+
|
|
24
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { PACKAGE_ROOT } from '../workspace.mjs';
|
|
28
|
+
import { splitFrontmatter } from '../memory/decide.mjs';
|
|
29
|
+
import { LOADERS } from '../projection/loaders.mjs';
|
|
30
|
+
import { journaledWriteFile } from '../journal.mjs';
|
|
31
|
+
|
|
32
|
+
/** The one agent this tool ships, and the filename stem every harness shares. */
|
|
33
|
+
const AGENT_FILE = 'simt-agent.md';
|
|
34
|
+
export const AGENT_STEM = 'simt-agent';
|
|
35
|
+
|
|
36
|
+
/** The companion skill: the command reference the agent reads on demand. */
|
|
37
|
+
const SKILL_SOURCE = join('skills', 'simt-commands', 'SKILL.md');
|
|
38
|
+
export const SKILL_STEM = 'simt-commands';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The per-harness differences. `frontmatter` returns the ordered [key, value]
|
|
42
|
+
* pairs a markdown harness documents; Codex has none because its shape is TOML,
|
|
43
|
+
* not frontmatter. The body is identical everywhere and is not a difference.
|
|
44
|
+
*/
|
|
45
|
+
const ADAPTERS = {
|
|
46
|
+
opencode: {
|
|
47
|
+
id: 'opencode',
|
|
48
|
+
format: 'md',
|
|
49
|
+
frontmatter: (agent) => [
|
|
50
|
+
['description', agent.description],
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
claude: {
|
|
54
|
+
id: 'claude',
|
|
55
|
+
format: 'md',
|
|
56
|
+
frontmatter: (agent) => [
|
|
57
|
+
['name', agent.name],
|
|
58
|
+
['description', agent.description],
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
cursor: {
|
|
62
|
+
id: 'cursor',
|
|
63
|
+
format: 'md',
|
|
64
|
+
frontmatter: (agent) => [
|
|
65
|
+
['name', agent.name],
|
|
66
|
+
['description', agent.description],
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
gemini: {
|
|
70
|
+
id: 'gemini',
|
|
71
|
+
format: 'md',
|
|
72
|
+
frontmatter: (agent) => [
|
|
73
|
+
['name', agent.name],
|
|
74
|
+
['description', agent.description],
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
codex: {
|
|
78
|
+
id: 'codex',
|
|
79
|
+
format: 'toml',
|
|
80
|
+
},
|
|
81
|
+
antigravity: {
|
|
82
|
+
id: 'antigravity',
|
|
83
|
+
format: 'md',
|
|
84
|
+
frontmatter: (agent) => [
|
|
85
|
+
['name', agent.name],
|
|
86
|
+
['description', agent.description],
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The reader-table row for a harness's agents directory, or null when none is documented. */
|
|
92
|
+
function agentInfo(id) {
|
|
93
|
+
const row = LOADERS.find((l) => l.id === id);
|
|
94
|
+
return row?.agents ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The path a harness reads the agent from, sourced from the reader table rather
|
|
99
|
+
* than spelled here. Null for a harness that documents no agents directory.
|
|
100
|
+
*/
|
|
101
|
+
export function agentPath(id) {
|
|
102
|
+
const info = agentInfo(id);
|
|
103
|
+
return info ? join(info.dir, `${AGENT_STEM}${info.ext}`) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every reader, with the agents path it documents and the note for the ones that
|
|
108
|
+
* do not. The command reports these so a harness this tool does not write is
|
|
109
|
+
* named rather than silent.
|
|
110
|
+
*/
|
|
111
|
+
export function agentRows() {
|
|
112
|
+
return LOADERS.map((l) => ({
|
|
113
|
+
id: l.id,
|
|
114
|
+
label: l.label,
|
|
115
|
+
path: l.agents ? join(l.agents.dir, `${AGENT_STEM}${l.agents.ext}`) : null,
|
|
116
|
+
note: l.agents ? l.agents.verified : (l.agentsNote ?? 'no agents file is documented'),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The adapters that both have a reader-table path and a renderer, in reader order. */
|
|
121
|
+
export function adapters() {
|
|
122
|
+
return agentRows()
|
|
123
|
+
.filter((r) => r.path !== null)
|
|
124
|
+
.map((r) => ADAPTERS[r.id])
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One adapter by id, or null when the harness has no path or no renderer. */
|
|
129
|
+
export function adapter(id) {
|
|
130
|
+
return ADAPTERS[id] && agentInfo(id) !== null ? ADAPTERS[id] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse the neutral frontmatter of the source file: `key: value` lines. */
|
|
134
|
+
function parseSimpleFront(front) {
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const line of front.split('\n')) {
|
|
137
|
+
const idx = line.indexOf(':');
|
|
138
|
+
if (idx < 0) continue;
|
|
139
|
+
const key = line.slice(0, idx).trim();
|
|
140
|
+
const val = line.slice(idx + 1).trim();
|
|
141
|
+
if (key) out[key] = val;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The one agent, read from this repository's `agents/` directory. The body is
|
|
148
|
+
* the system prompt; `name` and `description` are re-emitted per harness.
|
|
149
|
+
*/
|
|
150
|
+
export async function loadAgent() {
|
|
151
|
+
const raw = await readFile(join(PACKAGE_ROOT, 'agents', AGENT_FILE), 'utf8');
|
|
152
|
+
const { front, body } = splitFrontmatter(raw);
|
|
153
|
+
const meta = parseSimpleFront(front ?? '');
|
|
154
|
+
if (!meta.name || !meta.description) {
|
|
155
|
+
throw new Error(`${AGENT_FILE} must declare a name and a description in its frontmatter`);
|
|
156
|
+
}
|
|
157
|
+
return { name: meta.name, description: meta.description, body: body.trim() };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The companion skill file, read verbatim from this repository. */
|
|
161
|
+
async function loadSkill() {
|
|
162
|
+
return readFile(join(PACKAGE_ROOT, SKILL_SOURCE), 'utf8');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Where the skill lands in the receiving repo. The same heuristic `skills
|
|
167
|
+
* install` uses: respect an existing skills directory, default to `.agents/skills`.
|
|
168
|
+
*/
|
|
169
|
+
async function skillDestPath(root) {
|
|
170
|
+
let entries = [];
|
|
171
|
+
try {
|
|
172
|
+
const { readdir } = await import('node:fs/promises');
|
|
173
|
+
entries = await readdir(root);
|
|
174
|
+
} catch { /* fresh repo, no entries */ }
|
|
175
|
+
let dir;
|
|
176
|
+
if (entries.includes('.agents')) dir = '.agents/skills';
|
|
177
|
+
else if (entries.includes('.cursor')) dir = '.cursor/skills';
|
|
178
|
+
else if (entries.includes('.claude')) dir = '.claude/skills';
|
|
179
|
+
else dir = '.agents/skills';
|
|
180
|
+
return join(dir, SKILL_STEM, 'SKILL.md');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A basic TOML string: double-quoted, with backslashes and quotes escaped. */
|
|
184
|
+
function tomlString(value) {
|
|
185
|
+
return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The markdown form: frontmatter the harness documents, a blank line, the body. */
|
|
189
|
+
function renderMarkdown(adapterRow, agent) {
|
|
190
|
+
const fm = adapterRow.frontmatter(agent).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
191
|
+
return `---\n${fm}\n---\n\n${agent.body}\n`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The Codex form: a TOML file. The body goes in `developer_instructions` as a
|
|
196
|
+
* multi-line basic string; a literal `"""` in the body would end the string, so
|
|
197
|
+
* one of its quotes is escaped.
|
|
198
|
+
*/
|
|
199
|
+
function renderToml(agent) {
|
|
200
|
+
const body = agent.body.replace(/"""/g, '\\"""');
|
|
201
|
+
return `name = ${tomlString(agent.name)}\ndescription = ${tomlString(agent.description)}\ndeveloper_instructions = """\n${body}\n"""\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Render the agent for a harness and compare it to what is on disk. The tool
|
|
206
|
+
* owns the file whole, so the comparison is by bytes and the action is one of
|
|
207
|
+
* created, unchanged, updated.
|
|
208
|
+
*/
|
|
209
|
+
export function rendered(adapterRow, agent, existing) {
|
|
210
|
+
const text = adapterRow.format === 'toml' ? renderToml(agent) : renderMarkdown(adapterRow, agent);
|
|
211
|
+
if (existing === null) return { text, action: 'created' };
|
|
212
|
+
if (existing === text) return { text, action: 'unchanged' };
|
|
213
|
+
return { text, action: 'updated' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Read a file as UTF-8, or null when it is not there. */
|
|
217
|
+
async function readIfExists(path) {
|
|
218
|
+
try {
|
|
219
|
+
return await readFile(path, 'utf8');
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The agent files a repository should carry, one per harness that documents an
|
|
227
|
+
* agents directory, plus the companion skill file. Reads each existing file and
|
|
228
|
+
* renders what install would write, so a dry run and a run describe the same set
|
|
229
|
+
* of files. `harness` narrows the agent files to one; the skill is always included.
|
|
230
|
+
*/
|
|
231
|
+
export async function planAgents(root, { harness } = {}) {
|
|
232
|
+
const agent = await loadAgent();
|
|
233
|
+
const rows = harness === undefined ? adapters() : adapters().filter((a) => a.id === harness);
|
|
234
|
+
const plan = [];
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
const path = agentPath(row.id);
|
|
237
|
+
const existing = await readIfExists(join(root, path));
|
|
238
|
+
const result = rendered(row, agent, existing);
|
|
239
|
+
plan.push({ id: row.id, path, action: result.action, text: result.text });
|
|
240
|
+
}
|
|
241
|
+
// The companion skill: one file, not per-harness.
|
|
242
|
+
const skillText = await loadSkill();
|
|
243
|
+
const skillPath = await skillDestPath(root);
|
|
244
|
+
const skillExisting = await readIfExists(join(root, skillPath));
|
|
245
|
+
const skillAction = skillExisting === null ? 'created' : skillExisting === skillText ? 'unchanged' : 'updated';
|
|
246
|
+
plan.push({ id: 'skill', path: skillPath, action: skillAction, text: skillText });
|
|
247
|
+
return plan;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Write the agent files a plan names, journaled so `undo` restores the run.
|
|
252
|
+
* Skips files that are already unchanged. Returns the paths written.
|
|
253
|
+
*/
|
|
254
|
+
export async function writeAgentPlan(root, journal, plan) {
|
|
255
|
+
const written = [];
|
|
256
|
+
for (const p of plan.filter((x) => x.action !== 'unchanged')) {
|
|
257
|
+
await mkdir(join(root, dirname(p.path)), { recursive: true });
|
|
258
|
+
await journaledWriteFile(root, journal, join(root, p.path), p.text);
|
|
259
|
+
written.push(p.path);
|
|
260
|
+
}
|
|
261
|
+
return written;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Write just the skill file so `installedSkills` can find it before `plan()`
|
|
266
|
+
* computes the region. Called by `init` before the region is rendered.
|
|
267
|
+
*/
|
|
268
|
+
export async function writeSkillFile(root, journal) {
|
|
269
|
+
const text = await loadSkill();
|
|
270
|
+
const path = await skillDestPath(root);
|
|
271
|
+
const full = join(root, path);
|
|
272
|
+
const existing = await readIfExists(full);
|
|
273
|
+
if (existing === text) return null;
|
|
274
|
+
await mkdir(dirname(full), { recursive: true });
|
|
275
|
+
await journaledWriteFile(root, journal, full, text);
|
|
276
|
+
return path;
|
|
277
|
+
}
|
package/src/argv.mjs
CHANGED
|
@@ -119,6 +119,14 @@ export const VALUE_FLAGS = new Set([
|
|
|
119
119
|
'task',
|
|
120
120
|
'workspace',
|
|
121
121
|
'root',
|
|
122
|
+
// skills/graph: which registry to read. A value rather than a boolean because
|
|
123
|
+
// "a registry" without a path would have to guess, and the guess is the
|
|
124
|
+
// defect Q37 exists to remove (review 0003).
|
|
125
|
+
'registry',
|
|
126
|
+
// compare: how many repository rows to build at once (review 0003 Q38).
|
|
127
|
+
// A count, not a switch, because "parallel" without a cap is the burst this
|
|
128
|
+
// flag exists to prevent.
|
|
129
|
+
'concurrency',
|
|
122
130
|
// skills install: which body of a multi-variant skill, and which source when the
|
|
123
131
|
// bytes alone do not identify one.
|
|
124
132
|
'variant',
|