@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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +47 -0
  5. package/extensions/stdd.mjs +77 -0
  6. package/hooks/claude-hooks.json +28 -0
  7. package/hooks/codex-hooks.json +28 -0
  8. package/package.json +38 -0
  9. package/runtime/adapters/README.md +158 -0
  10. package/runtime/cli/check.mjs +555 -0
  11. package/runtime/cli/ci.mjs +190 -0
  12. package/runtime/cli/claude-hooks.mjs +689 -0
  13. package/runtime/cli/config.mjs +27 -0
  14. package/runtime/cli/evidence.mjs +249 -0
  15. package/runtime/cli/generated-files.mjs +1693 -0
  16. package/runtime/cli/held-fs.mjs +415 -0
  17. package/runtime/cli/init.mjs +883 -0
  18. package/runtime/cli/ledger.mjs +1470 -0
  19. package/runtime/cli/lib.mjs +909 -0
  20. package/runtime/cli/path-bytes.mjs +83 -0
  21. package/runtime/cli/policy.mjs +112 -0
  22. package/runtime/cli/recorders.mjs +188 -0
  23. package/runtime/cli/review-fs.mjs +825 -0
  24. package/runtime/cli/review.mjs +1065 -0
  25. package/runtime/cli/runtime.mjs +32 -0
  26. package/runtime/cli/scope.mjs +185 -0
  27. package/runtime/cli/snapshot.mjs +897 -0
  28. package/runtime/cli/state-validation.mjs +168 -0
  29. package/runtime/cli/status.mjs +580 -0
  30. package/runtime/cli/stdd.mjs +536 -0
  31. package/runtime/cli/worker-fs.mjs +971 -0
  32. package/runtime/cli/worker-metadata.mjs +139 -0
  33. package/runtime/cli/worker.mjs +779 -0
  34. package/runtime/method/README.md +634 -0
  35. package/runtime/method/reference-commands.md +147 -0
  36. package/runtime/method/reference-generated-state.md +151 -0
  37. package/runtime/method/reference-integration.md +233 -0
  38. package/runtime/package.json +65 -0
  39. package/runtime/playbooks/brainstorming.md +46 -0
  40. package/runtime/playbooks/debugging.md +36 -0
  41. package/runtime/playbooks/delegate-slice.md +129 -0
  42. package/runtime/playbooks/finish-change.md +46 -0
  43. package/runtime/playbooks/implement.md +26 -0
  44. package/runtime/playbooks/investigation.md +33 -0
  45. package/runtime/playbooks/managed-playbooks.json +14 -0
  46. package/runtime/playbooks/planning.md +177 -0
  47. package/runtime/playbooks/pr-green.md +50 -0
  48. package/runtime/playbooks/start-change.md +37 -0
  49. package/runtime/playbooks/worktrees.md +45 -0
  50. package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
  51. package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
  52. package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
  53. package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
  54. package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
  55. package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
  56. package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
  57. package/runtime/sdk/adapters.mjs +279 -0
  58. package/runtime/sdk/file-observation.mjs +12 -0
  59. package/runtime/sdk/index.d.ts +140 -0
  60. package/runtime/sdk/index.mjs +31 -0
  61. package/runtime/sdk/native-fs.mjs +1235 -0
  62. package/runtime/sdk/path.mjs +71 -0
  63. package/runtime/sdk/text.mjs +42 -0
  64. package/runtime/sdk/workflow.mjs +294 -0
  65. package/runtime/templates/deferred-design.md +47 -0
  66. package/runtime/templates/github-stdd.yml +42 -0
  67. package/runtime/templates/gitlab-stdd.yml +72 -0
  68. package/runtime/templates/pr-description.md +35 -0
  69. package/scripts/adopting-root.mjs +42 -0
  70. package/scripts/stdd-hook.mjs +72 -0
  71. package/skills/stdd-brainstorming/SKILL.md +48 -0
  72. package/skills/stdd-debugging/SKILL.md +38 -0
  73. package/skills/stdd-delegate-slice/SKILL.md +118 -0
  74. package/skills/stdd-finish-change/SKILL.md +40 -0
  75. package/skills/stdd-implement/SKILL.md +28 -0
  76. package/skills/stdd-investigation/SKILL.md +35 -0
  77. package/skills/stdd-planning/SKILL.md +165 -0
  78. package/skills/stdd-pr-green/SKILL.md +52 -0
  79. package/skills/stdd-start-change/SKILL.md +39 -0
  80. package/skills/stdd-worktrees/SKILL.md +46 -0
