superdev-cli 0.1.1 → 0.1.2

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.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Provider registry - the canonical adapter contract for every external
4
+ * capability Superdev orchestrates.
5
+ *
6
+ * WHAT THIS FILE IS: identity, ownership boundary, and the machine-checkable
7
+ * parts of each adapter contract - how to DETECT a provider, how to probe its
8
+ * readiness without side effects, where it is installed from, what consent its
9
+ * installation needs, what a bounded context packet may contain, and what
10
+ * evidence its output must carry.
11
+ *
12
+ * WHAT THIS FILE IS NOT: a copy of any provider's methodology. Superdev never
13
+ * vendors, renames, reimplements, or approximates provider guidance. Each entry
14
+ * records WHERE the capability lives and HOW to reach it - never what it says.
15
+ * If a provider is absent, Superdev reports the truthful capability state and a
16
+ * remediation plan; it never silently substitutes its own approximation.
17
+ *
18
+ * Identities below were VERIFIED against a live environment (installed plugin
19
+ * records, marketplace sources, CLI/npm metadata) - never guessed. Anything that
20
+ * could not be verified is marked `unresolved` and reported as such.
21
+ */
22
+
23
+ /** Capability states (contract §21.1). */
24
+ export const CAPABILITY_STATES = [
25
+ "available-and-ready", "installed-but-disabled", "installed-but-incompatible",
26
+ "installed-but-unhealthy", "missing", "marketplace-unavailable", "policy-blocked",
27
+ "authentication-required", "optional-and-absent", "unknown",
28
+ ];
29
+ export const READY_STATES = new Set(["available-and-ready"]);
30
+
31
+ /** How a provider is delivered - determines detection and installation. */
32
+ export const DELIVERY = { CLAUDE_PLUGIN: "claude-plugin", AGENT_SKILL: "agent-skill", CLI: "cli" };
33
+
34
+ /** Failure classes every adapter maps its errors onto. */
35
+ export const FAILURE_CLASSES = [
36
+ "not-installed", "disabled", "incompatible-version", "misconfigured",
37
+ "unauthenticated", "invocation-error", "partial-output", "malformed-output",
38
+ "timeout", "policy-blocked",
39
+ ];
40
+
41
+ /**
42
+ * The seven mandatory provider integrations. Each entry carries the complete
43
+ * 15-element adapter contract. `identity` is the verified canonical identity.
44
+ */
45
+ export const PROVIDERS = [
46
+ {
47
+ id: "superpowers",
48
+ title: "Superpowers",
49
+ // 1. canonical provider identity (VERIFIED)
50
+ identity: { delivery: DELIVERY.CLAUDE_PLUGIN, plugin: "superpowers", marketplace: "claude-plugins-official", ref: "superpowers@claude-plugins-official" },
51
+ // 2. ownership boundary
52
+ ownership: "Externally owned by the claude-plugins-official marketplace. Superdev routes to it and consumes its outputs; it never reproduces its brainstorming, planning, TDD, debugging, review, or finishing methodology.",
53
+ // 3. applicable intents
54
+ intents: ["brainstorm", "plan", "implement-tdd", "debug", "review", "finish"],
55
+ // 4. detection mechanism (read-only)
56
+ detection: { kind: "claude-plugin-record", key: "superpowers@claude-plugins-official" },
57
+ // 5. readiness probe (non-destructive)
58
+ readiness: { kind: "skill-namespace", expect: "superpowers:", note: "the loaded session exposes superpowers:* skills" },
59
+ // 6. installation source
60
+ install: { marketplaceSource: { source: "github", repo: "anthropics/claude-plugins-official" }, command: "claude plugin install superpowers@claude-plugins-official", verified: true },
61
+ // 7. consent requirements
62
+ consent: { required: true, class: "install-plugin", prompt: "Install the Superpowers plugin from the claude-plugins-official marketplace?" },
63
+ // 8. invocation contract
64
+ invocation: { kind: "skill", pattern: "superpowers:<skill>", examples: ["superpowers:brainstorming", "superpowers:systematic-debugging", "superpowers:test-driven-development"], superdevNeverInlines: true },
65
+ // 9. structured context packet (bounded - never the whole record)
66
+ contextPacket: ["objective", "acceptedConstraints", "relevantDecisionIds", "openOwnerQuestionIds", "riskTier", "requiredRecordOutputs"],
67
+ // 10. expected output / evidence contract
68
+ output: { form: "narrative + artifacts authored into talks/", evidence: ["providerInvoked", "providerIdentity", "artifactPaths"], mustBeTraceable: true },
69
+ // 11. failure classification
70
+ failures: ["not-installed", "disabled", "incompatible-version", "invocation-error", "partial-output"],
71
+ // 12. unavailable-provider behavior
72
+ unavailable: "State plainly that Superpowers was unavailable, run Superdev's OWN gates (scope contract, spec depth, completion evidence), and record that the specialist methodology was not applied.",
73
+ // 13. no-substitution behavior
74
+ noSubstitution: "Superdev must not present its own gates as Superpowers' methodology, and must not claim brainstorming/TDD/debugging discipline it did not obtain from the provider.",
75
+ // 14. privacy and secret boundary
76
+ privacy: { sends: "bounded context packet only", never: ["raw talks/ tree", "secrets", "PII", "model-private reasoning"], screened: true },
77
+ // 15. tests - see tests/integration/provider-contracts.test.mjs
78
+ testsRef: "tests/integration/provider-contracts.test.mjs",
79
+ },
80
+ {
81
+ id: "claude-mem",
82
+ title: "Claude Mem",
83
+ identity: { delivery: DELIVERY.CLAUDE_PLUGIN, plugin: "claude-mem", marketplace: "thedotmack", ref: "claude-mem@thedotmack" },
84
+ ownership: "Externally owned (github thedotmack/claude-mem). A recall/search CACHE - never project authority.",
85
+ intents: ["recall", "search-prior-work"],
86
+ detection: { kind: "claude-plugin-record", key: "claude-mem@thedotmack" },
87
+ readiness: { kind: "skill-namespace", expect: "claude-mem:", note: "the loaded session exposes claude-mem:* skills" },
88
+ install: { marketplaceSource: { source: "github", repo: "thedotmack/claude-mem" }, command: "claude plugin install claude-mem@thedotmack", verified: true },
89
+ consent: { required: true, class: "install-plugin", prompt: "Install the Claude Mem plugin from thedotmack/claude-mem?" },
90
+ invocation: { kind: "skill", pattern: "claude-mem:<skill>", examples: ["claude-mem:mem-search"], superdevNeverInlines: true },
91
+ contextPacket: ["narrowQuery", "timeWindow", "projectScope"],
92
+ output: { form: "recalled observations", evidence: ["providerInvoked", "recallIds", "verificationStatus"], mustBeTraceable: true },
93
+ failures: ["not-installed", "disabled", "invocation-error", "malformed-output", "partial-output"],
94
+ unavailable: "Proceed from talks/ state and session summaries alone, and say that cross-session recall was unavailable.",
95
+ // The critical authority rule for this provider.
96
+ noSubstitution: "Recall is NEVER authority. Every recalled fact must be verified against current artifacts before consequential use, and recall-sourced claims must be labelled with their verification status.",
97
+ privacy: { sends: "narrow query only", never: ["secrets", "PII", "whole-record dumps"], screened: true },
98
+ testsRef: "tests/integration/provider-contracts.test.mjs",
99
+ },
100
+ {
101
+ id: "frontend-design",
102
+ title: "Frontend Design",
103
+ identity: { delivery: DELIVERY.CLAUDE_PLUGIN, plugin: "frontend-design", marketplace: "claude-plugins-official", ref: "frontend-design@claude-plugins-official" },
104
+ ownership: "Externally owned by the claude-plugins-official marketplace. Superdev never reproduces its design methodology or aesthetic system.",
105
+ intents: ["frontend-direction", "component-implementation", "visual-design"],
106
+ detection: { kind: "claude-plugin-record", key: "frontend-design@claude-plugins-official" },
107
+ readiness: { kind: "skill-namespace", expect: "frontend-design:", note: "the loaded session exposes frontend-design:* skills" },
108
+ install: { marketplaceSource: { source: "github", repo: "anthropics/claude-plugins-official" }, command: "claude plugin install frontend-design@claude-plugins-official", verified: true },
109
+ consent: { required: true, class: "install-plugin", prompt: "Install the Frontend Design plugin from the claude-plugins-official marketplace?" },
110
+ invocation: { kind: "skill", pattern: "frontend-design:<skill>", examples: ["frontend-design:frontend-design"], superdevNeverInlines: true },
111
+ contextPacket: ["productContext", "userGoals", "designConstraints", "designSystemEvidence"],
112
+ output: { form: "design direction + implementation", evidence: ["providerInvoked", "artifactPaths"], mustBeTraceable: true },
113
+ failures: ["not-installed", "disabled", "incompatible-version", "invocation-error", "partial-output"],
114
+ unavailable: "Report that the design specialist was unavailable; implement only what the accepted design system already specifies, and do not invent visual direction.",
115
+ noSubstitution: "Superdev must not generate design direction and present it as the provider's, nor imitate its aesthetic methodology.",
116
+ privacy: { sends: "bounded design context", never: ["secrets", "PII", "customer data"], screened: true },
117
+ // Version was 'unknown' in the verified local install record - identity is
118
+ // confirmed, the version constraint is not. Reported honestly, never guessed.
119
+ versionResolution: "unresolved",
120
+ testsRef: "tests/integration/provider-contracts.test.mjs",
121
+ },
122
+ {
123
+ id: "impeccable",
124
+ title: "Impeccable",
125
+ identity: { delivery: DELIVERY.CLAUDE_PLUGIN, plugin: "impeccable", marketplace: "impeccable", ref: "impeccable@impeccable" },
126
+ ownership: "Externally owned (github pbakaus/impeccable). Superdev never reproduces its UI critique/refinement methodology.",
127
+ intents: ["ui-audit", "ui-polish", "design-critique", "accessibility-review"],
128
+ detection: { kind: "claude-plugin-record", key: "impeccable@impeccable" },
129
+ readiness: { kind: "skill-namespace", expect: "impeccable:", note: "the loaded session exposes impeccable:* skills" },
130
+ install: { marketplaceSource: { source: "github", repo: "pbakaus/impeccable" }, command: "claude plugin install impeccable@impeccable", verified: true },
131
+ consent: { required: true, class: "install-plugin", prompt: "Install the Impeccable plugin from pbakaus/impeccable?" },
132
+ invocation: { kind: "skill", pattern: "impeccable:<skill>", examples: ["impeccable:impeccable"], superdevNeverInlines: true },
133
+ contextPacket: ["surfaceUnderReview", "designSystemEvidence", "accessibilityRequirements", "knownConstraints"],
134
+ output: { form: "critique + concrete refinements", evidence: ["providerInvoked", "surfaceRefs"], mustBeTraceable: true },
135
+ failures: ["not-installed", "disabled", "incompatible-version", "invocation-error", "partial-output"],
136
+ unavailable: "Report that the UI specialist was unavailable and limit work to the accepted design system; do not improvise a critique framework.",
137
+ noSubstitution: "Superdev must not produce a UI critique and attribute it to Impeccable, nor imitate its refinement methodology.",
138
+ privacy: { sends: "surface + design constraints", never: ["secrets", "PII", "user data in screenshots"], screened: true },
139
+ testsRef: "tests/integration/provider-contracts.test.mjs",
140
+ },
141
+ {
142
+ id: "find-skills",
143
+ title: "Find Skills / skills.sh",
144
+ // Delivered as an agent skill; the ecosystem CLI is `skills` on npm.
145
+ identity: { delivery: DELIVERY.AGENT_SKILL, skill: "find-skills", cli: "skills", npm: "skills", ref: "find-skills (agent skill) + skills CLI" },
146
+ ownership: "Externally owned (the open agent skills ecosystem / skills.sh). Superdev never reimplements skill discovery or the package manager.",
147
+ intents: ["discover-capability", "install-skill"],
148
+ // COMPOSITE: the discovery skill and the ecosystem CLI are BOTH required -
149
+ // the skill without the CLI cannot install anything.
150
+ components: [
151
+ { id: "skill", kind: "agent-skill", skill: "find-skills", required: true },
152
+ { id: "cli", kind: "cli", cli: "skills", versionArgs: ["--version"], required: true },
153
+ ],
154
+ detection: { kind: "composite", skill: "find-skills", alsoCli: "skills" },
155
+ readiness: { kind: "cli-version", cli: "skills", args: ["--version"], note: "npx skills --version; the CLI is the ecosystem package manager" },
156
+ install: { npm: "skills", command: "npx skills add <skill>", verified: true, note: "skill discovery/installation is the provider's own flow" },
157
+ // The installation-flags rule is a hard safety boundary for this provider.
158
+ consent: { required: true, class: "install-skill", prompt: "Install a skill through the skills CLI?", forbiddenFlags: ["--all", "-y", "--yes"] },
159
+ invocation: { kind: "skill", pattern: "find-skills", examples: ["find-skills"], superdevNeverInlines: true },
160
+ contextPacket: ["capabilityGap", "intent", "constraints"],
161
+ output: { form: "candidate skills + install plan", evidence: ["providerInvoked", "candidateIds"], mustBeTraceable: true },
162
+ failures: ["not-installed", "misconfigured", "invocation-error", "malformed-output", "partial-output"],
163
+ unavailable: "State that skill discovery was unavailable and proceed with existing capabilities only; never fabricate a catalogue of skills.",
164
+ noSubstitution: "Superdev must not present its own guesses as ecosystem search results, and must never install with --all or a silent -y.",
165
+ privacy: { sends: "capability gap description", never: ["secrets", "PII", "proprietary code"], screened: true },
166
+ testsRef: "tests/integration/provider-contracts.test.mjs",
167
+ },
168
+ {
169
+ id: "task-observer",
170
+ title: "Task Observer",
171
+ identity: { delivery: DELIVERY.AGENT_SKILL, skill: "task-observer", repo: "rebelytics/one-skill-to-rule-them-all", ref: "task-observer (agent skill)" },
172
+ ownership: "Externally owned (github rebelytics/one-skill-to-rule-them-all). Superdev never reproduces its observation taxonomy or skill-extraction methodology.",
173
+ intents: ["observe-session", "capture-skill-opportunity"],
174
+ detection: { kind: "agent-skill", skill: "task-observer" },
175
+ readiness: { kind: "agent-skill-files", skill: "task-observer", expectFiles: ["SKILL.md"] },
176
+ install: { source: { source: "github", repo: "rebelytics/one-skill-to-rule-them-all" }, command: "npx skills add task-observer", verified: true },
177
+ consent: { required: true, class: "install-skill", prompt: "Install the Task Observer skill?", forbiddenFlags: ["--all", "-y", "--yes"] },
178
+ invocation: { kind: "skill", pattern: "task-observer", examples: ["task-observer"], superdevNeverInlines: true },
179
+ contextPacket: ["sessionObjective", "toolUsagePattern", "userCorrections"],
180
+ output: { form: "observation log entries", evidence: ["providerInvoked", "observationIds"], mustBeTraceable: true },
181
+ failures: ["not-installed", "misconfigured", "invocation-error", "partial-output", "malformed-output"],
182
+ unavailable: "Record session outcomes through Superdev's own session summaries and state that observation capture was unavailable.",
183
+ noSubstitution: "Superdev must not label its own session summary as Task Observer output or imitate its skill-extraction taxonomy.",
184
+ privacy: { sends: "session outcome signals", never: ["secrets", "PII", "model-private reasoning"], screened: true },
185
+ testsRef: "tests/integration/provider-contracts.test.mjs",
186
+ },
187
+ {
188
+ id: "envx",
189
+ title: "envx",
190
+ identity: { delivery: DELIVERY.CLI, cli: "envx", npm: "envx-cli", skill: "envx", ref: "envx-cli (CLI) + envx (agent skill)" },
191
+ ownership: "Externally owned (envx-cli). Superdev never reimplements secret encryption, decryption, or injection.",
192
+ intents: ["secret-management", "env-injection"],
193
+ // COMPOSITE: the CLI does the work, the skill carries the safe-usage
194
+ // contract. Either alone is only partially ready.
195
+ components: [
196
+ { id: "cli", kind: "cli", cli: "envx", versionArgs: ["--version"], required: true },
197
+ { id: "skill", kind: "agent-skill", skill: "envx", required: true },
198
+ ],
199
+ detection: { kind: "composite", cli: "envx", alsoSkill: "envx" },
200
+ readiness: { kind: "cli-version", cli: "envx", args: ["--version"] },
201
+ install: { npm: "envx-cli", command: "npm install -g envx-cli", verified: true, note: "the skill installs via `envx skill add`" },
202
+ consent: { required: true, class: "install-cli", prompt: "Install the envx CLI (envx-cli) globally?" },
203
+ invocation: { kind: "cli", pattern: "envx <command>", examples: ["envx run", "envx skill add"], superdevNeverInlines: true },
204
+ // The privacy boundary is the whole point of this provider.
205
+ contextPacket: ["stageName", "requiredVariableNames"],
206
+ output: { form: "marker presence / exit status only", evidence: ["providerInvoked", "stage"], mustBeTraceable: true },
207
+ failures: ["not-installed", "misconfigured", "unauthenticated", "invocation-error", "policy-blocked"],
208
+ unavailable: "Report that envx was unavailable and refuse to improvise secret handling; never read, print, or copy a decrypted secret.",
209
+ noSubstitution: "Superdev must NEVER decrypt, read, echo, or re-implement secret handling itself. It detects marker presence only.",
210
+ privacy: { sends: "variable NAMES only", never: ["secret values", "decrypted content", "passphrases"], screened: true, readsSecretValues: false },
211
+ testsRef: "tests/integration/provider-contracts.test.mjs",
212
+ },
213
+ ];
214
+
215
+ /** Version policy, stated explicitly rather than implied.
216
+ * No provider declares a minimum version today: Superdev integrates through
217
+ * stable, documented surfaces (skill namespaces, CLI subcommands) and has no
218
+ * verified evidence of a breaking floor. `minVersion` is therefore absent, and
219
+ * detection reports versions WITHOUT claiming compatibility validation. Adding
220
+ * a `minVersion` to a registry entry activates real enforcement (tested). */
221
+ export const VERSION_POLICY = {
222
+ enforced: false,
223
+ statement: "No minimum version is asserted for any provider; detection reports the observed version and never implies a compatibility check that was not performed. Set `minVersion` on a registry entry to enforce one.",
224
+ };
225
+
226
+ export const PROVIDER_IDS = PROVIDERS.map((p) => p.id);
227
+ export function getProvider(id) {
228
+ return PROVIDERS.find((p) => p.id === id) ?? null;
229
+ }
230
+
231
+ /** The 15 contract elements every provider entry must define. */
232
+ export const CONTRACT_ELEMENTS = [
233
+ "identity", "ownership", "intents", "detection", "readiness", "install", "consent",
234
+ "invocation", "contextPacket", "output", "failures", "unavailable", "noSubstitution",
235
+ "privacy", "testsRef",
236
+ ];
237
+
238
+ /** Which Superdev skill routes each intent to a provider (proactive routing). */
239
+ export const INTENT_ROUTING = {
240
+ brainstorm: "superpowers", plan: "superpowers", "implement-tdd": "superpowers",
241
+ debug: "superpowers", review: "superpowers", finish: "superpowers",
242
+ recall: "claude-mem", "search-prior-work": "claude-mem",
243
+ "frontend-direction": "frontend-design", "component-implementation": "frontend-design", "visual-design": "frontend-design",
244
+ "ui-audit": "impeccable", "ui-polish": "impeccable", "design-critique": "impeccable", "accessibility-review": "impeccable",
245
+ "discover-capability": "find-skills", "install-skill": "find-skills",
246
+ "observe-session": "task-observer", "capture-skill-opportunity": "task-observer",
247
+ "secret-management": "envx", "env-injection": "envx",
248
+ };
@@ -0,0 +1,116 @@
1
+ // Every path the published package's own code reaches must be inside the
2
+ // published package.
3
+ //
4
+ // scripts/providers/detect.mjs was imported by src/cli.mjs and src/init/index.mjs
5
+ // and was not listed in package.json's files array. So on every npm install of
6
+ // superdev-cli, provider readiness reported "could not be determined: Cannot find
7
+ // module", the init skill's step about checking readiness before relying on it
8
+ // could never succeed, and doctor printed Pass beside the failure. It worked
9
+ // everywhere except where users install it, because the plugin copy carries the
10
+ // whole repository and the npm package carries an allowlist.
11
+ //
12
+ // Nothing caught it because nothing compared what the code imports against what
13
+ // the package ships. The files array is edited by hand, and a missing entry is
14
+ // invisible locally and fatal remotely, which is the exact shape of defect a
15
+ // validator is for.
16
+
17
+ import { join, posix, relative, sep } from "node:path";
18
+
19
+ import { ERROR, WARNING, finding, importSpecifiers, isDirectory, readJson, readText, walk } from "./common.mjs";
20
+
21
+ export const name = "packaging";
22
+
23
+ const SCANNED_ROOTS = ["src", "scripts", "hooks"];
24
+
25
+ const slash = (path) => path.split(sep).join("/");
26
+
27
+ /**
28
+ * Would `npm pack` include this path, given the files array?
29
+ *
30
+ * Deliberately stricter than npm's own matching. A false finding costs somebody
31
+ * a reading; a missed one costs a release that fails in a stranger's terminal.
32
+ */
33
+ export function packaged(patterns, path) {
34
+ // npm includes these whatever the files array says.
35
+ if (path === "package.json" || /^(README|LICENSE|LICENCE|NOTICE|CHANGELOG)/i.test(path)) return true;
36
+
37
+ let included = false;
38
+ for (const raw of patterns) {
39
+ const negated = raw.startsWith("!");
40
+ const pattern = negated ? raw.slice(1) : raw;
41
+ if (matches(pattern, path)) included = !negated;
42
+ }
43
+ return included;
44
+ }
45
+
46
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+
48
+ function matches(pattern, path) {
49
+ if (pattern.endsWith("/")) return path.startsWith(pattern);
50
+ if (pattern.includes("*")) {
51
+ const source = pattern
52
+ .split("**").map((part) => part.split("*").map(escape).join("[^/]*"))
53
+ .join(".*");
54
+ return new RegExp(`^${source}$`).test(path);
55
+ }
56
+ return path === pattern || path.startsWith(`${pattern}/`);
57
+ }
58
+
59
+ export async function run(root) {
60
+ const findings = [];
61
+ const read = readJson(join(root, "package.json"));
62
+ if (read.error) {
63
+ findings.push(finding("PK-000", ERROR, "package.json", `cannot be read: ${read.error}`));
64
+ return { name, findings };
65
+ }
66
+ const manifest = read.value;
67
+ const patterns = manifest?.files;
68
+
69
+ if (!Array.isArray(patterns) || !patterns.length) {
70
+ findings.push(finding("PK-000", ERROR, "package.json",
71
+ "there is no files array, so the package would ship whatever happens to be in the directory"));
72
+ return { name, findings };
73
+ }
74
+
75
+ // The bin entry has to be in the package before anything else matters.
76
+ const bin = typeof manifest.bin === "string" ? manifest.bin : Object.values(manifest.bin ?? {})[0];
77
+ if (bin) {
78
+ const target = posix.normalize(String(bin).replace(/^\.\//, ""));
79
+ if (!packaged(patterns, target)) {
80
+ findings.push(finding("PK-001", ERROR, "package.json",
81
+ `bin points at ${target}, which the files array does not include, so the installed command would not exist`));
82
+ }
83
+ }
84
+
85
+ const files = SCANNED_ROOTS
86
+ .filter((dir) => isDirectory(join(root, dir)))
87
+ .flatMap((dir) => walk(join(root, dir)))
88
+ .filter((file) => file.endsWith(".mjs") || file.endsWith(".js"))
89
+ .map((file) => slash(relative(root, file)))
90
+ // Only shipped code can fail at runtime in somebody's install. A script that
91
+ // is deliberately repository-only may import whatever it likes.
92
+ .filter((path) => packaged(patterns, path) && !path.endsWith(".test.mjs"));
93
+
94
+ for (const path of files) {
95
+ const from = posix.dirname(path);
96
+ const seen = new Set();
97
+ for (const spec of importSpecifiers(readText(join(root, path)))) {
98
+ if (!spec.startsWith("./") && !spec.startsWith("../")) continue;
99
+ const target = posix.normalize(posix.join(from, spec));
100
+ if (seen.has(target)) continue;
101
+ seen.add(target);
102
+
103
+ if (!/\.(mjs|js|json|css|md)$/.test(target)) {
104
+ findings.push(finding("PK-003", WARNING, path,
105
+ `imports ${spec}, which names no file extension, so whether the package ships it cannot be decided here`));
106
+ continue;
107
+ }
108
+ if (!packaged(patterns, target)) {
109
+ findings.push(finding("PK-002", ERROR, path,
110
+ `imports ${target}, which package.json files does not include, so the published package cannot resolve it`));
111
+ }
112
+ }
113
+ }
114
+
115
+ return { name, findings };
116
+ }
@@ -30,11 +30,12 @@ import * as dataModel from "./data-model.mjs";
30
30
  import * as recordLinks from "./record-links.mjs";
31
31
  import * as skillCommands from "./skill-commands.mjs";
32
32
  import * as specification from "./specification.mjs";
33
+ import * as packaging from "./packaging.mjs";
33
34
 
34
35
  /** Declaration order is report order, so two runs read the same way. */
35
36
  export const VALIDATORS = [
36
37
  manifests, skills, docsTemplates, migrations, markdown,
37
- style, privacy, imports, dependencies, noTests, footprint, dataModel, recordLinks, skillCommands, specification,
38
+ style, privacy, imports, dependencies, noTests, footprint, dataModel, recordLinks, skillCommands, specification, packaging,
38
39
  ];
39
40
 
40
41
  const USAGE = `Usage: node validate-all.mjs [--root <path>] [--only <name,...>] [--json] [--out <file>] [--help]
@@ -7,6 +7,7 @@
7
7
  * Exit codes: 0 detection completed (including "none"), 2 usage error.
8
8
  */
9
9
  import { parseArgs } from "node:util";
10
+ import { execFileSync } from "node:child_process";
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { pathToFileURL } from "node:url";
@@ -90,6 +91,58 @@ function dirNames(dir) {
90
91
  .map((e) => e.name);
91
92
  }
92
93
 
94
+ /**
95
+ * Markdown under a directory that Git is not ignoring, at most three levels down.
96
+ *
97
+ * The bare existence of a subdirectory used to count as documentation. A real
98
+ * repository had `docs/` holding nothing but `.DS_Store` and a git-ignored
99
+ * `docs/client-shared/` of material the client had sent in. That is input to a
100
+ * project, not a projection of one, and detection called it "documentation
101
+ * present matching no known profile" and routed a brand new repository to adopt,
102
+ * which refuses to initialize.
103
+ *
104
+ * Git's own ignore list is the right authority for "is this part of the
105
+ * repository": it is the file the user already maintains to say so. Without Git
106
+ * the scan simply keeps everything, which is the old behaviour minus the
107
+ * empty-directory case.
108
+ */
109
+ function trackedMarkdown(root, dir) {
110
+ const found = markdownUnder(dir, 3);
111
+ const ignored = ignoredByGit(root, found);
112
+ return found.filter((file) => !ignored.has(file));
113
+ }
114
+
115
+ function markdownUnder(dir, depth) {
116
+ if (depth < 0 || !fs.existsSync(dir)) return [];
117
+ const found = [];
118
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
119
+ if (entry.name.startsWith(".")) continue;
120
+ const full = path.join(dir, entry.name);
121
+ if (entry.isDirectory()) found.push(...markdownUnder(full, depth - 1));
122
+ else if (entry.name.endsWith(".md")) found.push(full);
123
+ }
124
+ return found;
125
+ }
126
+
127
+ /** One `git check-ignore` for the whole list: a call per file is a call per file. */
128
+ function ignoredByGit(root, files) {
129
+ if (!files.length || !fs.existsSync(path.join(root, ".git"))) return new Set();
130
+ try {
131
+ const out = execFileSync("git", ["-C", root, "check-ignore", "--stdin"], {
132
+ input: files.join("\n"),
133
+ encoding: "utf8",
134
+ stdio: ["pipe", "pipe", "ignore"],
135
+ timeout: 10000,
136
+ });
137
+ return new Set(out.split(/\r?\n/).filter(Boolean).map((line) => path.resolve(root, line)));
138
+ } catch (error) {
139
+ // Exit 1 means nothing matched, which is an answer, not a failure. Anything
140
+ // else means git could not tell us, and then nothing is treated as ignored.
141
+ if (error?.status === 1) return new Set();
142
+ return new Set();
143
+ }
144
+ }
145
+
93
146
  const TYPE_FOLDERS = ["features", "apis", "api", "data", "database", "workflows", "schemas"];
94
147
  const DOCS_ROOT_CANDIDATES = ["docs/project", "docs", "documentation"];
95
148
 
@@ -143,8 +196,9 @@ export function detectProfile(root) {
143
196
  return { profile: "legacy-flat-docs", confidence: "medium", source: "structure", evidence, docsRoot: candidate };
144
197
  }
145
198
 
146
- const mdFiles = fs.readdirSync(docsRoot).filter((f) => f.endsWith(".md"));
147
- if (mdFiles.length || dirNames(docsRoot).length) {
199
+ // Markdown Git is keeping, not a directory that happens to be here.
200
+ const mdFiles = trackedMarkdown(root, docsRoot);
201
+ if (mdFiles.length) {
148
202
  evidence.push({ what: "documentation present matching no known profile", where: candidate });
149
203
  return { profile: "custom", confidence: "low", source: "structure", evidence, docsRoot: candidate };
150
204
  }
@@ -163,9 +163,13 @@ export async function milestoneShow(root, id) {
163
163
  const { milestone, features } = found;
164
164
  // Both shapes are read: a plain string from before conditions could be
165
165
  // judged, and the object a judged condition is stored as now.
166
- const exits = json(milestone.exit_conditions_json, []).map((c) =>
167
- typeof c === "string" ? { condition: c, met: false, reading: null, check: null } : c);
166
+ const asCondition = (c) => (typeof c === "string" ? { condition: c, met: false, reading: null, check: null } : c);
167
+ const exits = json(milestone.exit_conditions_json, []).map(asCondition);
168
+ // Entry conditions were stored and never shown, so a milestone could be
169
+ // blocked from starting by something the reader had no way to see.
170
+ const entries = json(milestone.entry_conditions_json, []).map(asCondition);
168
171
  const met = exits.filter((c) => c.met).length;
172
+ const entryMet = entries.filter((c) => c.met).length;
169
173
  const done = features.filter((f) => ["complete", "delivered", "implemented"].includes(f.status)).length;
170
174
  return {
171
175
  data: found,
@@ -181,6 +185,10 @@ export async function milestoneShow(root, id) {
181
185
  // that decided it, and was being printed straight into a bullet, which
182
186
  // rendered twenty five of them as [object Object]. The control centre
183
187
  // normalises the same shape; the command line was never taught to.
188
+ entries.length
189
+ ? R.block(`Entry conditions (${entryMet} of ${entries.length} met)`,
190
+ entries.map((c) => ` [${c.met ? "met" : "not met"}] ${c.condition}`).join("\n"))
191
+ : null,
184
192
  R.block(`Exit conditions (${met} of ${exits.length} met)`, exits.length
185
193
  ? exits.map((c) => R.stitch([
186
194
  ` [${c.met ? "met" : "not met"}] ${c.condition}`,
@@ -443,15 +443,23 @@ export function renderProposals(report) {
443
443
  * in one line, so the failing one is findable without reading the rest.
444
444
  */
445
445
  export function renderDoctor({ checks = [], findings = [] } = {}) {
446
- const rows = checks.map((c) => [c.name, c.ok ? "Pass" : "Problem", c.detail ?? ""]);
447
- const bad = checks.filter((c) => !c.ok).length;
446
+ // Three verdicts, because a check that threw is neither. Pass beside "could
447
+ // not be determined" was a real defect: it is the answer a reader trusts least
448
+ // once they notice it, and most when they do not.
449
+ const verdict = (ok) => (ok === true ? "Pass" : ok === false ? "Problem" : "Unknown");
450
+ const rows = checks.map((c) => [c.name, verdict(c.ok), c.detail ?? ""]);
451
+ const bad = checks.filter((c) => c.ok === false).length;
452
+ const unknown = checks.filter((c) => c.ok !== true && c.ok !== false).length;
448
453
  return stitch([
449
454
  heading("Doctor"),
450
455
  table(["Check", "Verdict", "Detail"], rows),
451
456
  "",
452
457
  bad
453
458
  ? `${plural(bad, "check")} found a problem.`
454
- : "Every check passed.",
459
+ : unknown
460
+ ? "No check found a problem."
461
+ : "Every check passed.",
462
+ unknown ? `${plural(unknown, "check")} could not determine an answer, so nothing here vouches for it.` : null,
455
463
  findings.length ? "" : null,
456
464
  findings.length ? block(`Findings (${findings.length})`, findingList(findings)) : null,
457
465
  ]);