@stdd/plugin 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- package/skills/stdd-worktrees/SKILL.md +46 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import {
|
|
5
|
+
AGENT_ADAPTERS,
|
|
6
|
+
CI_ADAPTERS,
|
|
7
|
+
CROSS_CLI_REVIEW_VIA_TOKEN,
|
|
8
|
+
getAgentAdapter,
|
|
9
|
+
MANDATORY_ROUTING_SKILLS,
|
|
10
|
+
renderAgentInstructions,
|
|
11
|
+
renderAgentSkill,
|
|
12
|
+
renderCiTemplate,
|
|
13
|
+
} from "../sdk/adapters.mjs";
|
|
14
|
+
import { resolveWritableRepoPath } from "../sdk/path.mjs";
|
|
15
|
+
import { hasLocalStddBinary, isStddSourceCheckout, prepareAgentHooks } from "./claude-hooks.mjs";
|
|
16
|
+
import { loadConfig } from "./config.mjs";
|
|
17
|
+
import {
|
|
18
|
+
finalizeGeneratedFilesWithCapabilities,
|
|
19
|
+
KNOWN_CAPABILITIES,
|
|
20
|
+
KNOWN_TOOLS,
|
|
21
|
+
loadLocalPlaybooks,
|
|
22
|
+
loadPlaybooks,
|
|
23
|
+
NATIVE_MANIFEST_IDENTITY,
|
|
24
|
+
NPM_RUNNER,
|
|
25
|
+
PKG_ROOT,
|
|
26
|
+
readManifestDocument,
|
|
27
|
+
readManifestDocumentWithCapabilities,
|
|
28
|
+
recoverCleanupJournalWithCapabilities,
|
|
29
|
+
renderInstalledMethod,
|
|
30
|
+
SOURCE_RUNNER,
|
|
31
|
+
STAMP,
|
|
32
|
+
VERSION,
|
|
33
|
+
validateAdapterSelection,
|
|
34
|
+
} from "./generated-files.mjs";
|
|
35
|
+
import {
|
|
36
|
+
openNativeRepoMutation,
|
|
37
|
+
openOrCreateNativeRepoDirectory,
|
|
38
|
+
preflightNativeRepoDestination,
|
|
39
|
+
publishNativeRepoFile,
|
|
40
|
+
readOptionalNativeRepoFile,
|
|
41
|
+
} from "./held-fs.mjs";
|
|
42
|
+
import {
|
|
43
|
+
LEDGER_REL,
|
|
44
|
+
LEDGER_RESET_TEMP_GIT_GLOB,
|
|
45
|
+
LEGACY_LEDGER_RESET_TEMP_IGNORE,
|
|
46
|
+
PLAN_REL,
|
|
47
|
+
REVIEW_VIAS,
|
|
48
|
+
} from "./ledger.mjs";
|
|
49
|
+
import { compileCapabilities, DEFAULT_CONFIG, mergeConfig, sha256 } from "./lib.mjs";
|
|
50
|
+
import { fail } from "./runtime.mjs";
|
|
51
|
+
import { WORKER_DELETIONS_REL } from "./worker-fs.mjs";
|
|
52
|
+
import { WORKER_METADATA_REL } from "./worker-metadata.mjs";
|
|
53
|
+
|
|
54
|
+
const UNINSPECTED_CONFIG = Symbol("uninspected config");
|
|
55
|
+
const PRE_PUSH_HEADER =
|
|
56
|
+
"#!/bin/sh\n# user-owned after generation — append your own steps freely\n" +
|
|
57
|
+
"# project-local/offline only: the explicit scoped package can never resolve unscoped `stdd`\n";
|
|
58
|
+
|
|
59
|
+
// Seeded once, then owned by the repository. Both headings ship empty: an
|
|
60
|
+
// entry is a decision someone made, never a default the kit assumed.
|
|
61
|
+
const POLICY_SEED =
|
|
62
|
+
"# Project policy\n\n" +
|
|
63
|
+
"Standing decisions for this repository. A note records nuance and grants\n" +
|
|
64
|
+
"nothing. A permission grants one action, and names the condition a session\n" +
|
|
65
|
+
"must verify before acting on it.\n\n" +
|
|
66
|
+
"Record them with `stdd policy add <text>` and\n" +
|
|
67
|
+
'`stdd policy allow <action> --when "<condition>"`.\n\n' +
|
|
68
|
+
"## Permissions\n\n## Notes\n";
|
|
69
|
+
|
|
70
|
+
function prePushHook(runner) {
|
|
71
|
+
return `${PRE_PUSH_HEADER}${runner} check . || exit 1\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isGeneratedPrePushHook(content) {
|
|
75
|
+
const command = content.slice(PRE_PUSH_HEADER.length);
|
|
76
|
+
return (
|
|
77
|
+
content.startsWith(PRE_PUSH_HEADER) &&
|
|
78
|
+
(command === `${SOURCE_RUNNER} check . || exit 1\n` ||
|
|
79
|
+
/^npm exec --offline (?:(?:--package=@stdd\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? )?-- stdd) check \. \|\| exit 1\n$/.test(
|
|
80
|
+
command,
|
|
81
|
+
))
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `--capabilities <list>`: write the full profile into the user-owned
|
|
87
|
+
* config — named capabilities on, every other known one off. Other config
|
|
88
|
+
* keys survive untouched.
|
|
89
|
+
*/
|
|
90
|
+
function preservedPublicationMode(state, fallback, label) {
|
|
91
|
+
if (!state || state.file.observation.identity.platform === "win32") return fallback;
|
|
92
|
+
const mode = Number(state.file.observation.permissions) & 0o777;
|
|
93
|
+
if (![0o600, 0o644, 0o755].includes(mode)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${label} has unsupported mode ${mode.toString(8)}; preserve it manually before retrying`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return mode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readConfigForWrite(context, inspectedState = UNINSPECTED_CONFIG) {
|
|
102
|
+
const state =
|
|
103
|
+
inspectedState !== UNINSPECTED_CONFIG
|
|
104
|
+
? inspectedState
|
|
105
|
+
: await readOptionalNativeRepoFile(context, ".stdd/config.json", {
|
|
106
|
+
label: "config path",
|
|
107
|
+
});
|
|
108
|
+
let parsed = { ...DEFAULT_CONFIG };
|
|
109
|
+
if (state) {
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(state.bytes.toString("utf8"));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
fail(`.stdd/config.json is not valid JSON: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
mergeConfig(parsed);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
fail(`.stdd/config.json: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
return { parsed, state };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function publishConfig(context, parsed, state) {
|
|
125
|
+
const content = Buffer.from(`${JSON.stringify(parsed, null, "\t")}\n`);
|
|
126
|
+
const file = await publishNativeRepoFile(context, ".stdd/config.json", content, {
|
|
127
|
+
mode: preservedPublicationMode(state, 0o644, ".stdd/config.json"),
|
|
128
|
+
tempPrefix: ".config-",
|
|
129
|
+
expectedTarget: state?.file.observation.identity ?? null,
|
|
130
|
+
expectedBytes: state?.bytes ?? null,
|
|
131
|
+
});
|
|
132
|
+
return { file, bytes: content };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function writeCapabilities(context, list, inspectedState) {
|
|
136
|
+
const { parsed, state } = await readConfigForWrite(context, inspectedState);
|
|
137
|
+
parsed.capabilities = Object.fromEntries(KNOWN_CAPABILITIES.map((c) => [c, list.includes(c)]));
|
|
138
|
+
return publishConfig(context, parsed, state);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Set review.via (and optionally the budget) in the user-owned config,
|
|
142
|
+
* preserving every other key. */
|
|
143
|
+
async function writeReviewVia(context, via, maxRounds = null, inspectedState) {
|
|
144
|
+
const { parsed, state } = await readConfigForWrite(context, inspectedState);
|
|
145
|
+
parsed.review = {
|
|
146
|
+
...(parsed.review ?? {}),
|
|
147
|
+
via,
|
|
148
|
+
...(maxRounds !== null ? { maxRounds } : {}),
|
|
149
|
+
};
|
|
150
|
+
return publishConfig(context, parsed, state);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `--interview`: one question at a time, the recommended answer first —
|
|
155
|
+
* an empty answer takes it. Piped answers work; a stream that ends early
|
|
156
|
+
* resolves every remaining question to its default.
|
|
157
|
+
*/
|
|
158
|
+
function makePrompter() {
|
|
159
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
160
|
+
// Own line queue instead of rl.question: piped answers arrive in one
|
|
161
|
+
// burst, and lines emitted while no question is pending would be lost.
|
|
162
|
+
const lines = [];
|
|
163
|
+
const waiters = [];
|
|
164
|
+
let closed = false;
|
|
165
|
+
rl.on("line", (line) => {
|
|
166
|
+
const waiter = waiters.shift();
|
|
167
|
+
if (waiter) waiter(line);
|
|
168
|
+
else lines.push(line);
|
|
169
|
+
});
|
|
170
|
+
rl.on("close", () => {
|
|
171
|
+
closed = true;
|
|
172
|
+
while (waiters.length > 0) waiters.shift()("");
|
|
173
|
+
});
|
|
174
|
+
const readLine = () => {
|
|
175
|
+
if (lines.length > 0) return Promise.resolve(lines.shift());
|
|
176
|
+
if (closed) return Promise.resolve("");
|
|
177
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
178
|
+
};
|
|
179
|
+
const ask = async (question, def) => {
|
|
180
|
+
process.stdout.write(question);
|
|
181
|
+
const answer = (await readLine()).trim();
|
|
182
|
+
return answer === "" ? def : answer;
|
|
183
|
+
};
|
|
184
|
+
const yes = async (question, def) =>
|
|
185
|
+
/^y/i.test(await ask(`${question} ${def ? "[Y/n]" : "[y/N]"} `, def ? "y" : "n"));
|
|
186
|
+
return { ask, yes, close: () => rl.close() };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The reviewer-route question, shared by init --interview and configure. */
|
|
190
|
+
async function askReviewVia(ask, close, def) {
|
|
191
|
+
const via = await ask(`Independent reviewer route (${REVIEW_VIAS.join("/")}) [${def}]: `, def);
|
|
192
|
+
if (!REVIEW_VIAS.includes(via)) {
|
|
193
|
+
close();
|
|
194
|
+
fail(`unknown review route "${via}" (known: ${REVIEW_VIAS.join(", ")})`);
|
|
195
|
+
}
|
|
196
|
+
return via;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recommendedReviewVia(tools, capabilities) {
|
|
200
|
+
if (capabilities.crossCli) {
|
|
201
|
+
return getAgentAdapter(tools[0]).crossCliReviewVia;
|
|
202
|
+
}
|
|
203
|
+
if (capabilities.subagents) return "subagent";
|
|
204
|
+
return DEFAULT_CONFIG.review.via;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function interview() {
|
|
208
|
+
const { ask, yes, close } = makePrompter();
|
|
209
|
+
|
|
210
|
+
console.log("stdd init — one question at a time; an empty answer takes the recommended default\n");
|
|
211
|
+
const toolsAnswer = await ask(
|
|
212
|
+
`Agents to compile for (${KNOWN_TOOLS.join(", ")}) [${KNOWN_TOOLS.join(",")}]: `,
|
|
213
|
+
KNOWN_TOOLS.join(","),
|
|
214
|
+
);
|
|
215
|
+
const tools = toolsAnswer
|
|
216
|
+
.split(",")
|
|
217
|
+
.map((t) => t.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
try {
|
|
220
|
+
validateAdapterSelection("tools", tools, KNOWN_TOOLS, { nonEmpty: true });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
close();
|
|
223
|
+
fail(err.message);
|
|
224
|
+
}
|
|
225
|
+
const capabilitiesList = [];
|
|
226
|
+
if (await yes("Can agents dispatch subagents?", true)) capabilitiesList.push("subagents");
|
|
227
|
+
if (await yes("May selected agent CLIs invoke a second reviewer CLI?", false))
|
|
228
|
+
capabilitiesList.push("crossCli");
|
|
229
|
+
if (await yes("Are isolated git worktrees available?", true)) capabilitiesList.push("worktrees");
|
|
230
|
+
const capabilities = Object.fromEntries(
|
|
231
|
+
KNOWN_CAPABILITIES.map((capability) => [capability, capabilitiesList.includes(capability)]),
|
|
232
|
+
);
|
|
233
|
+
// The first selected native host is the driver for the repository-level
|
|
234
|
+
// default. Generated skills still carry a per-host explicit override.
|
|
235
|
+
const reviewVia = await askReviewVia(ask, close, recommendedReviewVia(tools, capabilities));
|
|
236
|
+
const ci = (await yes("Install the GitHub Actions gate (stdd check + PR evidence)?", true))
|
|
237
|
+
? ["github"]
|
|
238
|
+
: [];
|
|
239
|
+
const hooks = await yes("Install the pre-push hook (stdd check — fast, offline)?", true);
|
|
240
|
+
const sessionHook =
|
|
241
|
+
tools.length > 0 ? await yes("Wire native agent session hooks (stdd status --local)?", true) : false;
|
|
242
|
+
const stopHook =
|
|
243
|
+
tools.length > 0
|
|
244
|
+
? await yes("Wire native agent stop integrations (gate or corrective continuation)?", false)
|
|
245
|
+
: false;
|
|
246
|
+
close();
|
|
247
|
+
const hasDispatch = capabilitiesList.includes("subagents") || capabilitiesList.includes("crossCli");
|
|
248
|
+
if (
|
|
249
|
+
hasDispatch &&
|
|
250
|
+
(reviewVia === "codex" || reviewVia === "claude") &&
|
|
251
|
+
!capabilitiesList.includes("crossCli")
|
|
252
|
+
) {
|
|
253
|
+
fail(
|
|
254
|
+
`review route "${reviewVia}" needs the crossCli capability — answer y to cross-CLI or pick subagent`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (hasDispatch && reviewVia === "subagent" && !capabilitiesList.includes("subagents")) {
|
|
258
|
+
fail(
|
|
259
|
+
`review route "subagent" needs the subagents capability — answer y to subagents or pick another route`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
tools,
|
|
264
|
+
ci,
|
|
265
|
+
hooks,
|
|
266
|
+
sessionHook,
|
|
267
|
+
stopHook,
|
|
268
|
+
capabilitiesList,
|
|
269
|
+
reviewVia,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `stdd configure` — the interview again, over an existing install:
|
|
275
|
+
* current values are the defaults, only the capability profile and the
|
|
276
|
+
* review route are edited, every other config key is preserved, and the
|
|
277
|
+
* same generated targets (remembered in the manifest) are recompiled.
|
|
278
|
+
* Never changes CI target selection or removes hook files. A remembered Stop
|
|
279
|
+
* hook is maintained, and --stop-hook is the explicit opt-in that may add it.
|
|
280
|
+
*/
|
|
281
|
+
export async function configure(targetDir, opts) {
|
|
282
|
+
const configPath = path.join(targetDir, ".stdd", "config.json");
|
|
283
|
+
if (!fs.existsSync(configPath)) {
|
|
284
|
+
fail(
|
|
285
|
+
"no .stdd/config.json here — `stdd configure` edits an existing install; run `stdd init` first",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const config = loadConfig(targetDir);
|
|
289
|
+
let targets = null;
|
|
290
|
+
let manifestFiles = null;
|
|
291
|
+
let manifest = null;
|
|
292
|
+
try {
|
|
293
|
+
manifest = readManifestDocument(targetDir);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
fail(`.stdd/manifest.json ${err.message}`);
|
|
296
|
+
}
|
|
297
|
+
if (manifest && Object.hasOwn(manifest, "targets")) {
|
|
298
|
+
targets = manifest.targets;
|
|
299
|
+
} else if (manifest) {
|
|
300
|
+
manifestFiles = Object.keys(manifest.files);
|
|
301
|
+
}
|
|
302
|
+
// installs made before targets were remembered: infer what the previous
|
|
303
|
+
// init actually GENERATED from manifest.files — live directories lie (a
|
|
304
|
+
// stray empty .claude/skills must not smuggle claude in) and an
|
|
305
|
+
// inferred blank would make the stale-file cleanup delete the CI
|
|
306
|
+
// workflow. The filesystem is the last resort with no usable manifest;
|
|
307
|
+
// hook files and settings entries are user-owned, never
|
|
308
|
+
// manifest-tracked, so they are always read from their files.
|
|
309
|
+
if (!targets) {
|
|
310
|
+
const tools = [];
|
|
311
|
+
const ci = [];
|
|
312
|
+
const skillRootCounts = new Map();
|
|
313
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
314
|
+
skillRootCounts.set(adapter.skillRoot, (skillRootCounts.get(adapter.skillRoot) ?? 0) + 1);
|
|
315
|
+
}
|
|
316
|
+
if (manifestFiles) {
|
|
317
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
318
|
+
const ownsDistinctSkillRoot = skillRootCounts.get(adapter.skillRoot) === 1;
|
|
319
|
+
if (
|
|
320
|
+
manifestFiles.includes(adapter.snippetFile) ||
|
|
321
|
+
(ownsDistinctSkillRoot &&
|
|
322
|
+
manifestFiles.some((file) => file.startsWith(`${adapter.skillRoot}/`)))
|
|
323
|
+
) {
|
|
324
|
+
tools.push(adapter.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const adapter of Object.values(CI_ADAPTERS)) {
|
|
328
|
+
if (adapter.outputFile && manifestFiles.includes(adapter.outputFile)) ci.push(adapter.id);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
332
|
+
const ownsDistinctSkillRoot = skillRootCounts.get(adapter.skillRoot) === 1;
|
|
333
|
+
if (
|
|
334
|
+
fs.existsSync(path.join(targetDir, adapter.snippetFile)) ||
|
|
335
|
+
(ownsDistinctSkillRoot && fs.existsSync(path.join(targetDir, adapter.skillRoot)))
|
|
336
|
+
) {
|
|
337
|
+
tools.push(adapter.id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const adapter of Object.values(CI_ADAPTERS)) {
|
|
341
|
+
if (adapter.outputFile && fs.existsSync(path.join(targetDir, adapter.outputFile))) {
|
|
342
|
+
ci.push(adapter.id);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let settingsText = "";
|
|
347
|
+
for (const relative of new Set(Object.values(AGENT_ADAPTERS).map((adapter) => adapter.hooksFile))) {
|
|
348
|
+
try {
|
|
349
|
+
settingsText += fs.readFileSync(path.join(targetDir, relative), "utf8");
|
|
350
|
+
} catch {
|
|
351
|
+
// absent agent settings do not imply hooks
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
targets = {
|
|
355
|
+
tools: tools.length > 0 ? tools : ["claude"],
|
|
356
|
+
ci,
|
|
357
|
+
hooks: fs.existsSync(path.join(targetDir, ".stdd", "hooks", "pre-push")),
|
|
358
|
+
sessionHook:
|
|
359
|
+
settingsText.includes("stdd status") ||
|
|
360
|
+
(settingsText.includes("STDD managed Pi lifecycle extension") &&
|
|
361
|
+
settingsText.includes('pi.on("session_start"')),
|
|
362
|
+
stopHook:
|
|
363
|
+
settingsText.includes("stdd stop-hook") ||
|
|
364
|
+
(settingsText.includes("STDD managed Pi lifecycle extension") &&
|
|
365
|
+
settingsText.includes('pi.on("agent_settled"')),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
let capabilitiesList = opts.capabilitiesList ?? null;
|
|
369
|
+
let reviewVia = opts.reviewVia ?? null;
|
|
370
|
+
let maxRounds = opts.maxRounds ?? null;
|
|
371
|
+
let stopHook = Boolean(opts.stopHook);
|
|
372
|
+
const interactive = !capabilitiesList && !reviewVia && maxRounds === null && !stopHook;
|
|
373
|
+
if (interactive) {
|
|
374
|
+
const { ask, yes, close } = makePrompter();
|
|
375
|
+
console.log("stdd configure — one question at a time; an empty answer keeps the current value\n");
|
|
376
|
+
capabilitiesList = [];
|
|
377
|
+
if (await yes("Can agents dispatch subagents?", config.capabilities.subagents))
|
|
378
|
+
capabilitiesList.push("subagents");
|
|
379
|
+
if (await yes("May selected agent CLIs invoke a second reviewer CLI?", config.capabilities.crossCli))
|
|
380
|
+
capabilitiesList.push("crossCli");
|
|
381
|
+
if (await yes("Are isolated git worktrees available?", config.capabilities.worktrees))
|
|
382
|
+
capabilitiesList.push("worktrees");
|
|
383
|
+
reviewVia = await askReviewVia(ask, close, config.review.via);
|
|
384
|
+
const current = config.review.maxRounds ?? 0;
|
|
385
|
+
const budgetAnswer = await ask(
|
|
386
|
+
`Review budget — changes-requested rounds before refusal (0 = unlimited) [${current}]: `,
|
|
387
|
+
String(current),
|
|
388
|
+
);
|
|
389
|
+
maxRounds = Number(budgetAnswer);
|
|
390
|
+
if (!/^\d+$/.test(budgetAnswer) || !Number.isSafeInteger(maxRounds)) {
|
|
391
|
+
close();
|
|
392
|
+
fail("the review budget must be a non-negative safe integer (0 = unlimited)");
|
|
393
|
+
}
|
|
394
|
+
if (targets.tools.length > 0 && !targets.stopHook) {
|
|
395
|
+
stopHook = await yes(
|
|
396
|
+
"Wire native agent stop integrations (gate or corrective continuation)?",
|
|
397
|
+
false,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
close();
|
|
401
|
+
}
|
|
402
|
+
// validate the combination BEFORE any write — no partial configuration
|
|
403
|
+
const caps = capabilitiesList
|
|
404
|
+
? Object.fromEntries(KNOWN_CAPABILITIES.map((c) => [c, capabilitiesList.includes(c)]))
|
|
405
|
+
: config.capabilities;
|
|
406
|
+
const via = reviewVia ?? config.review.via;
|
|
407
|
+
const hasDispatch = caps.subagents || caps.crossCli;
|
|
408
|
+
if (hasDispatch && (via === "codex" || via === "claude") && !caps.crossCli) {
|
|
409
|
+
fail(`review.via "${via}" needs the crossCli capability — pick another route or enable crossCli`);
|
|
410
|
+
}
|
|
411
|
+
if (hasDispatch && via === "subagent" && !caps.subagents) {
|
|
412
|
+
fail(
|
|
413
|
+
`review.via "subagent" needs the subagents capability — pick another route or enable subagents`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const desiredStopHook = stopHook || targets.stopHook;
|
|
417
|
+
const existingCi = targets.ci.filter((provider) => {
|
|
418
|
+
const outputFile = CI_ADAPTERS[provider].outputFile;
|
|
419
|
+
return outputFile === null || fs.existsSync(path.join(targetDir, outputFile));
|
|
420
|
+
});
|
|
421
|
+
await init(targetDir, {
|
|
422
|
+
tools: targets.tools,
|
|
423
|
+
ci: existingCi,
|
|
424
|
+
rememberedCiTargets: targets.ci,
|
|
425
|
+
hooks: false,
|
|
426
|
+
sessionHook: false,
|
|
427
|
+
stopHook: desiredStopHook,
|
|
428
|
+
rememberedHookTargets: {
|
|
429
|
+
hooks: targets.hooks,
|
|
430
|
+
sessionHook: targets.sessionHook,
|
|
431
|
+
stopHook: desiredStopHook,
|
|
432
|
+
},
|
|
433
|
+
capabilitiesList: capabilitiesList ?? Object.keys(caps).filter((c) => caps[c]),
|
|
434
|
+
reviewVia: via,
|
|
435
|
+
reviewMaxRounds: maxRounds,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function init(targetDir, opts) {
|
|
440
|
+
const { tools, ci, hooks, sessionHook, capabilitiesList } = opts;
|
|
441
|
+
const stopHook = Boolean(opts.stopHook);
|
|
442
|
+
const rememberedCiTargets = opts.rememberedCiTargets ?? ci;
|
|
443
|
+
const rememberedHookTargets = opts.rememberedHookTargets ?? {
|
|
444
|
+
hooks: Boolean(hooks),
|
|
445
|
+
sessionHook: Boolean(sessionHook),
|
|
446
|
+
stopHook,
|
|
447
|
+
};
|
|
448
|
+
const automationRunner = isStddSourceCheckout(targetDir) ? SOURCE_RUNNER : NPM_RUNNER;
|
|
449
|
+
try {
|
|
450
|
+
resolveWritableRepoPath(targetDir, ".stdd", "stdd install path");
|
|
451
|
+
} catch (err) {
|
|
452
|
+
fail(err.message);
|
|
453
|
+
}
|
|
454
|
+
// Config is user-owned input. Validate it before loading playbooks,
|
|
455
|
+
// inspecting manifests, or writing any generated/install state.
|
|
456
|
+
const existingConfig = loadConfig(targetDir);
|
|
457
|
+
// A previous crash or publish failure is settled before this run reads
|
|
458
|
+
// dynamic paths below. An unprovable inode or parent identity blocks init
|
|
459
|
+
// with the journal as the authoritative diagnostic.
|
|
460
|
+
// Validate every repo-authored dynamic path before the first write. A
|
|
461
|
+
// cloned repository must not turn `stdd init` into an arbitrary writer.
|
|
462
|
+
const local = loadLocalPlaybooks(targetDir);
|
|
463
|
+
let previousManifest;
|
|
464
|
+
try {
|
|
465
|
+
previousManifest = readManifestDocument(targetDir);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
fail(`.stdd/manifest.json ${error.message}`);
|
|
468
|
+
}
|
|
469
|
+
let oldFiles = previousManifest?.files ?? Object.create(null);
|
|
470
|
+
let oldQuarantineIdentities = previousManifest?.quarantineIdentities ?? Object.create(null);
|
|
471
|
+
let previouslyRetainedCleanupJournals = Object.keys(oldFiles).filter((relative) =>
|
|
472
|
+
relative.split("/").some((segment) => /^\.stdd-cleanup-journal-[0-9a-f]{32}\.tmp$/.test(segment)),
|
|
473
|
+
);
|
|
474
|
+
// A same-name local recipe always replaces the kit recipe. Capability
|
|
475
|
+
// filtering happens afterwards, so an inactive local override intentionally
|
|
476
|
+
// leaves an optional skill absent instead of silently falling back to the kit.
|
|
477
|
+
const capabilities = capabilitiesList
|
|
478
|
+
? Object.fromEntries(KNOWN_CAPABILITIES.map((c) => [c, capabilitiesList.includes(c)]))
|
|
479
|
+
: existingConfig.capabilities;
|
|
480
|
+
const reviewVia =
|
|
481
|
+
opts.reviewVia ??
|
|
482
|
+
(capabilitiesList && (capabilities.crossCli || capabilities.subagents)
|
|
483
|
+
? recommendedReviewVia(tools, capabilities)
|
|
484
|
+
: null);
|
|
485
|
+
const agentNeutralReviewVia = reviewVia ?? existingConfig.review.via;
|
|
486
|
+
const applies = (pb) => {
|
|
487
|
+
if (!pb.meta.requires) return true;
|
|
488
|
+
if (!KNOWN_CAPABILITIES.includes(pb.meta.requires)) {
|
|
489
|
+
fail(`playbook ${pb.file}: requires unknown capability "${pb.meta.requires}"`);
|
|
490
|
+
}
|
|
491
|
+
return capabilities[pb.meta.requires];
|
|
492
|
+
};
|
|
493
|
+
const compile = (pb, text) => {
|
|
494
|
+
try {
|
|
495
|
+
return compileCapabilities(text, capabilities);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
throw new Error(`playbook ${pb.file}: ${err.message}`, { cause: err });
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const localNames = new Set(local.map((pb) => pb.meta.name));
|
|
501
|
+
const localActive = local.filter(applies);
|
|
502
|
+
const kitActive = loadPlaybooks()
|
|
503
|
+
.filter(applies)
|
|
504
|
+
.filter((pb) => {
|
|
505
|
+
if (!localNames.has(pb.meta.name)) return true;
|
|
506
|
+
console.log(`local recipe overrides the kit playbook "${pb.meta.name}" (${pb.file})`);
|
|
507
|
+
return false;
|
|
508
|
+
});
|
|
509
|
+
const activeNames = new Set([...kitActive, ...localActive].map((pb) => pb.meta.name));
|
|
510
|
+
for (const name of MANDATORY_ROUTING_SKILLS) {
|
|
511
|
+
if (!activeNames.has(name)) {
|
|
512
|
+
fail(
|
|
513
|
+
`mandatory routing skill "${name}" is inactive; capability profiles and local overrides must keep every managed agent route available`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Compile and render every dynamic source before opening the mutating
|
|
519
|
+
// helper session. A malformed playbook, capability block, or CI template
|
|
520
|
+
// must not leave a partial installation behind.
|
|
521
|
+
const compiledPlaybooks = new Map(
|
|
522
|
+
[...kitActive, ...localActive].map((pb) => [
|
|
523
|
+
pb,
|
|
524
|
+
{ source: compile(pb, pb.source), body: compile(pb, pb.body) },
|
|
525
|
+
]),
|
|
526
|
+
);
|
|
527
|
+
const toolPlans = new Map();
|
|
528
|
+
for (const tool of tools) {
|
|
529
|
+
const adapter = getAgentAdapter(tool);
|
|
530
|
+
const skills = new Map();
|
|
531
|
+
for (const pb of [...kitActive, ...localActive]) {
|
|
532
|
+
skills.set(
|
|
533
|
+
pb,
|
|
534
|
+
renderAgentSkill({
|
|
535
|
+
adapter: tool,
|
|
536
|
+
name: pb.meta.name,
|
|
537
|
+
description: pb.meta.description,
|
|
538
|
+
when: pb.meta.when,
|
|
539
|
+
body: compiledPlaybooks.get(pb).body,
|
|
540
|
+
stamp: STAMP,
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
toolPlans.set(tool, {
|
|
545
|
+
adapter,
|
|
546
|
+
skills,
|
|
547
|
+
snippet: renderAgentInstructions({
|
|
548
|
+
adapter: tool,
|
|
549
|
+
stamp: STAMP,
|
|
550
|
+
npmRunner: automationRunner,
|
|
551
|
+
crossCli: capabilities.crossCli,
|
|
552
|
+
projectLogEnabled: existingConfig.projectLog.enabled,
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const ciPlans = new Map();
|
|
557
|
+
for (const provider of ci) {
|
|
558
|
+
const adapter = CI_ADAPTERS[provider];
|
|
559
|
+
if (adapter.outputFile !== null) {
|
|
560
|
+
ciPlans.set(
|
|
561
|
+
provider,
|
|
562
|
+
renderCiTemplate(
|
|
563
|
+
fs.readFileSync(path.join(PKG_ROOT, "templates", adapter.templateFile), "utf8"),
|
|
564
|
+
{ stamp: STAMP, version: VERSION },
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const publicationPaths = new Set([".stdd/method.md", ".stdd/config.json", ".gitignore"]);
|
|
570
|
+
for (const pb of kitActive) publicationPaths.add(`.stdd/playbooks/${pb.file}`);
|
|
571
|
+
for (const { adapter, skills } of toolPlans.values()) {
|
|
572
|
+
publicationPaths.add(adapter.snippetFile);
|
|
573
|
+
publicationPaths.add(adapter.instructionsFile);
|
|
574
|
+
for (const pb of skills.keys()) {
|
|
575
|
+
publicationPaths.add(`${adapter.skillRoot}/${pb.meta.name}/SKILL.md`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
579
|
+
publicationPaths.add(adapter.instructionsFile);
|
|
580
|
+
}
|
|
581
|
+
for (const provider of ciPlans.keys()) publicationPaths.add(CI_ADAPTERS[provider].outputFile);
|
|
582
|
+
if (hooks) publicationPaths.add(".stdd/hooks/pre-push");
|
|
583
|
+
publicationPaths.add(".stdd/policy.md");
|
|
584
|
+
for (const relative of publicationPaths) {
|
|
585
|
+
resolveWritableRepoPath(targetDir, relative, `generated path ${JSON.stringify(relative)}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let context;
|
|
589
|
+
let recoveredCleanupJournals = [];
|
|
590
|
+
let publishAgentHooks = async () => true;
|
|
591
|
+
try {
|
|
592
|
+
context = await openNativeRepoMutation(targetDir, "native filesystem helper for init");
|
|
593
|
+
for (const relative of publicationPaths) {
|
|
594
|
+
await preflightNativeRepoDestination(
|
|
595
|
+
context,
|
|
596
|
+
relative,
|
|
597
|
+
`generated path ${JSON.stringify(relative)}`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (sessionHook || stopHook) {
|
|
601
|
+
publishAgentHooks = await prepareAgentHooks(context, automationRunner, tools, {
|
|
602
|
+
sessionHook,
|
|
603
|
+
stopHook,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
previousManifest = await readManifestDocumentWithCapabilities(context);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw new Error(`.stdd/manifest.json ${error.message}`, { cause: error });
|
|
610
|
+
}
|
|
611
|
+
oldFiles = previousManifest?.files ?? Object.create(null);
|
|
612
|
+
oldQuarantineIdentities = previousManifest?.quarantineIdentities ?? Object.create(null);
|
|
613
|
+
previouslyRetainedCleanupJournals = Object.keys(oldFiles).filter((relative) =>
|
|
614
|
+
relative.split("/").some((segment) => /^\.stdd-cleanup-journal-[0-9a-f]{32}\.tmp$/.test(segment)),
|
|
615
|
+
);
|
|
616
|
+
const instructionStates = new Map();
|
|
617
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
618
|
+
instructionStates.set(
|
|
619
|
+
adapter.instructionsFile,
|
|
620
|
+
await readOptionalNativeRepoFile(context, adapter.instructionsFile, {
|
|
621
|
+
label: `${adapter.instructionsFile} path`,
|
|
622
|
+
}),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const prePushState = hooks
|
|
626
|
+
? await readOptionalNativeRepoFile(context, ".stdd/hooks/pre-push", {
|
|
627
|
+
label: "pre-push hook path",
|
|
628
|
+
})
|
|
629
|
+
: null;
|
|
630
|
+
const policyState = await readOptionalNativeRepoFile(context, ".stdd/policy.md", {
|
|
631
|
+
label: "policy path",
|
|
632
|
+
});
|
|
633
|
+
const gitignoreState = await readOptionalNativeRepoFile(context, ".gitignore", {
|
|
634
|
+
label: ".gitignore path",
|
|
635
|
+
});
|
|
636
|
+
let configState = (await readConfigForWrite(context)).state;
|
|
637
|
+
if (configState) preservedPublicationMode(configState, 0o644, ".stdd/config.json");
|
|
638
|
+
for (const [relative, state] of instructionStates) {
|
|
639
|
+
if (state) preservedPublicationMode(state, 0o644, relative);
|
|
640
|
+
}
|
|
641
|
+
if (gitignoreState) preservedPublicationMode(gitignoreState, 0o644, ".gitignore");
|
|
642
|
+
if (prePushState) preservedPublicationMode(prePushState, 0o755, ".stdd/hooks/pre-push");
|
|
643
|
+
if (policyState) preservedPublicationMode(policyState, 0o644, ".stdd/policy.md");
|
|
644
|
+
recoveredCleanupJournals = await recoverCleanupJournalWithCapabilities(context);
|
|
645
|
+
await openOrCreateNativeRepoDirectory(context, ".stdd", {
|
|
646
|
+
mode: 0o755,
|
|
647
|
+
label: "stdd install directory",
|
|
648
|
+
});
|
|
649
|
+
if (capabilitiesList) {
|
|
650
|
+
configState = await writeCapabilities(context, capabilitiesList, configState);
|
|
651
|
+
}
|
|
652
|
+
if (reviewVia) {
|
|
653
|
+
configState = await writeReviewVia(context, reviewVia, opts.reviewMaxRounds ?? null, configState);
|
|
654
|
+
}
|
|
655
|
+
// The previous run's manifest: files it generated that this profile no
|
|
656
|
+
// longer produces are removed at the end — only when still byte-identical.
|
|
657
|
+
// Every generated file is recorded here (repo-relative path → content
|
|
658
|
+
// hash) and written to .stdd/manifest.json, so check/doctor can detect
|
|
659
|
+
// hand edits, deletions, and stale copies. User-owned files (config.json,
|
|
660
|
+
// local recipes) are deliberately not recorded.
|
|
661
|
+
const generated = Object.create(null);
|
|
662
|
+
const initialQuarantineIdentities = Object.create(null);
|
|
663
|
+
const retireOnlyFiles = new Set();
|
|
664
|
+
const writeGenerated = async (relPath, content) => {
|
|
665
|
+
await publishNativeRepoFile(context, relPath, content, {
|
|
666
|
+
mode: 0o644,
|
|
667
|
+
tempPrefix: ".stdd-generated-",
|
|
668
|
+
});
|
|
669
|
+
generated[relPath] = sha256(content);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
await writeGenerated(
|
|
673
|
+
".stdd/method.md",
|
|
674
|
+
renderInstalledMethod(
|
|
675
|
+
fs.readFileSync(path.join(PKG_ROOT, "method", "README.md"), "utf8"),
|
|
676
|
+
existingConfig.projectLog.enabled,
|
|
677
|
+
),
|
|
678
|
+
);
|
|
679
|
+
for (const pb of kitActive) {
|
|
680
|
+
await writeGenerated(
|
|
681
|
+
`.stdd/playbooks/${pb.file}`,
|
|
682
|
+
compiledPlaybooks.get(pb).source.replaceAll(CROSS_CLI_REVIEW_VIA_TOKEN, agentNeutralReviewVia),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (!configState) {
|
|
686
|
+
await publishNativeRepoFile(
|
|
687
|
+
context,
|
|
688
|
+
".stdd/config.json",
|
|
689
|
+
`${JSON.stringify(DEFAULT_CONFIG, null, "\t")}\n`,
|
|
690
|
+
{ mode: 0o644, tempPrefix: ".config-", expectedTarget: null },
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
console.log(`Installed .stdd/ (method, ${kitActive.length} playbooks, config)`);
|
|
694
|
+
|
|
695
|
+
const managedInstructions = /<!-- stdd:begin[^>]*-->\r?\n[\s\S]*?<!-- stdd:end -->\r?\n?/;
|
|
696
|
+
for (const tool of tools) {
|
|
697
|
+
const { adapter, skills, snippet } = toolPlans.get(tool);
|
|
698
|
+
for (const pb of [...kitActive, ...localActive]) {
|
|
699
|
+
await writeGenerated(`${adapter.skillRoot}/${pb.meta.name}/SKILL.md`, skills.get(pb));
|
|
700
|
+
}
|
|
701
|
+
await writeGenerated(adapter.snippetFile, snippet);
|
|
702
|
+
resolveWritableRepoPath(targetDir, adapter.instructionsFile, `${adapter.instructionsFile} path`);
|
|
703
|
+
const instructionState = instructionStates.get(adapter.instructionsFile);
|
|
704
|
+
const block = `<!-- stdd:begin — managed section, re-run \`stdd init\` to update -->\n${snippet}<!-- stdd:end -->\n`;
|
|
705
|
+
if (!instructionState) {
|
|
706
|
+
await publishNativeRepoFile(context, adapter.instructionsFile, block, {
|
|
707
|
+
mode: 0o644,
|
|
708
|
+
tempPrefix: ".instructions-",
|
|
709
|
+
expectedTarget: null,
|
|
710
|
+
});
|
|
711
|
+
console.log(`Wrote ${adapter.instructionsFile} with the managed STDD section`);
|
|
712
|
+
} else {
|
|
713
|
+
const current = instructionState.bytes.toString("utf8");
|
|
714
|
+
const updated = managedInstructions.test(current)
|
|
715
|
+
? current.replace(managedInstructions, block)
|
|
716
|
+
: `${current}${current.endsWith("\n") ? "" : "\n"}\n${block}`;
|
|
717
|
+
if (updated !== current) {
|
|
718
|
+
await publishNativeRepoFile(context, adapter.instructionsFile, updated, {
|
|
719
|
+
mode: preservedPublicationMode(instructionState, 0o644, adapter.instructionsFile),
|
|
720
|
+
tempPrefix: ".instructions-",
|
|
721
|
+
expectedTarget: instructionState.file.observation.identity,
|
|
722
|
+
expectedBytes: instructionState.bytes,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
console.log(`Updated the managed STDD section in ${adapter.instructionsFile}`);
|
|
726
|
+
}
|
|
727
|
+
console.log(
|
|
728
|
+
`Installed ${kitActive.length + localActive.length} ${tool} skills under ${adapter.skillRoot}/`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
for (const adapter of Object.values(AGENT_ADAPTERS).filter(
|
|
732
|
+
(candidate) => !tools.includes(candidate.id),
|
|
733
|
+
)) {
|
|
734
|
+
resolveWritableRepoPath(targetDir, adapter.instructionsFile, `${adapter.instructionsFile} path`);
|
|
735
|
+
const instructionState = instructionStates.get(adapter.instructionsFile);
|
|
736
|
+
if (!instructionState) continue;
|
|
737
|
+
const current = instructionState.bytes.toString("utf8");
|
|
738
|
+
if (!managedInstructions.test(current)) continue;
|
|
739
|
+
const updated = current.replace(managedInstructions, "");
|
|
740
|
+
if (updated.trim() === "") {
|
|
741
|
+
// User-owned instruction files are not normally manifest-tracked.
|
|
742
|
+
// When the managed section is the whole file, temporarily add its
|
|
743
|
+
// exact current bytes to the old ownership set so finalization
|
|
744
|
+
// retires it behind the same cleanup WAL as generated outputs.
|
|
745
|
+
oldFiles[adapter.instructionsFile] = sha256(current);
|
|
746
|
+
retireOnlyFiles.add(adapter.instructionsFile);
|
|
747
|
+
} else {
|
|
748
|
+
await publishNativeRepoFile(context, adapter.instructionsFile, updated, {
|
|
749
|
+
mode: preservedPublicationMode(instructionState, 0o644, adapter.instructionsFile),
|
|
750
|
+
tempPrefix: ".instructions-",
|
|
751
|
+
expectedTarget: instructionState.file.observation.identity,
|
|
752
|
+
expectedBytes: instructionState.bytes,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
console.log(`Removed the managed STDD section from deselected ${adapter.instructionsFile}`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
for (const provider of ci) {
|
|
759
|
+
const adapter = CI_ADAPTERS[provider];
|
|
760
|
+
if (adapter.outputFile === null) {
|
|
761
|
+
console.log(
|
|
762
|
+
`Portable CI contract for ${adapter.id} (compose with your provider's checkout and live PR/MR body):\n` +
|
|
763
|
+
` npx --yes @stdd/cli@${VERSION} check .\n` +
|
|
764
|
+
` printf '%s' "$REVIEW_BODY" | npx --yes @stdd/cli@${VERSION} check-pr - --base "$BASE_REF"`,
|
|
765
|
+
);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
await writeGenerated(adapter.outputFile, ciPlans.get(provider));
|
|
769
|
+
console.log(`Installed ${adapter.outputFile} (${provider} live review evidence)`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Repository-owned standing decisions. Seeded once and then hands-off:
|
|
773
|
+
// user-owned after generation like config.json, never manifested, so a
|
|
774
|
+
// recorded permission survives every later init.
|
|
775
|
+
if (!policyState) {
|
|
776
|
+
await publishNativeRepoFile(context, ".stdd/policy.md", POLICY_SEED, {
|
|
777
|
+
mode: 0o644,
|
|
778
|
+
tempPrefix: ".policy-",
|
|
779
|
+
directoryMode: 0o755,
|
|
780
|
+
expectedTarget: null,
|
|
781
|
+
});
|
|
782
|
+
console.log("Installed .stdd/policy.md (notes and conditional permissions)");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (hooks) {
|
|
786
|
+
// One fast offline command only — network calls in hooks produce
|
|
787
|
+
// false positives that train --no-verify. User-owned after
|
|
788
|
+
// generation (like config.json): never manifested. A byte-for-byte
|
|
789
|
+
// generated hook is safe to re-pin on upgrade; any user edit makes it
|
|
790
|
+
// hands-off.
|
|
791
|
+
resolveWritableRepoPath(targetDir, ".stdd/hooks/pre-push", "pre-push hook path");
|
|
792
|
+
const hookState = prePushState;
|
|
793
|
+
if (hookState) {
|
|
794
|
+
const current = hookState.bytes.toString("utf8");
|
|
795
|
+
const updated = prePushHook(automationRunner);
|
|
796
|
+
if (current !== updated && isGeneratedPrePushHook(current)) {
|
|
797
|
+
await publishNativeRepoFile(context, ".stdd/hooks/pre-push", updated, {
|
|
798
|
+
mode: preservedPublicationMode(hookState, 0o755, ".stdd/hooks/pre-push"),
|
|
799
|
+
tempPrefix: ".hook-",
|
|
800
|
+
directoryMode: 0o755,
|
|
801
|
+
expectedTarget: hookState.file.observation.identity,
|
|
802
|
+
expectedBytes: hookState.bytes,
|
|
803
|
+
});
|
|
804
|
+
console.log("Re-pinned the generated .stdd/hooks/pre-push to this stdd version");
|
|
805
|
+
} else {
|
|
806
|
+
console.log(".stdd/hooks/pre-push already exists — left untouched (user-owned)");
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
await publishNativeRepoFile(context, ".stdd/hooks/pre-push", prePushHook(automationRunner), {
|
|
810
|
+
mode: 0o755,
|
|
811
|
+
tempPrefix: ".hook-",
|
|
812
|
+
directoryMode: 0o755,
|
|
813
|
+
expectedTarget: null,
|
|
814
|
+
});
|
|
815
|
+
console.log(
|
|
816
|
+
"Installed .stdd/hooks/pre-push (runs stdd check — fast, offline). Wire it up with ONE of:\n" +
|
|
817
|
+
" git config core.hooksPath .stdd/hooks # note: this disables hooks in .git/hooks\n" +
|
|
818
|
+
" …or call `stdd check` from your existing husky/lefthook pre-push",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (sessionHook || stopHook) await publishAgentHooks();
|
|
824
|
+
if ((hooks || sessionHook || stopHook) && !hasLocalStddBinary(targetDir)) {
|
|
825
|
+
console.error(
|
|
826
|
+
"stdd init: automation was generated, but no project-local stdd binary is installed — " +
|
|
827
|
+
"run `npm install --save-dev --save-exact @stdd/cli` before relying on hooks",
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// The ledger and the plan are per-checkout working artifacts — never
|
|
832
|
+
// committed. The ignore rules are user-owned once written, not manifested.
|
|
833
|
+
resolveWritableRepoPath(targetDir, ".gitignore", ".gitignore path");
|
|
834
|
+
const gitignore = gitignoreState?.bytes.toString("utf8") ?? "";
|
|
835
|
+
const retainedLines = gitignore
|
|
836
|
+
.split("\n")
|
|
837
|
+
.filter((line) => line !== LEGACY_LEDGER_RESET_TEMP_IGNORE && line !== LEDGER_RESET_TEMP_GIT_GLOB);
|
|
838
|
+
const retained = retainedLines.join("\n");
|
|
839
|
+
const missing = [LEDGER_REL, PLAN_REL, WORKER_METADATA_REL, `${WORKER_DELETIONS_REL}/`].filter(
|
|
840
|
+
(line) => !retainedLines.includes(line),
|
|
841
|
+
);
|
|
842
|
+
if (retained !== gitignore || missing.length > 0) {
|
|
843
|
+
const sep = retained === "" || retained.endsWith("\n") ? "" : "\n";
|
|
844
|
+
await publishNativeRepoFile(
|
|
845
|
+
context,
|
|
846
|
+
".gitignore",
|
|
847
|
+
`${retained}${sep}${missing.join("\n")}${missing.length ? "\n" : ""}`,
|
|
848
|
+
{
|
|
849
|
+
mode: preservedPublicationMode(gitignoreState, 0o644, ".gitignore"),
|
|
850
|
+
tempPrefix: ".gitignore-",
|
|
851
|
+
expectedTarget: gitignoreState?.file.observation.identity ?? null,
|
|
852
|
+
expectedBytes: gitignoreState?.bytes ?? null,
|
|
853
|
+
},
|
|
854
|
+
);
|
|
855
|
+
if (missing.length > 0) {
|
|
856
|
+
console.log(`Added ${missing.join(", ")} to .gitignore (per-checkout, never committed)`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
await finalizeGeneratedFilesWithCapabilities(context, {
|
|
861
|
+
oldFiles,
|
|
862
|
+
oldQuarantineIdentities,
|
|
863
|
+
initialQuarantineIdentities,
|
|
864
|
+
generated,
|
|
865
|
+
retainedCleanupJournals: [...previouslyRetainedCleanupJournals, ...recoveredCleanupJournals],
|
|
866
|
+
targets: {
|
|
867
|
+
tools,
|
|
868
|
+
ci: rememberedCiTargets,
|
|
869
|
+
hooks: rememberedHookTargets.hooks,
|
|
870
|
+
sessionHook: rememberedHookTargets.sessionHook,
|
|
871
|
+
stopHook: rememberedHookTargets.stopHook,
|
|
872
|
+
},
|
|
873
|
+
legacyRetainedCleanupJournal: previousManifest?.retainedCleanupJournal ?? null,
|
|
874
|
+
expectedManifestIdentity: previousManifest?.[NATIVE_MANIFEST_IDENTITY] ?? null,
|
|
875
|
+
retireOnlyFiles: [...retireOnlyFiles],
|
|
876
|
+
});
|
|
877
|
+
} catch (error) {
|
|
878
|
+
await context?.close().catch(() => {});
|
|
879
|
+
fail(error.message);
|
|
880
|
+
} finally {
|
|
881
|
+
await context?.close().catch(() => {});
|
|
882
|
+
}
|
|
883
|
+
}
|