@monte3l/groundwork 0.1.0-next.1 → 1.0.0-rc.3

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 (170) hide show
  1. package/README.md +16 -8
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -0,0 +1,287 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Adopt mode's staging of every pack under `templates/packs/`: each pack's
5
+ * manifest and `files/` tree are copied into `.groundwork/packs/<name>/`,
6
+ * every file under an inert `<path>.staged` name, so `/customize`'s Step 0
7
+ * can install a pack from a self-contained copy -- after confirmation --
8
+ * without any toolchain globbing the project ever picking a staged file up.
9
+ * All packs are written together to a temporary sibling directory and
10
+ * swapped in by rename, so `packs/` is never half-written -- the same
11
+ * lifecycle `baseline-stage.ts` uses (both build on `staging.ts`).
12
+ */
13
+ import { readFileSync, statSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { toPosixPath } from "./assets.js";
16
+ import { isPathContained } from "./emit.js";
17
+ import { STAGED_SUFFIX, assertPlanBuiltFor, clearStaging, collectTemplateFiles, findStagedPathCollision, prepareStaging, stageAtomically, stagedNameFor, writeStagedBytes, } from "./staging.js";
18
+ /** The staging directory's name under `.groundwork/`. */
19
+ const PACKS_DIR_NAME = "packs";
20
+ /** Each staged pack's subdirectory holding its `files/` tree. */
21
+ const PACK_FILES_DIR = "files";
22
+ /**
23
+ * The project-relative directory every pack is staged under; a pack's own
24
+ * staging directory is `${STAGED_PACKS_DIR}/<name>`.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const dir = `${STAGED_PACKS_DIR}/quality`; // ".groundwork/packs/quality"
29
+ * ```
30
+ */
31
+ export const STAGED_PACKS_DIR = `.groundwork/${PACKS_DIR_NAME}`;
32
+ /**
33
+ * The install name of a pack's manifest; it is staged as
34
+ * `pack.json` + `.staged` beside the pack's `files/` directory.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const staged = `${STAGED_PACK_MANIFEST}.staged`; // "pack.json.staged"
39
+ * ```
40
+ */
41
+ export const STAGED_PACK_MANIFEST = "pack.json";
42
+ function assertSingleSegmentName(name) {
43
+ if (name === "" || name === "." || name === ".." || /[\\/]/.test(name)) {
44
+ throw new Error(`stagePacks: pack name ${JSON.stringify(name)} is not a single directory name`);
45
+ }
46
+ if (name.includes(":")) {
47
+ throw new Error(`stagePacks: pack name ${JSON.stringify(name)} contains ":", which Windows reads as a drive letter or an alternate data stream`);
48
+ }
49
+ }
50
+ /**
51
+ * Whether `filesDir` is a directory. A missing path answers `false`; any
52
+ * other `statSync` failure (a permission error, say) becomes a plan-time
53
+ * `Error` naming the pack and the path, with the failure as `cause` -- seen
54
+ * before anything is written, it is a template defect or a permission
55
+ * problem to fix, not an "incomplete, re-run" staging failure.
56
+ */
57
+ function isFilesDir(name, filesDir) {
58
+ try {
59
+ return (statSync(filesDir, { throwIfNoEntry: false })?.isDirectory() === true);
60
+ }
61
+ catch (cause) {
62
+ throw new Error(`stagePacks: could not inspect pack "${name}"'s files directory ${filesDir}`, { cause });
63
+ }
64
+ }
65
+ function serializeManifest(name, manifest) {
66
+ // JSON.stringify is typed string but yields undefined for some inputs
67
+ // (a toJSON returning undefined); never write or hash that as text.
68
+ const text = JSON.stringify(manifest, null, 2);
69
+ if (typeof text !== "string") {
70
+ throw new Error(`stagePacks: pack "${name}"'s manifest is not JSON`);
71
+ }
72
+ return Buffer.from(`${text}\n`);
73
+ }
74
+ /**
75
+ * Validates and projects every pack before anything is touched. These
76
+ * defects are refused, each with its own `Error` (no `cause`): a pack name
77
+ * that is not a single directory name, contains `:`, or is shared by two
78
+ * packs (also when the two differ only by letter case or Unicode
79
+ * normalization); a missing
80
+ * `filesDir`; two files in one pack installing to the same path; an install
81
+ * path containing `:` (a drive letter or alternate data stream on Windows);
82
+ * a tokenized install path whose staged name would land outside the pack's
83
+ * `files/` staging directory (CWE-22, docs/assurance-case.md); and two
84
+ * staged names in one pack that would land on the same file
85
+ * ({@link findStagedPathCollision}). A `filesDir` that cannot be inspected
86
+ * at all is refused with its failure as `cause` (see `isFilesDir`).
87
+ */
88
+ function planPacks(packs, packsDir, tokens) {
89
+ const seen = new Set();
90
+ const plan = packs.map(({ manifest, filesDir }) => {
91
+ const name = manifest.name;
92
+ assertSingleSegmentName(name);
93
+ if (seen.has(name)) {
94
+ throw new Error(`stagePacks: two packs are named "${name}"; pack names must be unique`);
95
+ }
96
+ seen.add(name);
97
+ if (!isFilesDir(name, filesDir)) {
98
+ throw new Error(`stagePacks: pack "${name}"'s files directory ${filesDir} does not exist`);
99
+ }
100
+ const stagedFilesDir = join(packsDir, name, PACK_FILES_DIR);
101
+ const files = [...collectTemplateFiles(filesDir, tokens)]
102
+ .sort(([a], [b]) => (a < b ? -1 : 1))
103
+ .map(([path, sourcePath]) => {
104
+ if (path.includes(":")) {
105
+ throw new Error(`stagePacks: pack "${name}"'s file ${toPosixPath(path)} contains ":", which Windows reads as a drive letter or an alternate data stream`);
106
+ }
107
+ const destPath = join(stagedFilesDir, stagedNameFor(path));
108
+ if (!isPathContained(destPath, stagedFilesDir)) {
109
+ throw new Error(`stagePacks: staged path ${resolve(destPath)} for pack "${name}"'s ${path} escapes ${resolve(stagedFilesDir)}`);
110
+ }
111
+ return { path, sourcePath };
112
+ });
113
+ const collision = findStagedPathCollision(files.map(({ path }) => toPosixPath(stagedNameFor(path))));
114
+ if (collision !== undefined) {
115
+ throw new Error(`stagePacks: two of pack "${name}"'s staged files collide: ${collision}; rename one of them under ${filesDir}`);
116
+ }
117
+ return { name, manifestBytes: serializeManifest(name, manifest), files };
118
+ });
119
+ const nameCollision = findStagedPathCollision(plan.map(({ name }) => name));
120
+ if (nameCollision !== undefined) {
121
+ throw new Error(`stagePacks: two packs' staging directories collide: ${nameCollision}; pack names must be unique ignoring case and Unicode normalization`);
122
+ }
123
+ return plan;
124
+ }
125
+ /**
126
+ * Validates `packs` and computes the plan {@link stagePacks} writes from:
127
+ * every path under `<groundworkDir>/packs/` -- each pack's
128
+ * `pack.json.staged` and every `files/<path>.staged` -- and each file's
129
+ * source, so adopt mode can scope-check `paths` before any pack is written
130
+ * and then hand the same plan to {@link stagePacks}. Reads the packs'
131
+ * source trees; writes nothing.
132
+ *
133
+ * @throws The same plan `Error`s as {@link stagePacks}.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * import { assertAdoptWriteScope } from "./main.js";
138
+ * const plan = planPackStaging(packs, groundworkDir, tokens);
139
+ * assertAdoptWriteScope(targetDir, plan.paths);
140
+ * stagePacks(packs, groundworkDir, tokens, plan);
141
+ * ```
142
+ */
143
+ export function planPackStaging(packs, groundworkDir, tokens) {
144
+ const packsDir = join(groundworkDir, PACKS_DIR_NAME);
145
+ const planned = planPacks(packs, packsDir, tokens);
146
+ const paths = planned.flatMap(({ name, files }) => [
147
+ join(packsDir, name, stagedNameFor(STAGED_PACK_MANIFEST)),
148
+ ...files.map(({ path }) => join(packsDir, name, PACK_FILES_DIR, stagedNameFor(path))),
149
+ ]);
150
+ return { groundworkDir, paths, packs: planned };
151
+ }
152
+ /**
153
+ * Every path {@link stagePacks} would write for `packs` -- a thin wrapper
154
+ * returning {@link planPackStaging}'s `paths`. Reads the packs' source
155
+ * trees; writes nothing.
156
+ *
157
+ * @throws The same plan `Error`s as {@link stagePacks}.
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * import { assertAdoptWriteScope } from "./main.js";
162
+ * assertAdoptWriteScope(targetDir, plannedPackStagingPaths(packs, groundworkDir, tokens));
163
+ * ```
164
+ */
165
+ export function plannedPackStagingPaths(packs, groundworkDir, tokens) {
166
+ return [...planPackStaging(packs, groundworkDir, tokens).paths];
167
+ }
168
+ function writePack(plan, newDir) {
169
+ const manifestStaged = stagedNameFor(STAGED_PACK_MANIFEST);
170
+ // Relative to newDir, so the containment assertion also covers the name.
171
+ const manifestSha256 = writeStagedBytes("stagePacks", plan.manifestBytes, newDir, join(plan.name, manifestStaged));
172
+ const filesDir = join(newDir, plan.name, PACK_FILES_DIR);
173
+ const files = plan.files.map(({ path, sourcePath }) => {
174
+ const stagedName = stagedNameFor(path);
175
+ const sha256 = writeStagedBytes("stagePacks", readFileSync(sourcePath), filesDir, stagedName);
176
+ return {
177
+ path: toPosixPath(path),
178
+ staged: toPosixPath(stagedName),
179
+ sha256,
180
+ };
181
+ });
182
+ return {
183
+ name: plan.name,
184
+ dir: `${STAGED_PACKS_DIR}/${plan.name}`,
185
+ suffix: STAGED_SUFFIX,
186
+ manifest: {
187
+ path: STAGED_PACK_MANIFEST,
188
+ staged: manifestStaged,
189
+ sha256: manifestSha256,
190
+ },
191
+ files,
192
+ };
193
+ }
194
+ /**
195
+ * Stages every pack in `packs` into `<groundworkDir>/packs/`: for each, its
196
+ * manifest as `<name>/pack.json.staged` (`JSON.stringify(manifest, null, 2)`
197
+ * plus a newline) and every file of its `filesDir` tree as
198
+ * `<name>/files/<path>.staged`, copied byte-for-byte -- no token
199
+ * substitution into content, which is `/customize`'s job at install time.
200
+ * `tokens` only substitutes into install paths (and dotfile names are
201
+ * restored), the same derivation `planConflicts` uses. Recorded `path`/
202
+ * `staged` values use forward slashes; `dir` is the literal
203
+ * `.groundwork/packs/<name>`, whatever `groundworkDir` was passed.
204
+ *
205
+ * What is guaranteed:
206
+ * - The plan is validated first, before anything is deleted or written (or,
207
+ * when `plan` is passed, was already validated by {@link planPackStaging}
208
+ * and the packs' trees are not walked again): a pack name that is not a
209
+ * single directory name, contains `:`, or is used by two packs (ignoring
210
+ * case and Unicode normalization), a missing `filesDir`, two files in one
211
+ * pack installing to the same path, an install path containing `:`, a
212
+ * staged name that would escape its pack's staging directory, or two
213
+ * staged names in one pack landing on the same file (equal once
214
+ * NFC-normalized and case-folded, or one a directory prefix of the other)
215
+ * throws its own
216
+ * `Error`, leaving `.groundwork/` exactly as it was. A `filesDir` that
217
+ * cannot be inspected (a permission error) throws a plan `Error` naming
218
+ * the pack and path, with the failure as `cause`.
219
+ * - Then, still before anything is deleted or written, `groundworkDir` and
220
+ * `<groundworkDir>/packs` are checked not to be symlinks, and every
221
+ * `.packs-*` entry of the CLI-owned `groundworkDir` -- meant for work
222
+ * directories a crashed earlier run left, but removed whatever created
223
+ * it, so a concurrent run against the same directory can lose its
224
+ * in-progress work directory and fail with the incomplete/re-run error
225
+ * -- is removed (best effort; a failure to remove one only warns, a
226
+ * failure to list `groundworkDir` throws). `.baseline-*`
227
+ * entries are left alone.
228
+ * - When `packs` is empty, any previous `packs/` is removed and nothing is
229
+ * created.
230
+ * - Otherwise every pack is written into one temporary `.packs-*` sibling
231
+ * directory (each file created exclusively, `wx`) and swapped in by rename
232
+ * only after every copy succeeded, so `packs/` is never half-written and a
233
+ * failure leaves any previous `packs/` intact; a previous `packs/` is
234
+ * replaced wholesale (a pack no longer passed, or a file a pack dropped,
235
+ * does not linger).
236
+ * - With a previous `packs/`, the swap is two renames: the previous one is
237
+ * parked inside the temporary directory, then the new one moved into
238
+ * place. A process killed between the two leaves `packs/` absent and the
239
+ * previous copy at `.packs-XXXXXX/previous`; the next run's sweep removes
240
+ * it and regenerates the staging.
241
+ * - If the final swap rename fails, the previous `packs/` is renamed back.
242
+ * Only if that restore also fails is `packs/` left absent: the previous
243
+ * staging then survives, parked inside the temporary directory, which is
244
+ * deliberately not removed (a later run's stale-dir sweep does).
245
+ * - The temporary directory is otherwise always removed; a failure to remove
246
+ * it only warns, naming its path.
247
+ *
248
+ * @throws `Error` (no re-run advice; no `cause` except for an uninspectable
249
+ * `filesDir`) for an invalid plan, as above; `Error` before any delete or
250
+ * write when `groundworkDir` or
251
+ * `<groundworkDir>/packs` is a symlink; `AggregateError` of the swap and
252
+ * restore failures, naming where the previous `packs/` is parked, when both
253
+ * renames fail; the `AssertionError` itself, unwrapped, if the staged-path
254
+ * containment invariant ever fails while writing; otherwise an `Error` with
255
+ * `cause`, including the cause's message, saying `.groundwork/` is
256
+ * incomplete and the CLI should be re-run.
257
+ *
258
+ * @param plan - The plan {@link planPackStaging} computed for these same
259
+ * `packs`, `groundworkDir` and `tokens`; computed here when omitted. A plan
260
+ * whose `groundworkDir` resolves to a different directory throws a plain
261
+ * `Error` naming both ("the plan was built for …") before anything is
262
+ * deleted or written.
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * import { listPackNames, loadPack } from "./packs.js";
267
+ * const packs = listPackNames().map((name) => loadPack(name));
268
+ * const staged = stagePacks(packs, "/work/app/.groundwork", { PROJECT_NAME: "app" });
269
+ * // staged[0]: { name: "github", dir: ".groundwork/packs/github", suffix: ".staged", … }
270
+ * ```
271
+ */
272
+ export function stagePacks(packs, groundworkDir, tokens, plan = planPackStaging(packs, groundworkDir, tokens)) {
273
+ assertPlanBuiltFor("stagePacks", plan.groundworkDir, groundworkDir);
274
+ const target = {
275
+ groundworkDir,
276
+ dirName: PACKS_DIR_NAME,
277
+ noun: "packs",
278
+ plural: true,
279
+ };
280
+ prepareStaging(target);
281
+ if (plan.packs.length === 0) {
282
+ clearStaging(target);
283
+ return [];
284
+ }
285
+ return stageAtomically(target, (newDir) => plan.packs.map((planned) => writePack(planned, newDir)));
286
+ }
287
+ //# sourceMappingURL=pack-stage.js.map
package/dist/packs.d.ts CHANGED
@@ -28,7 +28,7 @@ export interface Pack {
28
28
  export declare function packsRootDir(): string;
29
29
  /** Names of every pack directory that has a `pack.json` under `root`, sorted for deterministic install order. `root` defaults to `templates/packs`, overridable for tests. */
30
30
  export declare function listPackNames(root?: string): string[];
31
- /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown or malformed. */
31
+ /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
32
32
  export declare function loadPack(name: string, root?: string): Pack;
33
33
  export interface PackInstallResult {
34
34
  filesWritten: string[];
@@ -41,21 +41,27 @@ export interface PackInstallResult {
41
41
  * applies the pack's three JSON wiring merges.
42
42
  */
43
43
  export declare function installPack(pack: Pack, targetDir: string, tokens: TokenTable): PackInstallResult;
44
- /**
45
- * Copies a pack's `pack.json` + `files/` tree, unmodified, into
46
- * `<groundworkDir>/packs/<name>/` -- adopt mode's staging area. `/customize`
47
- * installs from this self-contained copy rather than from `templateRoot`
48
- * (an absolute path that may not exist by the time it runs). Token
49
- * substitution is a no-op here (`{}`): staging a project's real name into
50
- * pack content is `/customize`'s job, not this offline copy's.
51
- */
52
- export declare function stagePackFiles(pack: Pack, groundworkDir: string): string[];
53
44
  /**
54
45
  * Index-level, adopt-mode-only facts about how a pack's wiring would land
55
46
  * against a real project's current `.claude/settings.json` and
56
47
  * `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
57
- * work, which is `/customize`'s Step 0 judgment call to make after reading
58
- * the project's real gate runner and hook config.
48
+ * work. That verdict is a judgment call for `/customize`'s Step 0 (the
49
+ * adopt-mode reconcile step in `/customize`) to make after reading the
50
+ * project's real gate runner and hook config. A path this process cannot
51
+ * reach (`EACCES`/`EPERM`/`ELOOP`) is observed with its errno, never as
52
+ * "not found"; nor is a dangling symlink or a file blocking an ancestor
53
+ * directory, which is also recorded once in `undetermined`. A
54
+ * `.claude/settings.json` that exists but cannot be read for a reason that
55
+ * is a property of the project's tree (`EACCES`/`EPERM`, `EISDIR`, `ENOENT`,
56
+ * `ELOOP`) is observed with its errno and also recorded once in
57
+ * `undetermined` -- adopt mode passes the survey's own list, so the report
58
+ * shows it; any other errno throws.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const undetermined: string[] = [];
63
+ * const observations = observeWiring(targetDir, pack.manifest, undetermined);
64
+ * ```
59
65
  */
60
- export declare function observeWiring(targetDir: string, manifest: PackManifest): string[];
66
+ export declare function observeWiring(targetDir: string, manifest: PackManifest, undetermined?: string[]): string[];
61
67
  //# sourceMappingURL=packs.d.ts.map
package/dist/packs.js CHANGED
@@ -1,17 +1,25 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Loads pack manifests from `templates/packs/<name>/pack.json` and installs
3
5
  * a pack's files + JSON wiring into a freshly-bootstrapped project. Adopt
4
6
  * mode never calls `installPack` -- it surveys packs into the report
5
- * (`observeWiring`, `stagePackFiles`) and defers installation to
6
- * `/customize`, which reads a project's real gate runner before translating
7
- * a pack's wiring (see inventory.ts).
7
+ * (`observeWiring`; `pack-stage.ts`'s `stagePacks` stages them, inert) and
8
+ * defers installation to `/customize`, which reads a project's real gate
9
+ * runner before translating a pack's wiring (see inventory.ts). Both modes call `loadPack` for every
10
+ * pack they handle (fresh mode: each `--pack`; adopt mode: every pack)
11
+ * before writing anything, and `loadPack` refuses a prototype-sensitive
12
+ * key in `wiring.settings`, `wiring.settingsTopLevel` or
13
+ * `wiring.packageScripts`, so such a pack is neither installed nor staged.
8
14
  */
9
15
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
10
16
  import { dirname, join } from "node:path";
11
17
  import { resolveAsset } from "./assets.js";
12
18
  import { emitTemplate } from "./emit.js";
13
19
  import { parseJsonc } from "./jsonc.js";
14
- import { isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
20
+ import { blockedAbsentNote } from "./survey/internal/blocked-path.js";
21
+ import { probePath, readFailure, recordedReadCode, unreadableNote, } from "./survey/internal/read-guard.js";
22
+ import { isPrototypeSensitiveKey, isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
15
23
  /** `templates/packs`, resolved the same way `templatesCoreDir()` resolves `templates/core`. */
16
24
  export function packsRootDir() {
17
25
  return resolveAsset({ repo: "templates/packs", local: "templates/packs" });
@@ -26,7 +34,105 @@ export function listPackNames(root = packsRootDir()) {
26
34
  .map((entry) => entry.name)
27
35
  .sort();
28
36
  }
29
- /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown or malformed. */
37
+ /** A pack's own `name` becomes a path segment under `.groundwork/packs/`, so it must be a bare lowercase identifier: no separators, no `..`. */
38
+ const PACK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
39
+ const CAP_KEYS = [
40
+ "agents",
41
+ "skills",
42
+ "hooks",
43
+ "workflows",
44
+ "scripts",
45
+ ];
46
+ /** True when `budget` has an own non-negative integer for every {@link CapCounts} key (one read per key). */
47
+ function isValidBudget(budget) {
48
+ if (!isRecord(budget))
49
+ return false;
50
+ return CAP_KEYS.every((key) => {
51
+ if (!Object.hasOwn(budget, key))
52
+ return false;
53
+ const value = budget[key];
54
+ return Number.isInteger(value) && value >= 0;
55
+ });
56
+ }
57
+ /** The two CLI modes a pack's `modes` may name (see mode.ts). */
58
+ const PACK_MODES = ["fresh", "adopt"];
59
+ /** The `bin/lib/verify-steps.mjs` groups a pack's verify step may join. */
60
+ const VERIFY_GROUPS = [
61
+ "format",
62
+ "lint",
63
+ "typecheck",
64
+ "build",
65
+ "test",
66
+ ];
67
+ /** Throws, naming the entry's 0-based `index` and the offending field, when one `wiring.verifySteps[]` entry isn't an object, lacks a string `id`/`name`, lacks a non-empty string-array `cmd`, or names an unknown `group`. Each field is read once into a local before it is checked. */
68
+ function assertValidVerifyStep(name, step, index) {
69
+ const where = `pack "${name}": pack.json's wiring.verifySteps[${String(index)}]`;
70
+ if (!isRecord(step)) {
71
+ throw new Error(`${where} must be an object, got ${String(JSON.stringify(step))}`);
72
+ }
73
+ for (const key of ["id", "name"]) {
74
+ const value = Object.hasOwn(step, key) ? step[key] : undefined;
75
+ if (typeof value !== "string") {
76
+ throw new Error(`${where}.${key} must be a string, got ${String(JSON.stringify(value))}`);
77
+ }
78
+ }
79
+ const cmd = Object.hasOwn(step, "cmd") ? step["cmd"] : undefined;
80
+ if (!Array.isArray(cmd) ||
81
+ cmd.length === 0 ||
82
+ !cmd.every((part) => typeof part === "string")) {
83
+ throw new Error(`${where}.cmd must be a non-empty array of strings, got ${String(JSON.stringify(cmd))}`);
84
+ }
85
+ const group = Object.hasOwn(step, "group")
86
+ ? step["group"]
87
+ : undefined;
88
+ if (typeof group !== "string" || !VERIFY_GROUPS.includes(group)) {
89
+ throw new Error(`${where} group ${String(JSON.stringify(group))} must be one of ${VERIFY_GROUPS.join(", ")}`);
90
+ }
91
+ }
92
+ /**
93
+ * Throws, naming the pack, the field and the key, when one of `fragment`'s own
94
+ * enumerable keys is prototype-sensitive ({@link isPrototypeSensitiveKey}).
95
+ * `Object.keys` sees a `__proto__` key here because `parseJsonc`, like
96
+ * `JSON.parse`, creates it as an ordinary own data property. Only the
97
+ * fragment's own top-level keys are checked: they are the names a merge
98
+ * writes, while everything nested below them is an opaque value.
99
+ */
100
+ function assertNoPrototypeSensitiveKeys(name, field, fragment) {
101
+ for (const key of Object.keys(fragment)) {
102
+ if (isPrototypeSensitiveKey(key)) {
103
+ throw new Error(`pack "${name}": pack.json's wiring.${field} must not use the prototype-sensitive key ${JSON.stringify(key)}`);
104
+ }
105
+ }
106
+ }
107
+ /**
108
+ * Throws, naming the offending key or value, when `wiring.settings`/`packageScripts` is missing or isn't an object, `verifySteps` is missing or isn't an array, any `verifySteps[]` entry is malformed (see {@link assertValidVerifyStep}), or `settings`, `packageScripts` or (when it is an object) `settingsTopLevel` has a prototype-sensitive own key (see {@link assertNoPrototypeSensitiveKeys}).
109
+ * Those three are the only wiring fields whose keys a merge writes; `verifySteps` ids are values. An absent or non-object `settingsTopLevel` is not rejected here.
110
+ */
111
+ function assertValidWiringShape(name, wiring) {
112
+ for (const key of ["settings", "packageScripts"]) {
113
+ const value = Object.hasOwn(wiring, key) ? wiring[key] : undefined;
114
+ if (!isRecord(value)) {
115
+ throw new Error(`pack "${name}": pack.json's wiring.${key} must be an object`);
116
+ }
117
+ assertNoPrototypeSensitiveKeys(name, key, value);
118
+ }
119
+ const topLevel = Object.hasOwn(wiring, "settingsTopLevel")
120
+ ? wiring["settingsTopLevel"]
121
+ : undefined;
122
+ if (isRecord(topLevel)) {
123
+ assertNoPrototypeSensitiveKeys(name, "settingsTopLevel", topLevel);
124
+ }
125
+ const steps = Object.hasOwn(wiring, "verifySteps")
126
+ ? wiring["verifySteps"]
127
+ : undefined;
128
+ if (!Array.isArray(steps)) {
129
+ throw new Error(`pack "${name}": pack.json's wiring.verifySteps must be an array`);
130
+ }
131
+ steps.forEach((step, index) => {
132
+ assertValidVerifyStep(name, step, index);
133
+ });
134
+ }
135
+ /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
30
136
  export function loadPack(name, root = packsRootDir()) {
31
137
  const packDir = join(root, name);
32
138
  const manifestPath = join(packDir, "pack.json");
@@ -38,10 +144,43 @@ export function loadPack(name, root = packsRootDir()) {
38
144
  if (!parsed.ok) {
39
145
  throw new Error(`pack "${name}": pack.json failed to parse -- ${parsed.error}`);
40
146
  }
41
- const manifest = parsed.value;
147
+ const raw = parsed.value;
148
+ if (!isRecord(raw)) {
149
+ throw new Error(`pack "${name}": pack.json must be an object, got ${String(JSON.stringify(raw))}`);
150
+ }
151
+ const manifest = raw;
42
152
  if (manifest.schemaVersion !== 1) {
43
153
  throw new Error(`pack "${name}": unsupported pack.json schemaVersion ${JSON.stringify(manifest.schemaVersion)}`);
44
154
  }
155
+ const manifestName = manifest.name;
156
+ if (typeof manifestName !== "string" ||
157
+ !PACK_NAME_PATTERN.test(manifestName)) {
158
+ throw new Error(`pack "${name}": pack.json's pack name ${JSON.stringify(manifestName)} must match ${String(PACK_NAME_PATTERN)} (a bare lowercase identifier, no path separators)`);
159
+ }
160
+ const modes = manifest.modes;
161
+ if (!Array.isArray(modes) ||
162
+ modes.length === 0 ||
163
+ !modes.every((mode) => typeof mode === "string")) {
164
+ throw new Error(`pack "${name}": pack.json's modes must be a non-empty array of strings`);
165
+ }
166
+ const badMode = modes.find((mode) => !PACK_MODES.includes(mode));
167
+ if (badMode !== undefined) {
168
+ throw new Error(`pack "${name}": pack.json's mode ${JSON.stringify(badMode)} must be one of ${PACK_MODES.join(", ")}`);
169
+ }
170
+ const wiring = manifest.wiring;
171
+ if (!isRecord(wiring)) {
172
+ throw new Error(`pack "${name}": pack.json's wiring must be an object`);
173
+ }
174
+ assertValidWiringShape(name, wiring);
175
+ const budget = manifest.budget;
176
+ if (!isValidBudget(budget)) {
177
+ throw new Error(`pack "${name}": pack.json's budget must set ${CAP_KEYS.join(", ")} to non-negative integers`);
178
+ }
179
+ // pack-stage.ts's stagePacks keys each pack's staging directory on
180
+ // manifest.name, so a mismatch would let two pack directories collide.
181
+ if (manifestName !== name) {
182
+ throw new Error(`pack "${name}": pack.json's name "${manifestName}" does not match its directory name "${name}"`);
183
+ }
45
184
  return { manifest, filesDir: join(packDir, "files") };
46
185
  }
47
186
  function readJsonOrThrow(path) {
@@ -126,35 +265,96 @@ export function installPack(pack, targetDir, tokens) {
126
265
  return { filesWritten, budget: manifest.budget };
127
266
  }
128
267
  /**
129
- * Copies a pack's `pack.json` + `files/` tree, unmodified, into
130
- * `<groundworkDir>/packs/<name>/` -- adopt mode's staging area. `/customize`
131
- * installs from this self-contained copy rather than from `templateRoot`
132
- * (an absolute path that may not exist by the time it runs). Token
133
- * substitution is a no-op here (`{}`): staging a project's real name into
134
- * pack content is `/customize`'s job, not this offline copy's.
268
+ * Reads `.claude/settings.json` for {@link observeWiring}. A failure that is
269
+ * a fact about the project's own tree -- a permission failure
270
+ * (`EACCES`/`EPERM`), a directory at the path (`EISDIR`), the file vanishing
271
+ * after the exists probe or a dangling symlink (`ENOENT`), a symlink loop
272
+ * (`ELOOP`) -- is recorded as an observation naming the path and errno, and
273
+ * once in `undetermined` (so the adoption report shows it), and `undefined`
274
+ * is returned; any other errno is about the machine and throws, naming the
275
+ * path with the original failure as `cause`.
135
276
  */
136
- export function stagePackFiles(pack, groundworkDir) {
137
- const destDir = join(groundworkDir, "packs", pack.manifest.name);
138
- const { filesWritten } = emitTemplate(pack.filesDir, join(destDir, "files"), {});
139
- writeJson(join(destDir, "pack.json"), pack.manifest);
140
- return [...filesWritten.map((f) => join("files", f)), "pack.json"];
277
+ function readSettingsOrObserve(settingsPath, observations, undetermined) {
278
+ try {
279
+ return readFileSync(settingsPath, "utf8");
280
+ }
281
+ catch (error) {
282
+ const code = recordedReadCode(error);
283
+ if (code === undefined)
284
+ throw readFailure(settingsPath, error);
285
+ observations.push(`.claude/settings.json (${settingsPath}) exists but could not be read (${code})`);
286
+ // Called once per pack against the same file: record the note once.
287
+ const note = unreadableNote(settingsPath, code);
288
+ if (!undetermined.includes(note))
289
+ undetermined.push(note);
290
+ return undefined;
291
+ }
292
+ }
293
+ /**
294
+ * Whether `path` exists, for {@link observeWiring}. A real `stat`, never
295
+ * `existsSync`, so a file under a directory this process cannot search is
296
+ * not observed as missing: an `EACCES`/`EPERM`/`ELOOP` is recorded as an
297
+ * observation naming the path and errno, and `undefined` is returned so the
298
+ * caller states neither "found" nor "not found". `ENOENT`/`ENOTDIR` answers
299
+ * `false` only when nothing is really there: a dangling symlink at the path
300
+ * or an ancestor, or a regular file blocking an ancestor, is observed and
301
+ * recorded once in `undetermined` (the same note `conflicts.ts` records via
302
+ * `blockedAbsentNote`) and answers `undefined`. Any other errno throws (see
303
+ * `probePath`).
304
+ */
305
+ function existsOrObserve(path, targetDir, observations, undetermined) {
306
+ const probe = probePath(path);
307
+ if (probe.kind === "unresolvable") {
308
+ observations.push(`could not check whether ${path} exists (${probe.code}) -- left undetermined, not reported missing`);
309
+ return undefined;
310
+ }
311
+ if (probe.kind === "present")
312
+ return true;
313
+ const note = blockedAbsentNote(path, targetDir);
314
+ if (note === undefined)
315
+ return false;
316
+ observations.push(`${note} -- left undetermined, not reported missing`);
317
+ // Called once per pack against the same path: record the note once.
318
+ if (!undetermined.includes(note))
319
+ undetermined.push(note);
320
+ return undefined;
141
321
  }
142
322
  /**
143
323
  * Index-level, adopt-mode-only facts about how a pack's wiring would land
144
324
  * against a real project's current `.claude/settings.json` and
145
325
  * `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
146
- * work, which is `/customize`'s Step 0 judgment call to make after reading
147
- * the project's real gate runner and hook config.
326
+ * work. That verdict is a judgment call for `/customize`'s Step 0 (the
327
+ * adopt-mode reconcile step in `/customize`) to make after reading the
328
+ * project's real gate runner and hook config. A path this process cannot
329
+ * reach (`EACCES`/`EPERM`/`ELOOP`) is observed with its errno, never as
330
+ * "not found"; nor is a dangling symlink or a file blocking an ancestor
331
+ * directory, which is also recorded once in `undetermined`. A
332
+ * `.claude/settings.json` that exists but cannot be read for a reason that
333
+ * is a property of the project's tree (`EACCES`/`EPERM`, `EISDIR`, `ENOENT`,
334
+ * `ELOOP`) is observed with its errno and also recorded once in
335
+ * `undetermined` -- adopt mode passes the survey's own list, so the report
336
+ * shows it; any other errno throws.
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * const undetermined: string[] = [];
341
+ * const observations = observeWiring(targetDir, pack.manifest, undetermined);
342
+ * ```
148
343
  */
149
- export function observeWiring(targetDir, manifest) {
344
+ export function observeWiring(targetDir, manifest, undetermined = []) {
150
345
  const observations = [];
151
346
  const settingsPath = join(targetDir, ".claude", "settings.json");
152
- if (!existsSync(settingsPath)) {
347
+ const settingsExists = existsOrObserve(settingsPath, targetDir, observations, undetermined);
348
+ if (settingsExists === false) {
153
349
  observations.push("no .claude/settings.json found");
154
350
  }
155
- else {
156
- const parsed = parseJsonc(readFileSync(settingsPath, "utf8"));
157
- if (!parsed.ok || !isRecord(parsed.value)) {
351
+ else if (settingsExists) {
352
+ const content = readSettingsOrObserve(settingsPath, observations, undetermined);
353
+ const parsed = content === undefined ? undefined : parseJsonc(content);
354
+ if (parsed === undefined) {
355
+ // Unreadable -- already recorded as an observation.
356
+ }
357
+ else if (!parsed.ok || !isRecord(parsed.value)) {
158
358
  observations.push(".claude/settings.json exists but could not be parsed");
159
359
  }
160
360
  else {
@@ -172,14 +372,17 @@ export function observeWiring(targetDir, manifest) {
172
372
  }
173
373
  }
174
374
  }
175
- if (existsSync(join(targetDir, ".claude", "settings.local.json"))) {
375
+ if (existsOrObserve(join(targetDir, ".claude", "settings.local.json"), targetDir, observations, undetermined) === true) {
176
376
  observations.push(".claude/settings.local.json is present and may shadow a merged hook entry");
177
377
  }
178
378
  if (manifest.wiring.verifySteps.length > 0) {
179
379
  const stepsPath = join(targetDir, "bin", "lib", "verify-steps.packs.json");
180
- observations.push(existsSync(stepsPath)
181
- ? "bin/lib/verify-steps.packs.json exists"
182
- : "no bin/lib/verify-steps.packs.json found -- no bin/verify.mjs-shaped gate runner detected");
380
+ const stepsExist = existsOrObserve(stepsPath, targetDir, observations, undetermined);
381
+ if (stepsExist !== undefined) {
382
+ observations.push(stepsExist
383
+ ? "bin/lib/verify-steps.packs.json exists"
384
+ : "no bin/lib/verify-steps.packs.json found -- no bin/verify.mjs-shaped gate runner detected");
385
+ }
183
386
  }
184
387
  return observations;
185
388
  }