@@ -0,0 +1,1693 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ AGENT_ADAPTERS,
7
+ assertSemanticVersion,
8
+ CI_ADAPTERS,
9
+ getAgentAdapter,
10
+ renderAgentInstructions,
11
+ } from "../sdk/adapters.mjs";
12
+ import { sameFileIdentity } from "../sdk/file-observation.mjs";
13
+ import { assertSkillName, resolveRepoPath, resolveWritableRepoPath } from "../sdk/path.mjs";
14
+ import { isStddSourceCheckout } from "./claude-hooks.mjs";
15
+ import {
16
+ openNativeRepoPath,
17
+ openOrCreateNativeRepoDirectory,
18
+ publishNativeRepoFile,
19
+ readNativeFile,
20
+ verifyNativeRepoDirectory,
21
+ writeNativeFileContent,
22
+ } from "./held-fs.mjs";
23
+ import { DEFAULT_CONFIG, parseFrontmatter, sha256 } from "./lib.mjs";
24
+ import { fail } from "./runtime.mjs";
25
+ import { MANIFEST_HASH_PATTERN } from "./state-validation.mjs";
26
+
27
+ export const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
+ export const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
29
+ export const KNOWN_TOOLS = Object.keys(AGENT_ADAPTERS);
30
+ export const KNOWN_CI = Object.keys(CI_ADAPTERS);
31
+ export const KNOWN_CAPABILITIES = Object.keys(DEFAULT_CONFIG.capabilities);
32
+ export const CLEANUP_JOURNAL_REL = ".stdd/cleanup-transaction.json";
33
+ const SHIPPED_PLAYBOOK_FILES = new Set(
34
+ fs.readdirSync(path.join(PKG_ROOT, "playbooks")).filter((file) => file.endsWith(".md")),
35
+ );
36
+ // This append-only registry is package data: deleting a shipped playbook does
37
+ // not delete its ownership record, so existing installs can retire it safely
38
+ // without a release author remembering to move a basename between two lists.
39
+ const MANAGED_PLAYBOOK_REGISTRY = JSON.parse(
40
+ fs.readFileSync(path.join(PKG_ROOT, "playbooks", "managed-playbooks.json"), "utf8"),
41
+ );
42
+ if (
43
+ typeof MANAGED_PLAYBOOK_REGISTRY !== "object" ||
44
+ MANAGED_PLAYBOOK_REGISTRY === null ||
45
+ !Array.isArray(MANAGED_PLAYBOOK_REGISTRY.managed) ||
46
+ MANAGED_PLAYBOOK_REGISTRY.managed.some(
47
+ (file) =>
48
+ typeof file !== "string" ||
49
+ !file.endsWith(".md") ||
50
+ path.posix.basename(file) !== file ||
51
+ file === "." ||
52
+ file === "..",
53
+ ) ||
54
+ new Set(MANAGED_PLAYBOOK_REGISTRY.managed).size !== MANAGED_PLAYBOOK_REGISTRY.managed.length ||
55
+ [...SHIPPED_PLAYBOOK_FILES].some((file) => !MANAGED_PLAYBOOK_REGISTRY.managed.includes(file))
56
+ ) {
57
+ throw new Error(
58
+ "playbooks/managed-playbooks.json must be an append-only registry of shipped basenames",
59
+ );
60
+ }
61
+ const KNOWN_MANAGED_PLAYBOOK_FILES = new Set(MANAGED_PLAYBOOK_REGISTRY.managed);
62
+ const NATIVE_JOURNAL_IDENTITY = Symbol("native cleanup journal identity");
63
+ export const NATIVE_MANIFEST_IDENTITY = Symbol("native manifest identity");
64
+ export const STAMP = `generated by stdd v${VERSION} — do not edit, re-run \`stdd init\``;
65
+ export const NPM_RUNNER = `npm exec --offline --package=@stdd/cli@${VERSION} -- stdd`;
66
+ export const SOURCE_RUNNER = 'node "$(git rev-parse --show-toplevel)/cli/stdd.mjs"';
67
+
68
+ export function validateAdapterSelection(field, values, known, { nonEmpty = false } = {}) {
69
+ if (!Array.isArray(values)) throw new TypeError(`${field} must be an array`);
70
+ if (nonEmpty && values.length === 0) {
71
+ throw new TypeError(`${field} must be a non-empty array`);
72
+ }
73
+ if (values.some((id) => typeof id !== "string" || !known.includes(id))) {
74
+ throw new TypeError(`${field} contains an unknown adapter ID`);
75
+ }
76
+ if (new Set(values).size !== values.length) {
77
+ throw new TypeError(`${field} must not contain duplicate adapter IDs`);
78
+ }
79
+ return [...values];
80
+ }
81
+
82
+ function validateManifestTargets(value) {
83
+ const required = ["tools", "ci", "hooks", "sessionHook", "stopHook"];
84
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
85
+ throw new TypeError("must be an object");
86
+ }
87
+ const keys = Object.keys(value);
88
+ const missing = required.filter((key) => !Object.hasOwn(value, key));
89
+ const unknown = keys.filter((key) => !required.includes(key));
90
+ if (missing.length > 0 || unknown.length > 0) {
91
+ throw new TypeError(
92
+ `must contain exactly ${required.join(", ")}${
93
+ missing.length > 0 ? `; missing ${missing.join(", ")}` : ""
94
+ }${unknown.length > 0 ? `; unknown ${unknown.join(", ")}` : ""}`,
95
+ );
96
+ }
97
+ const tools = validateAdapterSelection("tools", value.tools, KNOWN_TOOLS, {
98
+ nonEmpty: true,
99
+ });
100
+ const ci = validateAdapterSelection("ci", value.ci, KNOWN_CI);
101
+ for (const field of ["hooks", "sessionHook", "stopHook"]) {
102
+ if (typeof value[field] !== "boolean") throw new TypeError(`${field} must be a boolean`);
103
+ }
104
+ return {
105
+ tools,
106
+ ci,
107
+ hooks: value.hooks,
108
+ sessionHook: value.sessionHook,
109
+ stopHook: value.stopHook,
110
+ };
111
+ }
112
+
113
+ export function loadPlaybooks() {
114
+ const dir = path.join(PKG_ROOT, "playbooks");
115
+ return fs
116
+ .readdirSync(dir)
117
+ .filter((f) => f.endsWith(".md"))
118
+ .map((f) => {
119
+ const source = fs.readFileSync(path.join(dir, f), "utf8");
120
+ const { meta, body } = parseFrontmatter(source);
121
+ if (!meta.name || !meta.description) {
122
+ fail(`playbook ${f} is missing required frontmatter (name, description)`);
123
+ }
124
+ try {
125
+ assertSkillName(meta.name, `playbook ${f} name`);
126
+ } catch (err) {
127
+ fail(err.message);
128
+ }
129
+ return { file: f, source, meta, body };
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Project recipes: repo-owned playbooks in `.stdd/playbooks/local/`, same
135
+ * frontmatter contract as the kit's. Never generated, never overwritten.
136
+ */
137
+ export function loadLocalPlaybooks(targetDir) {
138
+ let dir;
139
+ try {
140
+ dir = resolveWritableRepoPath(targetDir, ".stdd/playbooks/local", "local playbooks directory");
141
+ } catch (err) {
142
+ fail(err.message);
143
+ }
144
+ if (!fs.existsSync(dir)) return [];
145
+ const playbooks = fs
146
+ .readdirSync(dir, { withFileTypes: true })
147
+ .filter((entry) => entry.name.endsWith(".md"))
148
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
149
+ .map((entry) => {
150
+ if (!entry.isFile()) fail(`local playbook local/${entry.name} must be a regular file`);
151
+ const f = entry.name;
152
+ let filePath;
153
+ try {
154
+ filePath = resolveWritableRepoPath(
155
+ targetDir,
156
+ `.stdd/playbooks/local/${f}`,
157
+ `local playbook local/${f}`,
158
+ );
159
+ } catch (err) {
160
+ fail(err.message);
161
+ }
162
+ const source = fs.readFileSync(filePath, "utf8");
163
+ const { meta, body } = parseFrontmatter(source);
164
+ if (!meta.name || !meta.description) {
165
+ fail(`local playbook local/${f} is missing required frontmatter (name, description)`);
166
+ }
167
+ try {
168
+ assertSkillName(meta.name, `local playbook local/${f} name`);
169
+ } catch (err) {
170
+ fail(err.message);
171
+ }
172
+ return { file: f, source, meta, body };
173
+ });
174
+ const byName = new Map();
175
+ for (const playbook of playbooks) {
176
+ const previous = byName.get(playbook.meta.name);
177
+ if (previous) {
178
+ fail(
179
+ `duplicate local playbook name "${playbook.meta.name}" in .stdd/playbooks/local/${previous.file} and .stdd/playbooks/local/${playbook.file}; rename one recipe or give it a unique frontmatter name`,
180
+ );
181
+ }
182
+ byName.set(playbook.meta.name, playbook);
183
+ }
184
+ return playbooks;
185
+ }
186
+
187
+ function isRecognizedGeneratedOutput(file) {
188
+ const exact = new Set([
189
+ ".stdd/method.md",
190
+ ...Object.values(AGENT_ADAPTERS).map((adapter) => adapter.snippetFile),
191
+ ...Object.values(CI_ADAPTERS)
192
+ .map((adapter) => adapter.outputFile)
193
+ .filter((output) => output !== null),
194
+ ...[...KNOWN_MANAGED_PLAYBOOK_FILES].map((name) => `.stdd/playbooks/${name}`),
195
+ ]);
196
+ if (exact.has(file)) return true;
197
+
198
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
199
+ const prefix = `${adapter.skillRoot}/`;
200
+ if (!file.startsWith(prefix)) continue;
201
+ const parts = file.slice(prefix.length).split("/");
202
+ if (parts.length === 2 && parts[1] === "SKILL.md") {
203
+ try {
204
+ assertSkillName(parts[0], "manifest generated skill name");
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+ }
211
+
212
+ if (isPrivateGeneratedQuarantinePath(file)) return true;
213
+ if (!MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(file))) return false;
214
+ const parent = path.posix.dirname(file);
215
+ const exactParents = new Set([...exact].map((output) => path.posix.dirname(output)));
216
+ if (exactParents.has(parent)) return true;
217
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
218
+ const prefix = `${adapter.skillRoot}/`;
219
+ if (!parent.startsWith(prefix)) continue;
220
+ const skillName = parent.slice(prefix.length);
221
+ try {
222
+ assertSkillName(skillName, "manifest quarantined skill name");
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Read and validate the generated-file manifest once for every caller.
233
+ * Returns null when the manifest is absent and throws an actionable shape
234
+ * or path error for a present manifest that cannot be trusted.
235
+ */
236
+ export function readManifestDocument(targetDir) {
237
+ let manifestPath;
238
+ try {
239
+ manifestPath = resolveWritableRepoPath(targetDir, ".stdd/manifest.json", "manifest path");
240
+ } catch (err) {
241
+ throw new TypeError(`has an unsafe path: ${err.message}`);
242
+ }
243
+ if (!fs.existsSync(manifestPath)) return null;
244
+ return parseManifestDocument(targetDir, fs.readFileSync(manifestPath, "utf8"));
245
+ }
246
+
247
+ function parseManifestDocument(targetDir, text) {
248
+ let parsed;
249
+ try {
250
+ parsed = JSON.parse(text);
251
+ } catch (err) {
252
+ throw new TypeError(`is not valid JSON: ${err.message}`);
253
+ }
254
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
255
+ throw new TypeError("must contain a JSON object");
256
+ }
257
+ if (!Object.hasOwn(parsed, "generatedBy") || parsed.generatedBy !== "stdd") {
258
+ throw new TypeError('generatedBy must equal "stdd"');
259
+ }
260
+ if (!Object.hasOwn(parsed, "version")) {
261
+ throw new TypeError("version must be a semantic version");
262
+ }
263
+ assertSemanticVersion(parsed.version, "version");
264
+ if (
265
+ !Object.hasOwn(parsed, "files") ||
266
+ typeof parsed.files !== "object" ||
267
+ parsed.files === null ||
268
+ Array.isArray(parsed.files)
269
+ ) {
270
+ throw new TypeError("files must be an object");
271
+ }
272
+ const files = Object.create(null);
273
+ for (const file of Object.keys(parsed.files)) {
274
+ try {
275
+ resolveWritableRepoPath(targetDir, file, `manifest path ${JSON.stringify(file)}`);
276
+ } catch (err) {
277
+ throw new TypeError(`has an unsafe path: ${err.message}`);
278
+ }
279
+ if (!isRecognizedGeneratedOutput(file)) {
280
+ throw new TypeError(
281
+ `files[${JSON.stringify(file)}] is not a recognized STDD-generated output path`,
282
+ );
283
+ }
284
+ const hash = parsed.files[file];
285
+ if (typeof hash !== "string" || !MANIFEST_HASH_PATTERN.test(hash)) {
286
+ throw new TypeError(
287
+ `files[${JSON.stringify(file)}] must be an exact sha256:<64 lowercase hex> hash`,
288
+ );
289
+ }
290
+ files[file] = hash;
291
+ }
292
+ const normalized = { ...parsed, files };
293
+ if (Object.hasOwn(parsed, "quarantineIdentities")) {
294
+ if (
295
+ typeof parsed.quarantineIdentities !== "object" ||
296
+ parsed.quarantineIdentities === null ||
297
+ Array.isArray(parsed.quarantineIdentities)
298
+ ) {
299
+ throw new TypeError("quarantineIdentities must be an object");
300
+ }
301
+ const quarantineIdentities = Object.create(null);
302
+ for (const [relative, observation] of Object.entries(parsed.quarantineIdentities)) {
303
+ if (
304
+ !Object.hasOwn(files, relative) ||
305
+ !(
306
+ MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(relative)) ||
307
+ isPrivateGeneratedQuarantinePath(relative)
308
+ ) ||
309
+ typeof observation !== "object" ||
310
+ observation === null ||
311
+ Array.isArray(observation) ||
312
+ typeof observation.identity !== "object" ||
313
+ observation.identity === null ||
314
+ observation.identity.version !== 2 ||
315
+ typeof observation.identity.platform !== "string" ||
316
+ typeof observation.identity.volume !== "string" ||
317
+ typeof observation.identity.fileId !== "string" ||
318
+ observation.identity.kind !== "file" ||
319
+ ["owner", "permissions", "linkCount"].some(
320
+ (field) => Object.hasOwn(observation, field) && typeof observation[field] !== "string",
321
+ ) ||
322
+ (Object.hasOwn(observation, "parentObservation") &&
323
+ (typeof observation.parentObservation !== "object" ||
324
+ observation.parentObservation === null ||
325
+ observation.parentObservation.identity?.version !== 2 ||
326
+ typeof observation.parentObservation.identity?.platform !== "string" ||
327
+ typeof observation.parentObservation.identity?.volume !== "string" ||
328
+ typeof observation.parentObservation.identity?.fileId !== "string" ||
329
+ observation.parentObservation.identity?.kind !== "directory" ||
330
+ ["owner", "permissions", "linkCount"].some(
331
+ (field) =>
332
+ Object.hasOwn(observation.parentObservation, field) &&
333
+ typeof observation.parentObservation[field] !== "string",
334
+ )))
335
+ ) {
336
+ throw new TypeError(
337
+ `quarantineIdentities[${JSON.stringify(relative)}] must describe a listed portable file identity`,
338
+ );
339
+ }
340
+ quarantineIdentities[relative] = observation;
341
+ }
342
+ normalized.quarantineIdentities = quarantineIdentities;
343
+ }
344
+ if (Object.hasOwn(parsed, "targets")) {
345
+ try {
346
+ normalized.targets = validateManifestTargets(parsed.targets);
347
+ } catch (err) {
348
+ throw new TypeError(`targets ${err.message}`);
349
+ }
350
+ }
351
+ const validateRetainedJournal = (relative, field) => {
352
+ if (
353
+ typeof relative !== "string" ||
354
+ !isPrivateGeneratedQuarantinePath(relative) ||
355
+ !relative
356
+ .split("/")
357
+ .some((segment) => /^\.stdd-cleanup-journal-[0-9a-f]{32}\.tmp$/.test(segment)) ||
358
+ !Object.hasOwn(files, relative)
359
+ ) {
360
+ throw new TypeError(`${field} must name a listed retained cleanup-journal quarantine`);
361
+ }
362
+ return relative;
363
+ };
364
+ if (Object.hasOwn(parsed, "retainedCleanupJournal")) {
365
+ normalized.retainedCleanupJournal = validateRetainedJournal(
366
+ parsed.retainedCleanupJournal,
367
+ "retainedCleanupJournal",
368
+ );
369
+ }
370
+ if (Object.hasOwn(parsed, "retainedCleanupJournals")) {
371
+ if (
372
+ !Array.isArray(parsed.retainedCleanupJournals) ||
373
+ new Set(parsed.retainedCleanupJournals).size !== parsed.retainedCleanupJournals.length
374
+ ) {
375
+ throw new TypeError("retainedCleanupJournals must be an array without duplicates");
376
+ }
377
+ normalized.retainedCleanupJournals = parsed.retainedCleanupJournals.map((relative, index) =>
378
+ validateRetainedJournal(relative, `retainedCleanupJournals[${index}]`),
379
+ );
380
+ }
381
+ return normalized;
382
+ }
383
+
384
+ /** Files recorded by the previous init, or an empty map when none exists. */
385
+ export function readManifestFiles(targetDir) {
386
+ try {
387
+ return readManifestDocument(targetDir)?.files ?? Object.create(null);
388
+ } catch (err) {
389
+ fail(`.stdd/manifest.json ${err.message}`);
390
+ }
391
+ }
392
+
393
+ function readCleanupJournal(targetDir) {
394
+ const journalPath = resolveRepoPath(targetDir, CLEANUP_JOURNAL_REL, "cleanup journal path");
395
+ const parentPath = path.dirname(journalPath);
396
+ let before;
397
+ try {
398
+ before = fs.lstatSync(journalPath);
399
+ } catch (err) {
400
+ if (err.code === "ENOENT") return null;
401
+ throw err;
402
+ }
403
+ let parentDescriptor = null;
404
+ let journalDescriptor = null;
405
+ let text;
406
+ try {
407
+ const parentBefore = fs.lstatSync(parentPath);
408
+ if (parentBefore.isSymbolicLink() || !parentBefore.isDirectory()) {
409
+ throw new Error("cleanup journal parent must be a non-symlinked directory");
410
+ }
411
+ parentDescriptor = fs.openSync(
412
+ parentPath,
413
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
414
+ );
415
+ const parentOpened = fs.fstatSync(parentDescriptor);
416
+ if (!sameFileIdentity(parentBefore, parentOpened)) {
417
+ throw new Error("cleanup journal parent changed before it could be opened");
418
+ }
419
+ const descriptorPath = journalPath;
420
+ journalDescriptor = fs.openSync(descriptorPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
421
+ const opened = fs.fstatSync(journalDescriptor);
422
+ const currentUid = typeof process.getuid === "function" ? process.getuid() : null;
423
+ const safeShape = (stat) =>
424
+ stat.isFile() &&
425
+ !stat.isSymbolicLink() &&
426
+ (stat.mode & 0o777) === 0o600 &&
427
+ stat.nlink === 1 &&
428
+ (currentUid === null || stat.uid === currentUid);
429
+ if (!safeShape(before) || !safeShape(opened) || !sameFileIdentity(before, opened)) {
430
+ throw new Error(
431
+ "cleanup journal must be one unchanged regular file owned by the current user, single-linked, and exactly mode 0600",
432
+ );
433
+ }
434
+ text = fs.readFileSync(journalDescriptor, "utf8");
435
+ const openedAfter = fs.fstatSync(journalDescriptor);
436
+ const after = fs.lstatSync(journalPath);
437
+ const parentAfter = fs.lstatSync(parentPath);
438
+ if (
439
+ !safeShape(openedAfter) ||
440
+ !safeShape(after) ||
441
+ !sameFileIdentity(opened, openedAfter) ||
442
+ !sameFileIdentity(opened, after) ||
443
+ !sameFileIdentity(parentOpened, parentAfter)
444
+ ) {
445
+ throw new Error("cleanup journal or its parent changed while it was read");
446
+ }
447
+ } finally {
448
+ if (journalDescriptor !== null) {
449
+ try {
450
+ fs.closeSync(journalDescriptor);
451
+ } catch {}
452
+ }
453
+ if (parentDescriptor !== null) {
454
+ try {
455
+ fs.closeSync(parentDescriptor);
456
+ } catch {}
457
+ }
458
+ }
459
+ return validateNativeCleanupJournal(targetDir, JSON.parse(text));
460
+ }
461
+
462
+ const MANIFEST_QUARANTINE_BASENAME = /^\.stdd-cleanup-(?:journal-)?[0-9a-f]{32}\.tmp$/;
463
+ const PRIVATE_GENERATED_QUARANTINE_ROOT = ".stdd/generated-quarantines";
464
+
465
+ function privateQuarantineContainer(relative) {
466
+ const prefix = `${PRIVATE_GENERATED_QUARANTINE_ROOT}/`;
467
+ if (!relative.startsWith(prefix)) return null;
468
+ const remainder = relative.slice(prefix.length);
469
+ const separator = remainder.indexOf("/");
470
+ const container = separator === -1 ? remainder : remainder.slice(0, separator);
471
+ return MANIFEST_QUARANTINE_BASENAME.test(container) ? container : null;
472
+ }
473
+
474
+ function isPrivateGeneratedQuarantinePath(relative) {
475
+ const container = privateQuarantineContainer(relative);
476
+ if (!container) return false;
477
+ const suffix = relative.slice(`${PRIVATE_GENERATED_QUARANTINE_ROOT}/${container}/`.length);
478
+ return suffix.length > 0 && path.posix.basename(suffix) === suffix;
479
+ }
480
+
481
+ function newPrivateCleanupQuarantine(fileName, journal = false) {
482
+ const container = `.stdd-cleanup-${journal ? "journal-" : ""}${randomBytes(16).toString("hex")}.tmp`;
483
+ return `${PRIVATE_GENERATED_QUARANTINE_ROOT}/${container}/${fileName}`;
484
+ }
485
+
486
+ /**
487
+ * Read one manifest-tracked output without following a symlink or trusting a
488
+ * check-then-read path race. Callers receive a finding, never a filesystem
489
+ * exception. `missing` remains distinct so check keeps its established hint.
490
+ */
491
+ function inspectManifestOutput(targetDir, file) {
492
+ let filePath;
493
+ try {
494
+ filePath = resolveWritableRepoPath(targetDir, file, `manifest path ${JSON.stringify(file)}`);
495
+ } catch (err) {
496
+ return { kind: "invalid", reason: err.message };
497
+ }
498
+
499
+ let before;
500
+ try {
501
+ before = fs.lstatSync(filePath);
502
+ } catch (err) {
503
+ if (err.code === "ENOENT") return { kind: "missing" };
504
+ return { kind: "invalid", reason: `metadata could not be read safely: ${err.message}` };
505
+ }
506
+ if (before.isSymbolicLink()) {
507
+ return { kind: "invalid", reason: "is a symlink, not a regular file" };
508
+ }
509
+ if (!before.isFile()) {
510
+ return {
511
+ kind: "invalid",
512
+ reason: before.isDirectory() ? "is a directory, not a regular file" : "is a non-regular file",
513
+ };
514
+ }
515
+
516
+ let descriptor = null;
517
+ let result;
518
+ try {
519
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
520
+ const opened = fs.fstatSync(descriptor);
521
+ if (!opened.isFile()) {
522
+ result = {
523
+ kind: "invalid",
524
+ reason: "was replaced during inspection with a non-regular file",
525
+ };
526
+ } else {
527
+ const bytes = fs.readFileSync(descriptor);
528
+ const after = fs.lstatSync(filePath);
529
+ // Re-run the path walk after reading: a parent directory may have
530
+ // become a symlink after the initial resolution.
531
+ resolveWritableRepoPath(targetDir, file, `manifest path ${JSON.stringify(file)}`);
532
+ const realRoot = fs.realpathSync(targetDir);
533
+ const realFile = fs.realpathSync(filePath);
534
+ const fromRoot = path.relative(realRoot, realFile);
535
+ const escaped =
536
+ fromRoot === ".." || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot);
537
+ const finalStat = fs.lstatSync(filePath);
538
+ if (
539
+ escaped ||
540
+ after.isSymbolicLink() ||
541
+ !after.isFile() ||
542
+ !sameFileIdentity(opened, after) ||
543
+ finalStat.isSymbolicLink() ||
544
+ !finalStat.isFile() ||
545
+ !sameFileIdentity(opened, finalStat)
546
+ ) {
547
+ result = {
548
+ kind: "invalid",
549
+ reason: escaped
550
+ ? "resolved outside the repository during inspection"
551
+ : "was replaced during inspection",
552
+ };
553
+ } else {
554
+ result = {
555
+ kind: "ok",
556
+ filePath,
557
+ bytes,
558
+ identity: opened,
559
+ realParentPath: path.dirname(realFile),
560
+ };
561
+ }
562
+ }
563
+ } catch (err) {
564
+ result = {
565
+ kind: "invalid",
566
+ reason:
567
+ err.code === "ENOENT"
568
+ ? "was replaced or removed during inspection"
569
+ : `could not be read safely: ${err.message}`,
570
+ };
571
+ } finally {
572
+ if (descriptor !== null) {
573
+ try {
574
+ fs.closeSync(descriptor);
575
+ } catch (err) {
576
+ result = {
577
+ kind: "invalid",
578
+ reason: `could not be closed safely after reading: ${err.message}`,
579
+ };
580
+ }
581
+ }
582
+ }
583
+ return result;
584
+ }
585
+
586
+ /**
587
+ * Quarantine and remove a verified output through a held parent directory.
588
+ * Linux procfs gives pathname operations a stable dirfd-like anchor. Other
589
+ * runtimes fail closed and preserve the output for manual cleanup.
590
+ */
591
+ function nativeIdentityMatches(identity, portable, legacyDevice, legacyFile) {
592
+ if (portable) {
593
+ return (
594
+ identity.version === portable.version &&
595
+ identity.platform === portable.platform &&
596
+ identity.volume === portable.volume &&
597
+ identity.fileId === portable.fileId &&
598
+ identity.kind === portable.kind
599
+ );
600
+ }
601
+ return (
602
+ identity.platform === "linux" && identity.volume === legacyDevice && identity.fileId === legacyFile
603
+ );
604
+ }
605
+
606
+ function recordedObservationMatches(current, recorded, kind) {
607
+ if (!recorded || !nativeIdentityMatches(current.identity, recorded.identity, null, null)) {
608
+ return false;
609
+ }
610
+ if (current.identity.kind !== kind) return false;
611
+ return ["owner", "permissions", "linkCount"].every(
612
+ (field) => !Object.hasOwn(recorded, field) || current[field] === recorded[field],
613
+ );
614
+ }
615
+
616
+ function nativeOwnerMatchesRoot(context, observation) {
617
+ return (
618
+ observation.identity.platform === "win32" || observation.owner === context.root.observation.owner
619
+ );
620
+ }
621
+
622
+ function quarantineObservation(file, parent) {
623
+ return { ...file, parentObservation: parent };
624
+ }
625
+
626
+ function stableQuarantineObservation(file, parent) {
627
+ const stable = ({ identity, owner, permissions, linkCount }) => ({
628
+ identity,
629
+ owner,
630
+ permissions,
631
+ linkCount,
632
+ });
633
+ return { ...stable(file), parentObservation: stable(parent) };
634
+ }
635
+
636
+ async function nativeOpenParent(context, relative, label) {
637
+ const parentRelative = path.posix.dirname(relative);
638
+ try {
639
+ return await openNativeRepoPath(context, parentRelative, label);
640
+ } catch (error) {
641
+ if (error.cause?.code === "not-found" || error.code === "not-found") return null;
642
+ throw error;
643
+ }
644
+ }
645
+
646
+ async function nativeInspectOutput(context, relative, expected = null) {
647
+ resolveRepoPath(context.rootPath, relative, `generated path ${JSON.stringify(relative)}`);
648
+ const parent = await nativeOpenParent(context, relative, `generated parent for ${relative}`);
649
+ if (!parent) return { kind: "missing" };
650
+ let file;
651
+ try {
652
+ file = await context.session.openChild(parent.cap, path.posix.basename(relative));
653
+ } catch (error) {
654
+ if (error.code === "not-found") return { kind: "missing", parent };
655
+ return {
656
+ kind: "invalid",
657
+ parent,
658
+ reason: error.code === "symlink-rejected" ? `unsafe symlink: ${error.message}` : error.message,
659
+ };
660
+ }
661
+ if (file.observation.identity.kind !== "file") {
662
+ return { kind: "invalid", parent, reason: "is not a non-symlinked regular file" };
663
+ }
664
+ try {
665
+ if (
666
+ file.observation.identity.platform === "win32" &&
667
+ file.observation.owner !== context.root.observation.owner
668
+ ) {
669
+ await context.session.verifyPrivate(file.cap);
670
+ }
671
+ const bytes = await readNativeFile(context, file);
672
+ if (
673
+ expected &&
674
+ !nativeIdentityMatches(
675
+ file.observation.identity,
676
+ expected.fileIdentity,
677
+ expected.fileDev,
678
+ expected.fileIno,
679
+ )
680
+ ) {
681
+ return { kind: "other", parent, file, bytes };
682
+ }
683
+ return { kind: "ok", parent, file, bytes };
684
+ } catch (error) {
685
+ return { kind: "invalid", parent, file, reason: error.message };
686
+ }
687
+ }
688
+
689
+ export async function readManifestDocumentWithCapabilities(context) {
690
+ const inspected = await nativeInspectOutput(context, ".stdd/manifest.json");
691
+ if (inspected.kind === "missing") return null;
692
+ if (inspected.kind !== "ok") {
693
+ throw new TypeError(`cannot be read safely: ${inspected.reason}`);
694
+ }
695
+ const observation = inspected.file.observation;
696
+ if (observation.linkCount !== "1" || !nativeOwnerMatchesRoot(context, observation)) {
697
+ throw new TypeError("must be single-linked and owned by the repository owner");
698
+ }
699
+ if (
700
+ observation.identity.platform !== "win32" &&
701
+ ![0o600, 0o644].includes(Number(observation.permissions) & 0o777)
702
+ ) {
703
+ throw new TypeError("must be mode 0600 or canonical VCS checkout mode 0644");
704
+ }
705
+ const manifest = parseManifestDocument(context.rootPath, inspected.bytes.toString("utf8"));
706
+ Object.defineProperty(manifest, NATIVE_MANIFEST_IDENTITY, {
707
+ value: observation.identity,
708
+ });
709
+ return manifest;
710
+ }
711
+
712
+ function validateNativeCleanupJournal(targetDir, parsed) {
713
+ if (
714
+ typeof parsed !== "object" ||
715
+ parsed === null ||
716
+ Array.isArray(parsed) ||
717
+ parsed.version !== 1 ||
718
+ !Array.isArray(parsed.entries) ||
719
+ parsed.entries.length === 0
720
+ ) {
721
+ throw new Error("cleanup journal has an invalid schema");
722
+ }
723
+ for (const entry of parsed.entries) {
724
+ if (
725
+ typeof entry !== "object" ||
726
+ entry === null ||
727
+ !["planned", "quarantined", "unresolved"].includes(entry.phase) ||
728
+ typeof entry.source !== "string" ||
729
+ typeof entry.quarantine !== "string" ||
730
+ typeof entry.parentDev !== "string" ||
731
+ typeof entry.parentIno !== "string" ||
732
+ typeof entry.fileDev !== "string" ||
733
+ typeof entry.fileIno !== "string" ||
734
+ typeof entry.keepSource !== "boolean" ||
735
+ typeof entry.reason !== "string" ||
736
+ entry.source === entry.quarantine ||
737
+ !MANIFEST_HASH_PATTERN.test(entry.hash)
738
+ ) {
739
+ throw new Error("cleanup journal has an invalid entry");
740
+ }
741
+ for (const [field, kind] of [
742
+ ["parentIdentity", "directory"],
743
+ ["fileIdentity", "file"],
744
+ ["quarantineParentIdentity", "directory"],
745
+ ]) {
746
+ if (!Object.hasOwn(entry, field)) continue;
747
+ const identity = entry[field];
748
+ if (
749
+ typeof identity !== "object" ||
750
+ identity === null ||
751
+ identity.version !== 2 ||
752
+ typeof identity.platform !== "string" ||
753
+ typeof identity.volume !== "string" ||
754
+ typeof identity.fileId !== "string" ||
755
+ identity.kind !== kind
756
+ ) {
757
+ throw new Error("cleanup journal has an invalid portable identity");
758
+ }
759
+ }
760
+ const source = resolveRepoPath(targetDir, entry.source, "cleanup journal source");
761
+ const quarantine = resolveRepoPath(targetDir, entry.quarantine, "cleanup journal quarantine");
762
+ if (
763
+ path.dirname(source) !== path.dirname(quarantine) &&
764
+ !Object.hasOwn(entry, "quarantineParentIdentity") &&
765
+ entry.phase !== "planned"
766
+ ) {
767
+ throw new Error("cleanup journal entry paths must share one parent");
768
+ }
769
+ if (
770
+ !MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(entry.quarantine)) &&
771
+ !isPrivateGeneratedQuarantinePath(entry.quarantine)
772
+ ) {
773
+ throw new Error("cleanup journal has an invalid quarantine name");
774
+ }
775
+ }
776
+ return parsed;
777
+ }
778
+
779
+ async function readCleanupJournalNative(context) {
780
+ const inspected = await nativeInspectOutput(context, CLEANUP_JOURNAL_REL);
781
+ if (inspected.kind === "missing") return null;
782
+ if (inspected.kind !== "ok") {
783
+ throw new Error(`cleanup journal could not be read safely: ${inspected.reason}`);
784
+ }
785
+ const observation = inspected.file.observation;
786
+ if (observation.linkCount !== "1" || !nativeOwnerMatchesRoot(context, observation)) {
787
+ throw new Error("cleanup journal must be one unchanged regular file owned by the current user");
788
+ }
789
+ if (observation.identity.platform !== "win32" && (Number(observation.permissions) & 0o777) !== 0o600) {
790
+ throw new Error("cleanup journal must be exactly mode 0600");
791
+ }
792
+ let parsed;
793
+ try {
794
+ parsed = JSON.parse(inspected.bytes.toString("utf8"));
795
+ } catch (error) {
796
+ throw new Error(`cleanup journal is not valid JSON: ${error.message}`);
797
+ }
798
+ const validated = validateNativeCleanupJournal(context.rootPath, parsed);
799
+ Object.defineProperty(validated, NATIVE_JOURNAL_IDENTITY, {
800
+ value: observation.identity,
801
+ writable: true,
802
+ });
803
+ return validated;
804
+ }
805
+
806
+ async function writeCleanupJournalNative(context, journal) {
807
+ const published = await publishNativeRepoFile(
808
+ context,
809
+ CLEANUP_JOURNAL_REL,
810
+ `${JSON.stringify(journal, null, "\t")}\n`,
811
+ {
812
+ mode: 0o600,
813
+ tempPrefix: ".cleanup-journal-",
814
+ directoryMode: 0o700,
815
+ expectedTarget: journal[NATIVE_JOURNAL_IDENTITY] ?? null,
816
+ },
817
+ );
818
+ if (Object.hasOwn(journal, NATIVE_JOURNAL_IDENTITY)) {
819
+ journal[NATIVE_JOURNAL_IDENTITY] = published.observation.identity;
820
+ } else {
821
+ Object.defineProperty(journal, NATIVE_JOURNAL_IDENTITY, {
822
+ value: published.observation.identity,
823
+ writable: true,
824
+ });
825
+ }
826
+ }
827
+
828
+ async function inspectCleanupEntryStateNative(context, entry) {
829
+ try {
830
+ const source = await nativeInspectOutput(context, entry.source, entry);
831
+ const quarantine = await nativeInspectOutput(context, entry.quarantine, entry);
832
+ const sourceParent = source.parent;
833
+ const quarantineParent = quarantine.parent;
834
+ if (
835
+ !sourceParent ||
836
+ !nativeIdentityMatches(
837
+ sourceParent.observation.identity,
838
+ entry.parentIdentity,
839
+ entry.parentDev,
840
+ entry.parentIno,
841
+ )
842
+ ) {
843
+ throw new Error("logical parent no longer maps to the journaled directory identity");
844
+ }
845
+ if (
846
+ entry.quarantineParentIdentity &&
847
+ (!quarantineParent ||
848
+ !nativeIdentityMatches(
849
+ quarantineParent.observation.identity,
850
+ entry.quarantineParentIdentity,
851
+ entry.quarantineParentIdentity.volume,
852
+ entry.quarantineParentIdentity.fileId,
853
+ ))
854
+ ) {
855
+ throw new Error("quarantine parent no longer maps to the journaled directory identity");
856
+ }
857
+ const exact = (candidate) =>
858
+ candidate.kind === "ok" &&
859
+ nativeIdentityMatches(
860
+ candidate.file.observation.identity,
861
+ entry.fileIdentity,
862
+ entry.fileDev,
863
+ entry.fileIno,
864
+ ) &&
865
+ sha256(candidate.bytes) === entry.hash;
866
+ if (exact(quarantine)) {
867
+ return {
868
+ kind: "quarantined",
869
+ keepSource: source.kind !== "missing",
870
+ sourceParent,
871
+ quarantineParent,
872
+ quarantine,
873
+ };
874
+ }
875
+ if (exact(source) && quarantine.kind === "missing") {
876
+ return { kind: "restored", sourceParent, quarantineParent, source };
877
+ }
878
+ throw new Error(
879
+ `cannot prove the journaled file identity at either path (source=${source.kind}, quarantine=${quarantine.kind})`,
880
+ );
881
+ } catch (error) {
882
+ return { kind: "unresolved", reason: error.message };
883
+ }
884
+ }
885
+
886
+ async function rollbackCleanupJournalNative(context, journal) {
887
+ const unresolved = [];
888
+ for (const entry of [...journal.entries].reverse()) {
889
+ const state = await inspectCleanupEntryStateNative(context, entry);
890
+ if (state.kind === "restored") continue;
891
+ if (state.kind !== "quarantined" || state.keepSource) {
892
+ entry.phase = "unresolved";
893
+ entry.reason =
894
+ state.kind === "unresolved"
895
+ ? state.reason
896
+ : "rollback found both the source and quarantine occupied";
897
+ unresolved.push(`${entry.source}: ${entry.reason}`);
898
+ continue;
899
+ }
900
+ try {
901
+ await context.session.rename({
902
+ fromParent: state.quarantineParent.cap,
903
+ from: path.posix.basename(entry.quarantine),
904
+ expected: entry.fileIdentity ?? state.quarantine.file.observation.identity,
905
+ toParent: state.sourceParent.cap,
906
+ to: path.posix.basename(entry.source),
907
+ replace: "never",
908
+ });
909
+ await context.session.flush(
910
+ state.sourceParent.cap,
911
+ "namespace",
912
+ state.sourceParent.observation.identity,
913
+ );
914
+ if (state.quarantineParent.cap !== state.sourceParent.cap) {
915
+ await context.session.flush(
916
+ state.quarantineParent.cap,
917
+ "namespace",
918
+ state.quarantineParent.observation.identity,
919
+ );
920
+ }
921
+ await verifyNativeRepoDirectory(
922
+ context,
923
+ path.posix.dirname(entry.source),
924
+ state.sourceParent.observation.identity,
925
+ "cleanup rollback parent",
926
+ );
927
+ if (entry.quarantineParentIdentity) {
928
+ await verifyNativeRepoDirectory(
929
+ context,
930
+ path.posix.dirname(entry.quarantine),
931
+ state.quarantineParent.observation.identity,
932
+ "cleanup rollback quarantine",
933
+ );
934
+ }
935
+ const restored = await inspectCleanupEntryStateNative(context, entry);
936
+ if (restored.kind !== "restored") throw new Error(restored.reason ?? restored.kind);
937
+ } catch (error) {
938
+ entry.phase = "unresolved";
939
+ entry.reason = `capability-bound rollback failed: ${error.message}`;
940
+ unresolved.push(`${entry.source}: ${entry.reason}`);
941
+ }
942
+ }
943
+ if (unresolved.length > 0) await writeCleanupJournalNative(context, journal).catch(() => {});
944
+ return unresolved;
945
+ }
946
+
947
+ async function clearCleanupJournalNative(context, retainedRelative) {
948
+ const inspected = await nativeInspectOutput(context, CLEANUP_JOURNAL_REL);
949
+ if (inspected.kind === "missing") return null;
950
+ if (inspected.kind !== "ok")
951
+ throw new Error(`cleanup journal changed before clear: ${inspected.reason}`);
952
+ const retainedParent = await openOrCreateNativeRepoDirectory(
953
+ context,
954
+ path.posix.dirname(retainedRelative),
955
+ { mode: 0o700, label: "retained cleanup journal parent" },
956
+ );
957
+ if (
958
+ retainedParent.observation.identity.platform !== "win32" &&
959
+ (Number(retainedParent.observation.permissions) & 0o777) !== 0o700
960
+ ) {
961
+ throw new Error("retained cleanup journal parent must be owner-private mode 0700");
962
+ }
963
+ if (!nativeOwnerMatchesRoot(context, retainedParent.observation)) {
964
+ throw new Error("retained cleanup journal parent must be owned by the repository owner");
965
+ }
966
+ try {
967
+ await context.session.openChild(retainedParent.cap, path.posix.basename(retainedRelative));
968
+ throw new Error("retained cleanup journal basename is already occupied");
969
+ } catch (error) {
970
+ if (error?.code !== "not-found") throw error;
971
+ }
972
+ const zeroes = Buffer.alloc(inspected.bytes.length);
973
+ let journalMutated = false;
974
+ try {
975
+ journalMutated = true;
976
+ await writeNativeFileContent(context, inspected.file, zeroes);
977
+ await context.session.truncate(inspected.file.cap, 0, inspected.file.observation.identity);
978
+ await context.session.flush(inspected.file.cap, "all", inspected.file.observation.identity);
979
+ await context.session.rename({
980
+ fromParent: inspected.parent.cap,
981
+ from: path.posix.basename(CLEANUP_JOURNAL_REL),
982
+ expected: inspected.file.observation.identity,
983
+ toParent: retainedParent.cap,
984
+ to: path.posix.basename(retainedRelative),
985
+ replace: "never",
986
+ });
987
+ await context.session.flush(retainedParent.cap, "namespace", retainedParent.observation.identity);
988
+ await context.session.flush(
989
+ inspected.parent.cap,
990
+ "namespace",
991
+ inspected.parent.observation.identity,
992
+ );
993
+ await verifyNativeRepoDirectory(
994
+ context,
995
+ path.posix.dirname(retainedRelative),
996
+ retainedParent.observation.identity,
997
+ "retained cleanup journal parent",
998
+ );
999
+ await verifyNativeRepoDirectory(
1000
+ context,
1001
+ path.posix.dirname(CLEANUP_JOURNAL_REL),
1002
+ inspected.parent.observation.identity,
1003
+ "cleanup journal source parent",
1004
+ );
1005
+ } catch (error) {
1006
+ if (journalMutated) error.mutation = "committed";
1007
+ throw error;
1008
+ }
1009
+ return retainedRelative;
1010
+ }
1011
+
1012
+ function cleanupJournalCommittedNative(manifest, journal) {
1013
+ if (!manifest) return false;
1014
+ return journal.entries.every(
1015
+ (entry) =>
1016
+ entry.phase === "quarantined" &&
1017
+ Object.hasOwn(manifest.files, entry.quarantine) &&
1018
+ manifest.files[entry.quarantine] === entry.hash &&
1019
+ (entry.keepSource
1020
+ ? manifest.files[entry.source] === entry.hash
1021
+ : !Object.hasOwn(manifest.files, entry.source)),
1022
+ );
1023
+ }
1024
+
1025
+ export async function recoverCleanupJournalWithCapabilities(context) {
1026
+ let journal;
1027
+ try {
1028
+ journal = await readCleanupJournalNative(context);
1029
+ } catch (error) {
1030
+ const manifest = await readManifestDocumentWithCapabilities(context);
1031
+ const active = await nativeInspectOutput(context, CLEANUP_JOURNAL_REL);
1032
+ const retained = [
1033
+ ...(manifest?.retainedCleanupJournals ?? []),
1034
+ ...(manifest?.retainedCleanupJournal ? [manifest.retainedCleanupJournal] : []),
1035
+ ]
1036
+ .reverse()
1037
+ .find((relative) => {
1038
+ const recorded = manifest?.quarantineIdentities?.[relative];
1039
+ const expected = recorded?.identity ?? recorded;
1040
+ return (
1041
+ manifest?.files?.[relative] === sha256(Buffer.alloc(0)) &&
1042
+ active.kind === "ok" &&
1043
+ expected &&
1044
+ nativeIdentityMatches(
1045
+ active.file.observation.identity,
1046
+ expected,
1047
+ expected.volume,
1048
+ expected.fileId,
1049
+ )
1050
+ );
1051
+ });
1052
+ if (!retained) throw error;
1053
+ await clearCleanupJournalNative(context, retained);
1054
+ return [];
1055
+ }
1056
+ if (!journal) return [];
1057
+ const manifest = await readManifestDocumentWithCapabilities(context);
1058
+ if (cleanupJournalCommittedNative(manifest, journal)) {
1059
+ const states = await Promise.all(
1060
+ journal.entries.map((entry) => inspectCleanupEntryStateNative(context, entry)),
1061
+ );
1062
+ const invalid = states
1063
+ .map((state, index) => ({ state, entry: journal.entries[index] }))
1064
+ .filter(
1065
+ ({ state, entry }) => state.kind !== "quarantined" || state.keepSource !== entry.keepSource,
1066
+ );
1067
+ if (invalid.length > 0) {
1068
+ throw new Error(
1069
+ `${CLEANUP_JOURNAL_REL} has unresolved cleanup state — ${invalid
1070
+ .map(({ state, entry }) => `${entry.source}: ${state.reason ?? state.kind}`)
1071
+ .join("; ")}`,
1072
+ );
1073
+ }
1074
+ const retained =
1075
+ manifest.retainedCleanupJournals?.at(-1) ??
1076
+ manifest.retainedCleanupJournal ??
1077
+ newPrivateCleanupQuarantine("cleanup-transaction.tombstone", true);
1078
+ await clearCleanupJournalNative(context, retained);
1079
+ return [];
1080
+ }
1081
+ const unresolved = await rollbackCleanupJournalNative(context, journal);
1082
+ if (unresolved.length > 0) {
1083
+ throw new Error(`${CLEANUP_JOURNAL_REL} has unresolved cleanup state — ${unresolved.join("; ")}`);
1084
+ }
1085
+ // A rolled-back WAL is still retired rather than pathname-deleted. It is
1086
+ // not broadly discoverable; the next successful manifest publication
1087
+ // records this exact retained basename.
1088
+ const retained = newPrivateCleanupQuarantine("cleanup-transaction.tombstone", true);
1089
+ await clearCleanupJournalNative(context, retained);
1090
+ return [retained];
1091
+ }
1092
+
1093
+ /**
1094
+ * Capability-native generated retirement and manifest publication. This is
1095
+ * the async API shared by init/configure and the other mutation subsystems.
1096
+ */
1097
+ export async function finalizeGeneratedFilesWithCapabilities(
1098
+ context,
1099
+ {
1100
+ oldFiles,
1101
+ oldQuarantineIdentities = Object.create(null),
1102
+ initialQuarantineIdentities = Object.create(null),
1103
+ generated,
1104
+ targets,
1105
+ retainedCleanupJournals = [],
1106
+ legacyRetainedCleanupJournal = null,
1107
+ expectedManifestIdentity = null,
1108
+ retireOnlyFiles = [],
1109
+ },
1110
+ ) {
1111
+ const journal = { version: 1, entries: [] };
1112
+ const retireOnly = new Set(retireOnlyFiles);
1113
+ const quarantineIdentities = Object.assign(Object.create(null), initialQuarantineIdentities);
1114
+ let manifestRenamePossible = false;
1115
+ let manifestDurable = false;
1116
+ try {
1117
+ for (const [file, hash] of Object.entries(oldFiles)) {
1118
+ if (Object.hasOwn(generated, file)) continue;
1119
+ const inspected = await nativeInspectOutput(context, file);
1120
+ if (inspected.kind === "missing") continue;
1121
+ if (inspected.kind !== "ok") {
1122
+ if (retireOnly.has(file)) {
1123
+ throw new Error(
1124
+ `Preserved user-owned ${file}; it could not be safely revalidated for WAL-backed retirement (${inspected.reason})`,
1125
+ );
1126
+ }
1127
+ generated[file] = hash;
1128
+ console.error(
1129
+ `Preserved unsafe formerly generated output ${file}; it cannot be safely inspected (${inspected.reason}) and remains manifest-tracked until removed or relocated`,
1130
+ );
1131
+ continue;
1132
+ }
1133
+ if (
1134
+ (MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(file)) ||
1135
+ isPrivateGeneratedQuarantinePath(file)) &&
1136
+ sha256(inspected.bytes) === hash
1137
+ ) {
1138
+ const quarantineModeSafe =
1139
+ inspected.parent.observation.identity.platform === "win32" ||
1140
+ (Number(inspected.parent.observation.permissions) & 0o777) === 0o700;
1141
+ if (
1142
+ !quarantineModeSafe ||
1143
+ !nativeOwnerMatchesRoot(context, inspected.parent.observation) ||
1144
+ !nativeOwnerMatchesRoot(context, inspected.file.observation) ||
1145
+ inspected.file.observation.linkCount !== "1"
1146
+ ) {
1147
+ generated[file] = hash;
1148
+ quarantineIdentities[file] =
1149
+ oldQuarantineIdentities[file] ??
1150
+ quarantineObservation(inspected.file.observation, inspected.parent.observation);
1151
+ console.error(
1152
+ `Preserved unsafe retained quarantine ${file}; its owner, mode, or link count no longer matches the durable quarantine contract`,
1153
+ );
1154
+ continue;
1155
+ }
1156
+ const expected = oldQuarantineIdentities[file] ?? null;
1157
+ if (
1158
+ expected &&
1159
+ (!recordedObservationMatches(inspected.file.observation, expected, "file") ||
1160
+ (expected.parentObservation &&
1161
+ !recordedObservationMatches(
1162
+ inspected.parent.observation,
1163
+ expected.parentObservation,
1164
+ "directory",
1165
+ )))
1166
+ ) {
1167
+ generated[file] = hash;
1168
+ quarantineIdentities[file] = oldQuarantineIdentities[file];
1169
+ console.error(
1170
+ `Preserved replaced retained quarantine ${file}; its bytes match but durable parent/file metadata does not`,
1171
+ );
1172
+ continue;
1173
+ }
1174
+ generated[file] = hash;
1175
+ quarantineIdentities[file] =
1176
+ expected ?? quarantineObservation(inspected.file.observation, inspected.parent.observation);
1177
+ continue;
1178
+ }
1179
+ if (sha256(inspected.bytes) !== hash) {
1180
+ if (retireOnly.has(file)) {
1181
+ throw new Error(`Preserved user-owned ${file}; it changed before WAL-backed retirement`);
1182
+ }
1183
+ generated[file] = hash;
1184
+ console.error(
1185
+ `Preserved modified formerly generated file ${file}; it remains manifest-tracked and stale until removed or relocated`,
1186
+ );
1187
+ continue;
1188
+ }
1189
+ if (
1190
+ !nativeOwnerMatchesRoot(context, inspected.file.observation) ||
1191
+ inspected.file.observation.linkCount !== "1"
1192
+ ) {
1193
+ if (retireOnly.has(file)) {
1194
+ throw new Error(
1195
+ `Preserved user-owned ${file}; owner or link count is not trusted for WAL-backed retirement`,
1196
+ );
1197
+ }
1198
+ generated[file] = hash;
1199
+ console.error(
1200
+ `Preserved unsafe formerly generated output ${file}; owner or link count is not trusted and it remains manifest-tracked until removed or relocated`,
1201
+ );
1202
+ continue;
1203
+ }
1204
+ const quarantine = newPrivateCleanupQuarantine(path.posix.basename(file));
1205
+ const parentIdentity = inspected.parent.observation.identity;
1206
+ const fileIdentity = inspected.file.observation.identity;
1207
+ const entry = {
1208
+ source: file,
1209
+ quarantine,
1210
+ hash,
1211
+ parentDev: parentIdentity.volume,
1212
+ parentIno: parentIdentity.fileId,
1213
+ fileDev: fileIdentity.volume,
1214
+ fileIno: fileIdentity.fileId,
1215
+ phase: "planned",
1216
+ keepSource: false,
1217
+ reason: "",
1218
+ parentIdentity,
1219
+ fileIdentity,
1220
+ parentObservation: inspected.parent.observation,
1221
+ fileObservation: inspected.file.observation,
1222
+ };
1223
+ journal.entries.push(entry);
1224
+ await writeCleanupJournalNative(context, journal);
1225
+ const quarantineParent = await openOrCreateNativeRepoDirectory(
1226
+ context,
1227
+ path.posix.dirname(quarantine),
1228
+ { mode: 0o700, label: "generated cleanup quarantine" },
1229
+ );
1230
+ if (
1231
+ quarantineParent.observation.identity.platform !== "win32" &&
1232
+ (Number(quarantineParent.observation.permissions) & 0o777) !== 0o700
1233
+ ) {
1234
+ throw new Error("generated cleanup quarantine must be owner-private mode 0700");
1235
+ }
1236
+ if (!nativeOwnerMatchesRoot(context, quarantineParent.observation)) {
1237
+ throw new Error("generated cleanup quarantine must be owned by the repository owner");
1238
+ }
1239
+ entry.quarantineParentIdentity = quarantineParent.observation.identity;
1240
+ entry.quarantineParentObservation = quarantineParent.observation;
1241
+ await writeCleanupJournalNative(context, journal);
1242
+ try {
1243
+ await context.session.rename({
1244
+ fromParent: inspected.parent.cap,
1245
+ from: path.posix.basename(file),
1246
+ expected: fileIdentity,
1247
+ toParent: quarantineParent.cap,
1248
+ to: path.posix.basename(quarantine),
1249
+ replace: "never",
1250
+ });
1251
+ await context.session.flush(
1252
+ inspected.parent.cap,
1253
+ "namespace",
1254
+ inspected.parent.observation.identity,
1255
+ );
1256
+ await context.session.flush(
1257
+ quarantineParent.cap,
1258
+ "namespace",
1259
+ quarantineParent.observation.identity,
1260
+ );
1261
+ await verifyNativeRepoDirectory(
1262
+ context,
1263
+ path.posix.dirname(file),
1264
+ inspected.parent.observation.identity,
1265
+ "generated cleanup parent",
1266
+ );
1267
+ await verifyNativeRepoDirectory(
1268
+ context,
1269
+ path.posix.dirname(quarantine),
1270
+ quarantineParent.observation.identity,
1271
+ "generated cleanup quarantine",
1272
+ );
1273
+ } catch (error) {
1274
+ entry.phase = "unresolved";
1275
+ entry.reason = `generated cleanup quarantine failed: ${error.message}`;
1276
+ await writeCleanupJournalNative(context, journal).catch(() => {});
1277
+ throw error;
1278
+ }
1279
+ const state = await inspectCleanupEntryStateNative(context, entry);
1280
+ if (state.kind !== "quarantined") {
1281
+ entry.phase = "unresolved";
1282
+ entry.reason = state.reason ?? "cleanup did not retain the exact journaled identity";
1283
+ await writeCleanupJournalNative(context, journal).catch(() => {});
1284
+ throw new Error(entry.reason);
1285
+ }
1286
+ entry.phase = "quarantined";
1287
+ entry.keepSource = state.keepSource;
1288
+ await writeCleanupJournalNative(context, journal);
1289
+ if (retireOnly.has(file) && state.keepSource) {
1290
+ entry.phase = "unresolved";
1291
+ entry.reason = "a replacement occupied the user-owned source during WAL-backed retirement";
1292
+ await writeCleanupJournalNative(context, journal).catch(() => {});
1293
+ throw new Error(`${file}: ${entry.reason}`);
1294
+ }
1295
+ generated[quarantine] = hash;
1296
+ quarantineIdentities[quarantine] = quarantineObservation(
1297
+ state.quarantine.file.observation,
1298
+ state.quarantineParent.observation,
1299
+ );
1300
+ if (state.keepSource) generated[file] = hash;
1301
+ console.log(
1302
+ `Quarantined ${file} as ${quarantine} (no longer loaded by this profile; retained for identity-safe cleanup)`,
1303
+ );
1304
+ }
1305
+
1306
+ const retainedCleanupJournal =
1307
+ journal.entries.length > 0
1308
+ ? newPrivateCleanupQuarantine("cleanup-transaction.tombstone", true)
1309
+ : null;
1310
+ const allRetainedCleanupJournals = [
1311
+ ...new Set([
1312
+ ...retainedCleanupJournals,
1313
+ ...(legacyRetainedCleanupJournal ? [legacyRetainedCleanupJournal] : []),
1314
+ ...(retainedCleanupJournal ? [retainedCleanupJournal] : []),
1315
+ ]),
1316
+ ];
1317
+ const retainedCleanupParent = retainedCleanupJournal
1318
+ ? await openOrCreateNativeRepoDirectory(context, path.posix.dirname(retainedCleanupJournal), {
1319
+ mode: 0o700,
1320
+ label: "retained cleanup journal parent",
1321
+ })
1322
+ : null;
1323
+ for (const relative of allRetainedCleanupJournals) {
1324
+ // Existing retained paths were already classified by the old-files
1325
+ // loop above. In particular, operator-removed tombstones stay removed
1326
+ // instead of being resurrected as missing manifest entries.
1327
+ if (relative !== retainedCleanupJournal && Object.hasOwn(oldFiles, relative)) continue;
1328
+ generated[relative] = sha256(Buffer.alloc(0));
1329
+ if (relative !== retainedCleanupJournal) {
1330
+ const retained = await nativeInspectOutput(context, relative);
1331
+ if (retained.kind !== "ok" || retained.bytes.length !== 0) {
1332
+ throw new Error(`retained cleanup journal ${relative} changed before publication`);
1333
+ }
1334
+ quarantineIdentities[relative] = quarantineObservation(
1335
+ retained.file.observation,
1336
+ retained.parent.observation,
1337
+ );
1338
+ }
1339
+ }
1340
+ const publishedRetainedCleanupJournals = allRetainedCleanupJournals.filter((relative) =>
1341
+ Object.hasOwn(generated, relative),
1342
+ );
1343
+ if (retainedCleanupJournal) {
1344
+ const cleanupJournal = await nativeInspectOutput(context, CLEANUP_JOURNAL_REL);
1345
+ if (cleanupJournal.kind !== "ok") {
1346
+ throw new Error("cleanup journal changed before retained identity publication");
1347
+ }
1348
+ quarantineIdentities[retainedCleanupJournal] = stableQuarantineObservation(
1349
+ cleanupJournal.file.observation,
1350
+ retainedCleanupParent.observation,
1351
+ );
1352
+ }
1353
+ const manifest = {
1354
+ generatedBy: "stdd",
1355
+ version: VERSION,
1356
+ files: generated,
1357
+ targets: {
1358
+ tools: targets.tools,
1359
+ ci: targets.ci,
1360
+ hooks: targets.hooks,
1361
+ sessionHook: targets.sessionHook,
1362
+ stopHook: targets.stopHook,
1363
+ },
1364
+ ...(Object.keys(quarantineIdentities).length > 0 ? { quarantineIdentities } : {}),
1365
+ ...(legacyRetainedCleanupJournal && Object.hasOwn(generated, legacyRetainedCleanupJournal)
1366
+ ? { retainedCleanupJournal: legacyRetainedCleanupJournal }
1367
+ : {}),
1368
+ ...(publishedRetainedCleanupJournals.length > 0
1369
+ ? { retainedCleanupJournals: publishedRetainedCleanupJournals }
1370
+ : {}),
1371
+ };
1372
+ try {
1373
+ await publishNativeRepoFile(
1374
+ context,
1375
+ ".stdd/manifest.json",
1376
+ `${JSON.stringify(manifest, null, "\t")}\n`,
1377
+ {
1378
+ mode: 0o600,
1379
+ tempPrefix: ".manifest-",
1380
+ directoryMode: 0o700,
1381
+ expectedTarget: expectedManifestIdentity,
1382
+ },
1383
+ );
1384
+ } catch (error) {
1385
+ manifestRenamePossible = error.mutation === "possible" || error.mutation === "committed";
1386
+ throw error;
1387
+ }
1388
+ manifestDurable = true;
1389
+ if (retainedCleanupJournal) {
1390
+ await clearCleanupJournalNative(context, retainedCleanupJournal);
1391
+ }
1392
+ } catch (error) {
1393
+ const unresolved = [];
1394
+ let rolledBackForRecovery = false;
1395
+ if (journal.entries.length > 0) {
1396
+ if (
1397
+ journal.entries.some((entry) => entry.phase === "unresolved") ||
1398
+ manifestRenamePossible ||
1399
+ manifestDurable
1400
+ ) {
1401
+ unresolved.push(
1402
+ manifestDurable
1403
+ ? "manifest publication is durable but cleanup journal settlement did not complete"
1404
+ : manifestRenamePossible
1405
+ ? "manifest publication crossed an indeterminate mutation boundary"
1406
+ : "cleanup settlement is unresolved",
1407
+ );
1408
+ } else {
1409
+ unresolved.push(...(await rollbackCleanupJournalNative(context, journal)));
1410
+ rolledBackForRecovery = unresolved.length === 0;
1411
+ }
1412
+ if (unresolved.length > 0) {
1413
+ throw new Error(
1414
+ `${error.message}; ${CLEANUP_JOURNAL_REL} remains for recovery — ${unresolved.join("; ")}`,
1415
+ { cause: error },
1416
+ );
1417
+ }
1418
+ if (rolledBackForRecovery) {
1419
+ throw new Error(
1420
+ `${error.message}; ${CLEANUP_JOURNAL_REL} remains for recovery after capability-bound rollback`,
1421
+ { cause: error },
1422
+ );
1423
+ }
1424
+ }
1425
+ throw error;
1426
+ }
1427
+ }
1428
+
1429
+ /**
1430
+ * Retire obsolete generated outputs and durably publish the replacement manifest.
1431
+ * Mutates `generated` with preserved/quarantined entries; failures terminate through `fail`.
1432
+ */
1433
+ const NO_PROJECT_LOG_METHOD_PREAMBLE = `# Repository policy: no project log
1434
+
1435
+ This repository sets \`projectLog.enabled\` to \`false\`. It does not use a
1436
+ project log. Do not create or search dated decision, design, or deferred-work
1437
+ archives. Keep current behavior in canonical docs; use PR descriptions and git
1438
+ history for rationale and decisions.
1439
+
1440
+ This repository policy overrides generic project-log examples later in this
1441
+ method.
1442
+
1443
+ ---
1444
+
1445
+ `;
1446
+
1447
+ export function renderInstalledMethod(source, projectLogEnabled) {
1448
+ return projectLogEnabled ? source : `${NO_PROJECT_LOG_METHOD_PREAMBLE}${source}`;
1449
+ }
1450
+
1451
+ /** Managed outputs that remain discoverable even when a partial init never
1452
+ * published its manifest, or an older buggy cleanup forgot one. */
1453
+ function discoverGeneratedOutputs(targetDir) {
1454
+ const found = new Set();
1455
+ const addIfPresent = (relative) => {
1456
+ const candidate = path.join(targetDir, relative);
1457
+ try {
1458
+ fs.lstatSync(candidate);
1459
+ found.add(relative);
1460
+ } catch (err) {
1461
+ if (err.code !== "ENOENT") found.add(relative);
1462
+ }
1463
+ };
1464
+
1465
+ addIfPresent(".stdd/method.md");
1466
+ for (const adapter of Object.values(AGENT_ADAPTERS)) addIfPresent(adapter.snippetFile);
1467
+ const shippedPlaybooks = loadPlaybooks();
1468
+ const reservedSkillNames = new Set([
1469
+ ...shippedPlaybooks.map((playbook) => playbook.meta.name),
1470
+ ...loadLocalPlaybooks(targetDir).map((playbook) => playbook.meta.name),
1471
+ ]);
1472
+ for (const playbook of shippedPlaybooks) addIfPresent(`.stdd/playbooks/${playbook.file}`);
1473
+ for (const adapter of Object.values(CI_ADAPTERS)) {
1474
+ if (adapter.outputFile) addIfPresent(adapter.outputFile);
1475
+ }
1476
+
1477
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
1478
+ // Shipped and validated local skill names reserve generated-output paths. Surface an
1479
+ // exact final object even when it cannot be opened as a stamped regular
1480
+ // file; absence remains normal, and unrelated user skill names are
1481
+ // still considered only when their contents carry the generated stamp.
1482
+ for (const name of reservedSkillNames) {
1483
+ addIfPresent(`${adapter.skillRoot}/${name}/SKILL.md`);
1484
+ }
1485
+ const skillsRoot = path.join(targetDir, adapter.skillRoot);
1486
+ let entries;
1487
+ try {
1488
+ entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
1489
+ } catch (err) {
1490
+ if (err.code !== "ENOENT") found.add(adapter.skillRoot);
1491
+ continue;
1492
+ }
1493
+ for (const entry of entries) {
1494
+ if (!entry.isDirectory()) continue;
1495
+ const relative = `${adapter.skillRoot}/${entry.name}/SKILL.md`;
1496
+ const skillPath = path.join(targetDir, relative);
1497
+ try {
1498
+ if (
1499
+ fs.lstatSync(skillPath).isFile() &&
1500
+ /<!-- generated by stdd v[^\s]+ —/u.test(fs.readFileSync(skillPath, "utf8"))
1501
+ ) {
1502
+ found.add(relative);
1503
+ }
1504
+ } catch {
1505
+ // A broken generated-looking skill path is still surfaced when
1506
+ // the manifest names it; unknown user-owned entries stay alone.
1507
+ }
1508
+ }
1509
+ }
1510
+ return [...found].sort();
1511
+ }
1512
+
1513
+ /**
1514
+ * Generated-file drift, as `{ file, message }` findings. With a manifest
1515
+ * (written by `stdd init`), every generated file is verified by content
1516
+ * hash and managed outputs may not exist outside its inventory. Without a
1517
+ * manifest, any generated output is a failed or legacy partial install that
1518
+ * must be regenerated before it can be trusted.
1519
+ */
1520
+ export function scanGeneratedDrift(targetDir, config) {
1521
+ const stale = [];
1522
+ try {
1523
+ const cleanupJournal = readCleanupJournal(targetDir);
1524
+ if (cleanupJournal) {
1525
+ stale.push({
1526
+ file: CLEANUP_JOURNAL_REL,
1527
+ message:
1528
+ "pending generated-output cleanup transaction — run stdd init to recover or inspect its unresolved paths",
1529
+ });
1530
+ }
1531
+ } catch (err) {
1532
+ stale.push({
1533
+ file: CLEANUP_JOURNAL_REL,
1534
+ message: `invalid cleanup transaction journal (${err.message}) — inspect it before running stdd init`,
1535
+ });
1536
+ }
1537
+ let manifest;
1538
+ try {
1539
+ manifest = readManifestDocument(targetDir);
1540
+ } catch (err) {
1541
+ return [
1542
+ ...stale,
1543
+ {
1544
+ file: ".stdd/manifest.json",
1545
+ message: `${err.message} — re-run stdd init`,
1546
+ },
1547
+ ];
1548
+ }
1549
+ if (manifest) {
1550
+ let installedMethodBytes = null;
1551
+ if (manifest.version !== VERSION) {
1552
+ stale.push({
1553
+ file: ".stdd/manifest.json",
1554
+ message: `written by stdd v${manifest.version}, CLI is v${VERSION} — re-run stdd init`,
1555
+ });
1556
+ }
1557
+ for (const [file, hash] of Object.entries(manifest.files)) {
1558
+ const inspected = inspectManifestOutput(targetDir, file);
1559
+ if (inspected.kind === "missing") {
1560
+ stale.push({
1561
+ file,
1562
+ message: "listed in .stdd/manifest.json but missing — re-run stdd init",
1563
+ });
1564
+ } else if (inspected.kind === "invalid") {
1565
+ stale.push({
1566
+ file,
1567
+ message: `cannot be safely inspected as a readable regular file (${inspected.reason}) — remove it or re-run stdd init`,
1568
+ });
1569
+ } else {
1570
+ if (file === ".stdd/method.md") installedMethodBytes = inspected.bytes;
1571
+ if (sha256(inspected.bytes) === hash) continue;
1572
+ stale.push({
1573
+ file,
1574
+ message: "edited by hand or stale — re-run stdd init",
1575
+ });
1576
+ }
1577
+ }
1578
+ for (const file of discoverGeneratedOutputs(targetDir)) {
1579
+ if (!Object.hasOwn(manifest.files, file)) {
1580
+ stale.push({
1581
+ file,
1582
+ message:
1583
+ "looks generated by stdd but is not listed in .stdd/manifest.json — remove it or re-run stdd init",
1584
+ });
1585
+ }
1586
+ }
1587
+ if (installedMethodBytes !== null) {
1588
+ const canonicalMethod = Buffer.from(
1589
+ renderInstalledMethod(
1590
+ fs.readFileSync(path.join(PKG_ROOT, "method", "README.md"), "utf8"),
1591
+ config.projectLog.enabled,
1592
+ ),
1593
+ );
1594
+ if (!installedMethodBytes.equals(canonicalMethod)) {
1595
+ stale.push({
1596
+ file: ".stdd/method.md",
1597
+ message: "does not match the canonical method shipped by this CLI — re-run stdd init",
1598
+ });
1599
+ }
1600
+ }
1601
+ const automationRunner = isStddSourceCheckout(targetDir) ? SOURCE_RUNNER : NPM_RUNNER;
1602
+ for (const tool of manifest.targets?.tools ?? []) {
1603
+ const adapter = getAgentAdapter(tool);
1604
+ const inspected = inspectManifestOutput(targetDir, adapter.snippetFile);
1605
+ if (inspected.kind !== "ok") continue;
1606
+ const expectedSnippet = Buffer.from(
1607
+ renderAgentInstructions({
1608
+ adapter,
1609
+ stamp: STAMP,
1610
+ npmRunner: automationRunner,
1611
+ crossCli: config.capabilities.crossCli,
1612
+ projectLogEnabled: config.projectLog.enabled,
1613
+ }),
1614
+ );
1615
+ if (
1616
+ !inspected.bytes.equals(expectedSnippet) &&
1617
+ !stale.some((finding) => finding.file === adapter.snippetFile)
1618
+ ) {
1619
+ stale.push({
1620
+ file: adapter.snippetFile,
1621
+ message: "does not match the current repository policy — re-run stdd init",
1622
+ });
1623
+ }
1624
+ }
1625
+ return stale;
1626
+ }
1627
+
1628
+ const partial = discoverGeneratedOutputs(targetDir);
1629
+ if (partial.length > 0) {
1630
+ for (const file of partial) {
1631
+ const inspected = inspectManifestOutput(targetDir, file);
1632
+ if (inspected.kind === "invalid") {
1633
+ stale.push({
1634
+ file,
1635
+ message: `unsafe object occupies an exact generated-output path (${inspected.reason}) — remove it or re-run stdd init`,
1636
+ });
1637
+ }
1638
+ }
1639
+ stale.push({
1640
+ file: ".stdd/manifest.json",
1641
+ message: `is missing for a partial install with generated output ${partial[0]} — re-run stdd init`,
1642
+ });
1643
+ }
1644
+ return stale;
1645
+ }
1646
+
1647
+ /**
1648
+ * Inventory only durable, provenance-backed generated quarantines. This is
1649
+ * intentionally not a filesystem scan: an unrelated temp-looking basename is
1650
+ * never attributed to STDD without a manifest or cleanup-WAL record.
1651
+ */
1652
+ export function generatedQuarantineInventory(targetDir) {
1653
+ const found = new Map();
1654
+ try {
1655
+ const manifest = readManifestDocument(targetDir);
1656
+ if (manifest) {
1657
+ for (const relative of Object.keys(manifest.files)) {
1658
+ if (
1659
+ MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(relative)) ||
1660
+ isPrivateGeneratedQuarantinePath(relative)
1661
+ ) {
1662
+ found.set(relative, "manifest");
1663
+ }
1664
+ }
1665
+ for (const relative of [
1666
+ manifest.retainedCleanupJournal,
1667
+ ...(Array.isArray(manifest.retainedCleanupJournals) ? manifest.retainedCleanupJournals : []),
1668
+ ]) {
1669
+ if (
1670
+ typeof relative === "string" &&
1671
+ (MANIFEST_QUARANTINE_BASENAME.test(path.posix.basename(relative)) ||
1672
+ isPrivateGeneratedQuarantinePath(relative))
1673
+ ) {
1674
+ found.set(relative, "manifest");
1675
+ }
1676
+ }
1677
+ }
1678
+ } catch {
1679
+ // The ordinary manifest finding is the actionable diagnostic. Never
1680
+ // infer quarantine ownership from a malformed document.
1681
+ }
1682
+ try {
1683
+ const journal = readCleanupJournal(targetDir);
1684
+ for (const entry of journal?.entries ?? []) {
1685
+ found.set(entry.quarantine, "cleanup journal");
1686
+ }
1687
+ } catch {
1688
+ // The pending/malformed journal finding is reported separately.
1689
+ }
1690
+ return [...found]
1691
+ .sort(([left], [right]) => left.localeCompare(right))
1692
+ .map(([relative, provenance]) => ({ relative, provenance }));
1693
+ }