usecontextual 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikael Endale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # usecontextual
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Contextual CLI: everything a script can do, with no AI. Zero runtime dependencies.
4
+
5
+ > **Warning:** The package is `usecontextual`. `npx contextual` runs a different, unrelated package.
6
+
7
+ Contextual isn't released yet. Two commands work in this version:
8
+
9
+ | Command | What it does |
10
+ |---|---|
11
+ | `usecontextual check [--branch <name>]` | Checks the context against the spec (section 6.1). Exit code 1 when there are errors |
12
+ | `usecontextual which <path>` | Shows who owns a file: a feature, the `shared:` line, or no one (exit code 1) |
13
+
14
+ `init`, `update`, `check --fix` and `check --staged` print "not released yet" and exit. The design is in sections 6 and 7 of [the spec](https://github.com/mikaelendale/contextual/blob/main/docs/spec.md).
15
+
16
+ ## Tests
17
+
18
+ ```bash
19
+ npm test
20
+ ```
21
+
22
+ The tests use Node's built-in runner (Node 20 or newer). The main one is that `check` passes on [`example/`](https://github.com/mikaelendale/contextual/tree/main/example) with 0 errors. The rest break copies of the example and confirm `check` catches each break, test `which`, and check that the example still matches the templates.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/cli.js"
3
+
4
+ process.exitCode = runCli(process.argv.slice(2), {
5
+ cwd: process.cwd(),
6
+ out: (line) => process.stdout.write(`${line}\n`),
7
+ err: (line) => process.stderr.write(`${line}\n`),
8
+ })
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "usecontextual",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "The Contextual CLI: adds, checks and updates the plain-markdown context that coding agents read.",
5
+ "type": "module",
6
+ "bin": {
7
+ "usecontextual": "bin/usecontextual.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test \"test/*.test.js\""
18
+ },
19
+ "license": "MIT",
20
+ "author": "Mikael Endale",
21
+ "homepage": "https://contextual.mikaelen.site",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/mikaelendale/contextual.git",
25
+ "directory": "packages/cli"
26
+ }
27
+ }
package/src/check.js ADDED
@@ -0,0 +1,371 @@
1
+ /*
2
+ * `usecontextual check`: validates a project's context against the spec
3
+ * (docs/spec.md, section 6.1). It only reads; it never changes a file.
4
+ * Not built yet: --fix and --staged (spec 6.2).
5
+ */
6
+
7
+ import { existsSync, readdirSync, statSync } from "node:fs"
8
+ import { dirname, join, relative } from "node:path"
9
+
10
+ import {
11
+ BLOCK_END,
12
+ BLOCK_START,
13
+ FEATURE_FILES,
14
+ REQUIRED_FIELDS,
15
+ STATUSES,
16
+ buildIndex,
17
+ contextualBlock,
18
+ hashText,
19
+ lineCount,
20
+ phaseIsDone,
21
+ readFeatures,
22
+ readPlan,
23
+ readShared,
24
+ readText,
25
+ statusFileFor,
26
+ } from "./context.js"
27
+ import { currentBranch, listFiles } from "./files.js"
28
+ import { matches } from "./glob.js"
29
+
30
+ const DEFAULT_CAPS = { agentsBlockLines: 60, statusLines: 40, featureReadmeLines: 60, startTokens: 3000 }
31
+ const REQUIRED_CONTEXT_FILES = [
32
+ "build-plan.md",
33
+ "architecture.md",
34
+ "conventions.md",
35
+ "status/main.md",
36
+ "features/_index.md",
37
+ "stack/_index.md",
38
+ "stack/overview.md",
39
+ "stack/requirements.md",
40
+ "stack/deploy.md",
41
+ "design/deslop.md",
42
+ "design/principles.md",
43
+ "design/layouts.md",
44
+ "design/components.md",
45
+ ]
46
+ const META_KEYS = ["contextual", "defaultBranch", "cli", "skillDirs", "lastSync", "hashes"]
47
+ const PORTABLE_SKILL_FIELDS = new Set(["name", "description", "license", "compatibility", "metadata"])
48
+ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/
49
+ const LOG_HEADING = /^(\d{4}-\d{2}-\d{2}) · \S+ · \S.*$/
50
+ const DECISION_FILE = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
51
+ const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, "")
52
+
53
+ function walkMarkdown(dir) {
54
+ if (!existsSync(dir)) return []
55
+ return readdirSync(dir).flatMap((name) => {
56
+ const path = join(dir, name)
57
+ if (statSync(path).isDirectory()) return walkMarkdown(path)
58
+ return name.endsWith(".md") ? [path] : []
59
+ })
60
+ }
61
+
62
+ /**
63
+ * @param {{ root: string, branch?: string | null }} options
64
+ * @returns {{ root: string, branch: string | null, findings: { level: "error" | "warning", file: string | null, message: string }[] }}
65
+ */
66
+ export function check({ root, branch = null }) {
67
+ const findings = []
68
+ const error = (file, message) => findings.push({ level: "error", file, message })
69
+ const warn = (file, message) => findings.push({ level: "warning", file, message })
70
+ const ctx = join(root, "context")
71
+ const rel = (path) => relative(root, path).split("\\").join("/")
72
+
73
+ // --- .meta.json
74
+ let meta = {}
75
+ try {
76
+ meta = JSON.parse(readText(join(ctx, ".meta.json")))
77
+ } catch (e) {
78
+ error("context/.meta.json", `isn't valid JSON (${e.message})`)
79
+ }
80
+ for (const key of META_KEYS) if (!(key in meta)) error("context/.meta.json", `is missing "${key}"`)
81
+ if (meta.lastSync && !("commit" in meta.lastSync && "date" in meta.lastSync)) {
82
+ error("context/.meta.json", `"lastSync" needs both "commit" and "date" (spec 3.7)`)
83
+ }
84
+ const caps = { ...DEFAULT_CAPS, ...(meta.caps ?? {}) }
85
+ const cli = meta.cli ?? "npx usecontextual"
86
+ const defaultBranch = meta.defaultBranch ?? "main"
87
+ const skillDirs = Array.isArray(meta.skillDirs) ? meta.skillDirs : []
88
+
89
+ // --- AGENTS.md and CLAUDE.md
90
+ const agentsPath = join(root, "AGENTS.md")
91
+ if (!existsSync(agentsPath)) {
92
+ error("AGENTS.md", "is missing")
93
+ } else {
94
+ const text = readText(agentsPath)
95
+ const block = contextualBlock(text)
96
+ if (!block) {
97
+ error("AGENTS.md", `has no Contextual block (${BLOCK_START} … ${BLOCK_END})`)
98
+ } else {
99
+ if (text.split(BLOCK_START).length > 2 || text.split(BLOCK_END).length > 2) error("AGENTS.md", "has more than one Contextual block")
100
+ const n = lineCount(block)
101
+ if (n > caps.agentsBlockLines) error("AGENTS.md", `the Contextual block is ${n} lines; the cap is ${caps.agentsBlockLines}`)
102
+ }
103
+ }
104
+ const claudePath = join(root, "CLAUDE.md")
105
+ if (!existsSync(claudePath)) {
106
+ error("CLAUDE.md", "is missing, so Claude Code won't read AGENTS.md. It needs the line @AGENTS.md")
107
+ } else if (!/^@AGENTS\.md[ \t]*$/m.test(readText(claudePath))) {
108
+ error("CLAUDE.md", "doesn't import AGENTS.md. Add the line @AGENTS.md")
109
+ }
110
+
111
+ // --- required files
112
+ for (const file of REQUIRED_CONTEXT_FILES) {
113
+ if (!existsSync(join(ctx, file))) error(`context/${file}`, "is missing")
114
+ }
115
+ const setupDone = existsSync(join(ctx, "idea.md"))
116
+ if (!setupDone) warn("context/idea.md", "doesn't exist yet: contextual-setup writes it")
117
+
118
+ // --- features
119
+ const features = readFeatures(root)
120
+ const byName = new Map(features.map((f) => [f.name, f]))
121
+ for (const f of features) {
122
+ const where = `context/features/${f.name}`
123
+ const readme = `${where}/README.md`
124
+ if (!KEBAB.test(f.name)) error(where, "feature folders are named in kebab-case (lowercase words joined by -)")
125
+ if (!f.readme) {
126
+ error(readme, "is missing")
127
+ continue
128
+ }
129
+ for (const field of REQUIRED_FIELDS) if (!(field in f.fields)) error(readme, `is missing its "${field}:" line`)
130
+ if ("status" in f.fields && !STATUSES.includes(f.status)) error(readme, `has status "${f.status}"; use one of: ${STATUSES.join(", ")}`)
131
+ if ("phase" in f.fields && f.phase === null) error(readme, `has phase "${f.fields.phase}"; use the phase number`)
132
+ if ("covers" in f.fields && f.covers.length === 0) error(readme, `"covers:" lists no paths`)
133
+ for (const dep of f.dependsOn) if (!byName.has(dep)) error(readme, `depends on "${dep}", which isn't a feature folder`)
134
+ if (!f.summary) error(readme, `needs a "## What it does" section; its first sentence goes in the index`)
135
+ if (f.acceptanceTotal === 0) error(readme, `needs at least one checkbox under "## Acceptance"`)
136
+ if (f.status === "shipped" && f.acceptanceTicked < f.acceptanceTotal) {
137
+ error(readme, `is shipped, but ${f.acceptanceTotal - f.acceptanceTicked} acceptance box(es) are unticked`)
138
+ }
139
+ const n = lineCount(f.readme)
140
+ if (n > caps.featureReadmeLines) error(readme, `is ${n} lines; the cap is ${caps.featureReadmeLines}. Split the feature into smaller ones`)
141
+
142
+ if (STATUSES.includes(f.status)) {
143
+ const expected = f.status === "planned" ? ["README.md"] : FEATURE_FILES
144
+ for (const file of expected) {
145
+ if (!f.files.includes(file)) error(`${where}/${file}`, `is missing (a ${f.status} feature has ${expected.join(", ")})`)
146
+ }
147
+ if (f.status === "planned") {
148
+ for (const file of FEATURE_FILES.slice(1)) {
149
+ if (f.files.includes(file)) error(`${where}/${file}`, "shouldn't exist yet: a planned feature has only README.md")
150
+ }
151
+ }
152
+ for (const file of f.files) {
153
+ if (!FEATURE_FILES.includes(file)) warn(`${where}/${file}`, "isn't one of the feature files (README, build, tests, notes, log)")
154
+ }
155
+ }
156
+ }
157
+
158
+ // --- build plan
159
+ let phases = []
160
+ if (existsSync(join(ctx, "build-plan.md"))) {
161
+ const plan = readPlan(readText(join(ctx, "build-plan.md")))
162
+ phases = plan.phases
163
+ for (const problem of plan.problems) error("context/build-plan.md", problem)
164
+ const planned = new Map()
165
+ for (const phase of phases) {
166
+ for (const item of phase.items) {
167
+ if (planned.has(item.name)) error("context/build-plan.md", `lists features/${item.name} more than once`)
168
+ planned.set(item.name, phase)
169
+ const f = byName.get(item.name)
170
+ if (!f) {
171
+ error("context/build-plan.md", `lists features/${item.name}, which has no folder`)
172
+ continue
173
+ }
174
+ if (f.phase !== null && f.phase !== phase.number) {
175
+ error(`context/features/${f.name}/README.md`, `says phase ${f.phase}, but build-plan.md has it in phase ${phase.number}`)
176
+ }
177
+ if (item.ticked && !["shipped", "deprecated"].includes(f.status)) {
178
+ error("context/build-plan.md", `features/${f.name} is ticked, but its status is ${f.status}`)
179
+ }
180
+ if (!item.ticked && f.status === "shipped") error("context/build-plan.md", `features/${f.name} is shipped but not ticked`)
181
+ }
182
+ }
183
+ for (const f of features) if (!planned.has(f.name)) error(`context/features/${f.name}`, "isn't in build-plan.md")
184
+ for (const phase of phases) {
185
+ const done = phaseIsDone(phase, byName)
186
+ if (done && phase.marker !== "done") error("context/build-plan.md", `every feature in Phase ${phase.number} is finished; mark it [done]`)
187
+ if (!done && phase.marker === "done") error("context/build-plan.md", `Phase ${phase.number} is marked [done], but not every feature in it is finished`)
188
+ }
189
+ }
190
+
191
+ // --- the feature index is derived, so it must match what would be rebuilt
192
+ const indexPath = join(ctx, "features", "_index.md")
193
+ if (existsSync(indexPath) && readText(indexPath) !== buildIndex({ cli, phases, featuresByName: byName })) {
194
+ error("context/features/_index.md", "is out of date: it doesn't match the feature READMEs and build-plan.md")
195
+ }
196
+
197
+ // --- status files
198
+ const statusDir = join(ctx, "status")
199
+ if (existsSync(statusDir)) {
200
+ for (const name of readdirSync(statusDir).filter((n) => n.endsWith(".md"))) {
201
+ const n = lineCount(readText(join(statusDir, name)))
202
+ if (n > caps.statusLines) error(`context/status/${name}`, `is ${n} lines; the cap is ${caps.statusLines}`)
203
+ }
204
+ }
205
+ const resolvedBranch = branch ?? currentBranch(root)
206
+ if (!resolvedBranch) {
207
+ warn(null, "couldn't tell the current branch, so its status file wasn't checked (pass --branch <name>)")
208
+ } else {
209
+ const file = statusFileFor(resolvedBranch, defaultBranch)
210
+ if (!existsSync(join(statusDir, file))) error(`context/status/${file}`, `is missing: the branch ${resolvedBranch} needs its status file`)
211
+ }
212
+
213
+ // --- every markdown file: links, leftover guidance, log entries
214
+ const markdown = [...(existsSync(agentsPath) ? [agentsPath] : []), ...walkMarkdown(ctx)]
215
+ for (const path of markdown) {
216
+ const text = readText(path)
217
+ const file = rel(path)
218
+ const visible = stripComments(text)
219
+ for (const m of visible.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
220
+ const target = m[1]
221
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) continue
222
+ const targetPath = target.split("#")[0].split("?")[0]
223
+ let decoded = targetPath
224
+ try {
225
+ decoded = decodeURI(targetPath)
226
+ } catch {
227
+ // keep it as written
228
+ }
229
+ if (targetPath && !existsSync(join(dirname(path), decoded))) error(file, `links to ${target}, which doesn't exist`)
230
+ }
231
+ if (setupDone && text.includes("<!-- fill:")) warn(file, "still has template guidance (<!-- fill: … -->) to replace")
232
+ if (path.endsWith(`${"log.md"}`) && file.startsWith("context/features/")) {
233
+ let previous = null
234
+ for (const m of visible.matchAll(/^## (.+)$/gm)) {
235
+ const heading = m[1].trim()
236
+ const date = heading.match(LOG_HEADING)?.[1]
237
+ if (!date) {
238
+ warn(file, `"## ${heading}" should read "## YYYY-MM-DD · agent · what was done"`)
239
+ continue
240
+ }
241
+ if (previous && date > previous) warn(file, `entries should be newest first ("${heading}" comes after an older entry)`)
242
+ previous = date
243
+ }
244
+ }
245
+ }
246
+
247
+ const decisionsDir = join(ctx, "decisions")
248
+ if (existsSync(decisionsDir)) {
249
+ for (const name of readdirSync(decisionsDir)) {
250
+ if (name.endsWith(".md") && name !== "README.md" && !DECISION_FILE.test(name)) {
251
+ warn(`context/decisions/${name}`, "decision files are named YYYY-MM-DD-<slug>.md")
252
+ }
253
+ }
254
+ }
255
+
256
+ // --- hashes (warnings only)
257
+ if (setupDone) {
258
+ const ideaHash = hashText(readText(join(ctx, "idea.md")))
259
+ if (!meta.hashes?.idea) warn("context/.meta.json", "has no hash for idea.md yet")
260
+ else if (meta.hashes.idea !== ideaHash) warn("context/idea.md", "has changed since setup. It holds the owner's own words and shouldn't be edited")
261
+ }
262
+ const deslopPath = join(ctx, "design", "deslop.md")
263
+ if (existsSync(deslopPath) && meta.hashes?.deslop && meta.hashes.deslop !== hashText(readText(deslopPath))) {
264
+ warn("context/design/deslop.md", `differs from the version Contextual ships; \`${cli} update\` restores it`)
265
+ }
266
+
267
+ // --- skills: present, valid, identical in every folder
268
+ const present = skillDirs.filter((d) => existsSync(join(root, d)))
269
+ if (skillDirs.length > 0 && present.length === 0) {
270
+ warn(null, `the skills aren't installed (${skillDirs.join(", ")}); \`${cli} update\` installs them`)
271
+ } else {
272
+ for (const d of skillDirs) if (!present.includes(d)) warn(d, "is missing; .meta.json lists it as a skill folder")
273
+ for (const d of present) {
274
+ for (const name of readdirSync(join(root, d))) {
275
+ if (statSync(join(root, d, name)).isDirectory()) checkSkill(join(root, d, name), `${d}/${name}`, name, error)
276
+ }
277
+ }
278
+ for (const d of present.slice(1)) compareTrees(join(root, present[0]), join(root, d), present[0], d, error)
279
+ }
280
+
281
+ // --- who owns each file (spec 3.8)
282
+ const architecturePath = join(ctx, "architecture.md")
283
+ const shared = existsSync(architecturePath) ? readShared(readText(architecturePath)) : null
284
+ if (existsSync(architecturePath) && shared === null) {
285
+ warn("context/architecture.md", `has no "shared:" line, so shared code can't be told apart from code no one owns`)
286
+ }
287
+ const ownFile = (p) =>
288
+ p.startsWith("context/") || p === "AGENTS.md" || p === "CLAUDE.md" || skillDirs.some((d) => p.startsWith(`${d.replace(/\/$/, "")}/`))
289
+ const projectFiles = listFiles(root).filter((p) => !ownFile(p))
290
+ const codeFiles = projectFiles.filter((p) => p.includes("/") || !/^(LICENSE.*|[^/]+\.(md|txt))$/i.test(p))
291
+ if (codeFiles.length === 0) {
292
+ warn(null, "found no code outside context/, so the covers: and shared: paths weren't checked")
293
+ } else {
294
+ for (const f of features) {
295
+ if (f.status !== "shipped" && f.status !== "in-progress") continue
296
+ for (const glob of f.covers) {
297
+ if (projectFiles.some((p) => matches(p, glob))) continue
298
+ const report = f.status === "shipped" ? error : warn
299
+ report(`context/features/${f.name}/README.md`, `covers ${glob}, which matches no files`)
300
+ }
301
+ }
302
+ for (const glob of shared ?? []) {
303
+ if (!projectFiles.some((p) => matches(p, glob))) warn("context/architecture.md", `shared: ${glob} matches no files`)
304
+ }
305
+ for (const p of projectFiles) {
306
+ const owners = features.filter((f) => f.covers.some((g) => matches(p, g))).map((f) => f.name)
307
+ const isShared = (shared ?? []).some((g) => matches(p, g))
308
+ if (owners.length > 1) error(p, `is covered by more than one feature: ${owners.join(", ")}`)
309
+ else if (owners.length === 1 && isShared) error(p, `is covered by ${owners[0]} and is also on the shared: line`)
310
+ }
311
+ }
312
+
313
+ return { root, branch: resolvedBranch, findings }
314
+ }
315
+
316
+ function readFrontmatter(text) {
317
+ if (!text.startsWith("---\n")) return null
318
+ const end = text.indexOf("\n---", 3)
319
+ if (end === -1) return null
320
+ const fields = {}
321
+ const lines = text.slice(4, end).split("\n")
322
+ for (let i = 0; i < lines.length; i++) {
323
+ const m = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
324
+ if (!m) continue
325
+ let value = m[2].trim()
326
+ if (/^[>|][+-]?$/.test(value)) {
327
+ const block = []
328
+ while (i + 1 < lines.length && /^\s+\S|^\s*$/.test(lines[i + 1])) block.push(lines[++i].trim())
329
+ value = block.join(" ").trim()
330
+ }
331
+ fields[m[1]] = value.replace(/^(["'])(.*)\1$/, "$2")
332
+ }
333
+ return fields
334
+ }
335
+
336
+ function checkSkill(dir, label, folderName, error) {
337
+ const skillPath = join(dir, "SKILL.md")
338
+ if (!existsSync(skillPath)) {
339
+ error(`${label}/SKILL.md`, "is missing")
340
+ return
341
+ }
342
+ const fields = readFrontmatter(readText(skillPath))
343
+ if (!fields) {
344
+ error(`${label}/SKILL.md`, "needs frontmatter that starts on line 1 with --- and closes with ---")
345
+ return
346
+ }
347
+ for (const key of Object.keys(fields)) {
348
+ if (!PORTABLE_SKILL_FIELDS.has(key)) error(`${label}/SKILL.md`, `uses "${key}", which not every harness accepts (use name, description, license, compatibility, metadata)`)
349
+ }
350
+ const name = fields.name ?? ""
351
+ if (name !== folderName) error(`${label}/SKILL.md`, `name "${name}" must equal the folder name "${folderName}"`)
352
+ if (!KEBAB.test(name) || name.length > 64) error(`${label}/SKILL.md`, "name must be lowercase letters, digits and single hyphens, 64 characters at most")
353
+ const description = fields.description ?? ""
354
+ if (description.length === 0 || description.length > 1024) error(`${label}/SKILL.md`, `description must be 1 to 1,024 characters (it is ${description.length})`)
355
+ }
356
+
357
+ function compareTrees(a, b, labelA, labelB, error) {
358
+ const list = (dir, prefix = "") =>
359
+ readdirSync(dir)
360
+ .sort()
361
+ .flatMap((name) => (statSync(join(dir, name)).isDirectory() ? list(join(dir, name), `${prefix}${name}/`) : [`${prefix}${name}`]))
362
+ const filesA = list(a)
363
+ const filesB = list(b)
364
+ for (const f of new Set([...filesA, ...filesB])) {
365
+ if (!filesA.includes(f) || !filesB.includes(f)) {
366
+ error(`${filesA.includes(f) ? labelB : labelA}/${f}`, `is missing; the skill copies in ${labelA} and ${labelB} must be identical`)
367
+ } else if (readText(join(a, f)) !== readText(join(b, f))) {
368
+ error(`${labelB}/${f}`, `differs from ${labelA}/${f}; the skill copies must be identical`)
369
+ }
370
+ }
371
+ }
package/src/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+
4
+ import { check } from "./check.js"
5
+ import { findRoot } from "./context.js"
6
+ import { which } from "./which.js"
7
+
8
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
9
+ const DOCS = "https://github.com/mikaelendale/contextual"
10
+
11
+ const HELP = `usecontextual ${version}
12
+
13
+ Usage:
14
+ usecontextual check [--branch <name>] check the context in this repo
15
+ usecontextual which <path> show which feature owns a file
16
+
17
+ Not released yet: init, update, check --fix, check --staged.
18
+ Docs: ${DOCS}`
19
+
20
+ function parseArgs(args) {
21
+ const flags = { _: [] }
22
+ for (let i = 0; i < args.length; i++) {
23
+ const arg = args[i]
24
+ if (arg.startsWith("--")) {
25
+ const [key, inline] = arg.slice(2).split("=", 2)
26
+ if (inline !== undefined) flags[key] = inline
27
+ else if (args[i + 1] !== undefined && !args[i + 1].startsWith("--") && key === "branch") flags[key] = args[++i]
28
+ else flags[key] = true
29
+ } else {
30
+ flags._.push(arg)
31
+ }
32
+ }
33
+ return flags
34
+ }
35
+
36
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`
37
+
38
+ function notReleased(what, err) {
39
+ err(`usecontextual ${what}: not released yet. See ${DOCS}`)
40
+ return 2
41
+ }
42
+
43
+ /** Runs the CLI and returns its exit code: 0 ok, 1 problems found, 2 usage errors and commands that aren't released yet. */
44
+ export function runCli(argv, { cwd, out, err }) {
45
+ const [command, ...rest] = argv
46
+ const flags = parseArgs(rest)
47
+
48
+ if (!command || command === "help" || command === "--help" || command === "-h") {
49
+ out(HELP)
50
+ return 0
51
+ }
52
+ if (command === "--version" || command === "-v") {
53
+ out(version)
54
+ return 0
55
+ }
56
+ if (command === "init" || command === "update") return notReleased(command, err)
57
+ if (command === "check" && (flags.fix || flags.staged)) return notReleased(`check --${flags.fix ? "fix" : "staged"}`, err)
58
+ if (command !== "check" && command !== "which") {
59
+ err(`Unknown command "${command}". Run usecontextual --help.`)
60
+ return 2
61
+ }
62
+
63
+ const root = findRoot(cwd)
64
+ if (!root) {
65
+ err("No context/.meta.json here or in any folder above it. Run `npx usecontextual init` first.")
66
+ return 2
67
+ }
68
+
69
+ if (command === "check") {
70
+ if (flags.branch === true) {
71
+ err("--branch needs a branch name.")
72
+ return 2
73
+ }
74
+ const result = check({ root, branch: typeof flags.branch === "string" ? flags.branch : null })
75
+ const errors = result.findings.filter((f) => f.level === "error")
76
+ const warnings = result.findings.filter((f) => f.level === "warning")
77
+ out(`usecontextual check · ${basename(root)}${result.branch ? ` · branch ${result.branch}` : ""}`)
78
+ for (const f of [...errors, ...warnings]) {
79
+ out(` ${f.level.padEnd(7)} ${f.file ? `${f.file}: ` : ""}${f.message}`)
80
+ }
81
+ out(`${plural(errors.length, "error")}, ${plural(warnings.length, "warning")}`)
82
+ return errors.length > 0 ? 1 : 0
83
+ }
84
+
85
+ const target = flags._[0]
86
+ if (!target) {
87
+ err("Usage: usecontextual which <path>")
88
+ return 2
89
+ }
90
+ const result = which({ root, path: target, cwd })
91
+ if (result.outside) {
92
+ err(`${target} isn't inside this project.`)
93
+ return 2
94
+ }
95
+ if (result.contextual) {
96
+ out(`${result.path} is one of Contextual's own files; it needs no owner.`)
97
+ return 0
98
+ }
99
+ if (result.owners.length > 1) {
100
+ for (const f of result.owners) out(`${f.name} context/features/${f.name}/README.md (${f.status})`)
101
+ out("More than one feature covers this path. `usecontextual check` reports it as an error.")
102
+ return 2
103
+ }
104
+ if (result.owners.length === 1) {
105
+ const f = result.owners[0]
106
+ out(`${f.name} context/features/${f.name}/README.md (${f.status})`)
107
+ if (result.shared) out("It's also on the shared: line, which `usecontextual check` reports as an error.")
108
+ return 0
109
+ }
110
+ if (result.shared) {
111
+ out("shared (the shared: line in context/architecture.md)")
112
+ return 0
113
+ }
114
+ out("no feature owns this path")
115
+ return 1
116
+ }
package/src/context.js ADDED
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Reading a project's context: feature READMEs, the build plan, the shared
3
+ * list, .meta.json, the AGENTS.md block. Pure parsing, no judgment.
4
+ */
5
+
6
+ import { createHash } from "node:crypto"
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
8
+ import { dirname, join, resolve } from "node:path"
9
+
10
+ export const STATUSES = ["planned", "in-progress", "shipped", "deprecated"]
11
+ export const FEATURE_FILES = ["README.md", "build.md", "tests.md", "notes.md", "log.md"]
12
+ export const REQUIRED_FIELDS = ["status", "phase", "covers", "depends-on"]
13
+ export const BLOCK_START = "<!-- contextual:start -->"
14
+ export const BLOCK_END = "<!-- contextual:end -->"
15
+
16
+ /** File text with line endings normalized to LF. */
17
+ export function readText(path) {
18
+ return readFileSync(path, "utf8").replace(/\r\n/g, "\n")
19
+ }
20
+
21
+ export function lineCount(text) {
22
+ if (!text) return 0
23
+ return text.replace(/\n$/, "").split("\n").length
24
+ }
25
+
26
+ /** sha256 of the LF-normalized text, as stored in .meta.json. */
27
+ export function hashText(text) {
28
+ return `sha256:${createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`
29
+ }
30
+
31
+ /** The nearest folder at or above `start` that has context/.meta.json. */
32
+ export function findRoot(start) {
33
+ let dir = resolve(start)
34
+ for (;;) {
35
+ if (existsSync(join(dir, "context", ".meta.json"))) return dir
36
+ const parent = dirname(dir)
37
+ if (parent === dir) return null
38
+ dir = parent
39
+ }
40
+ }
41
+
42
+ /** feat/boards → feat-boards (spec 3.4). */
43
+ export function branchSlug(branch) {
44
+ return branch.toLowerCase().replace(/[^a-z0-9._-]/g, "-")
45
+ }
46
+
47
+ /** The status file name for a branch: the default branch always uses main.md. */
48
+ export function statusFileFor(branch, defaultBranch) {
49
+ return branch === defaultBranch ? "main.md" : `${branchSlug(branch)}.md`
50
+ }
51
+
52
+ export function splitList(value) {
53
+ return (value ?? "")
54
+ .split(",")
55
+ .map((s) => s.trim())
56
+ .filter(Boolean)
57
+ }
58
+
59
+ /** The first sentence of a paragraph, which becomes the feature's line in the index. */
60
+ export function firstSentence(paragraph) {
61
+ const text = paragraph.trim().split("\n\n")[0].replace(/\s*\n\s*/g, " ")
62
+ const match = text.match(/^.*?[.!?](?=\s|$)/)
63
+ return (match ? match[0] : text).trim()
64
+ }
65
+
66
+ export function readFeature(featuresDir, name) {
67
+ const dir = join(featuresDir, name)
68
+ const files = readdirSync(dir).sort()
69
+ const feature = { name, dir, files, fields: {}, readme: null }
70
+ const readmePath = join(dir, "README.md")
71
+ if (!existsSync(readmePath)) return feature
72
+
73
+ const text = readText(readmePath)
74
+ feature.readme = text
75
+ feature.title = (text.match(/^# (.+)$/m) ?? [])[1] ?? null
76
+ const head = text.split(/^## /m)[0]
77
+ for (const line of head.split("\n")) {
78
+ const m = line.match(/^([a-z-]+):\s*(.*)$/)
79
+ if (m) feature.fields[m[1]] = m[2].trim()
80
+ }
81
+ feature.status = feature.fields.status
82
+ feature.phase = /^\d+$/.test(feature.fields.phase ?? "") ? Number(feature.fields.phase) : null
83
+ feature.covers = splitList(feature.fields.covers)
84
+ feature.dependsOn = splitList(feature.fields["depends-on"]).filter((d) => d !== "none")
85
+
86
+ const what = text.split(/^## What it does\n/m)[1]
87
+ feature.summary = what ? firstSentence(what.split(/^## /m)[0]) : null
88
+
89
+ const acceptance = (text.split(/^## Acceptance\n/m)[1] ?? "").split(/^## /m)[0]
90
+ feature.acceptanceTotal = (acceptance.match(/^- \[[ x]\] /gm) ?? []).length
91
+ feature.acceptanceTicked = (acceptance.match(/^- \[x\] /gm) ?? []).length
92
+ return feature
93
+ }
94
+
95
+ export function readFeatures(root) {
96
+ const featuresDir = join(root, "context", "features")
97
+ if (!existsSync(featuresDir)) return []
98
+ return readdirSync(featuresDir)
99
+ .filter((name) => statSync(join(featuresDir, name)).isDirectory())
100
+ .sort()
101
+ .map((name) => readFeature(featuresDir, name))
102
+ }
103
+
104
+ /**
105
+ * build-plan.md → phases. Headings that don't parse come back in `problems`
106
+ * rather than being guessed at.
107
+ */
108
+ export function readPlan(text) {
109
+ const phases = []
110
+ const problems = []
111
+ for (const block of text.split(/^## /m).slice(1)) {
112
+ const heading = block.split("\n")[0].trim()
113
+ const m = heading.match(/^Phase (\d+) — (.+?)\s+\[(planned|in progress|done)\]$/)
114
+ if (!m) {
115
+ problems.push(`can't read the phase heading "## ${heading}" (expected "## Phase 1 — Name [planned]")`)
116
+ continue
117
+ }
118
+ const items = []
119
+ for (const line of block.split("\n").slice(1)) {
120
+ const item = line.match(/^- \[([ x])\] features\/(\S+)/)
121
+ if (item) items.push({ name: item[2], ticked: item[1] === "x" })
122
+ }
123
+ phases.push({ number: Number(m[1]), name: m[2], marker: m[3], items })
124
+ }
125
+ return { phases, problems }
126
+ }
127
+
128
+ /** The globs on the `shared:` line of architecture.md, or null when there is no such line. */
129
+ export function readShared(architectureText) {
130
+ const m = architectureText.match(/^shared:[ \t]*(.*)$/m)
131
+ return m ? splitList(m[1]) : null
132
+ }
133
+
134
+ /** The Contextual block of AGENTS.md, markers included, or null. */
135
+ export function contextualBlock(agentsText) {
136
+ const start = agentsText.indexOf(BLOCK_START)
137
+ const end = agentsText.indexOf(BLOCK_END)
138
+ if (start === -1 || end === -1 || end < start) return null
139
+ return agentsText.slice(start, end + BLOCK_END.length)
140
+ }
141
+
142
+ const DONE = new Set(["shipped", "deprecated"])
143
+
144
+ /** Is every feature in this phase shipped or deprecated? */
145
+ export function phaseIsDone(phase, featuresByName) {
146
+ return phase.items.length > 0 && phase.items.every((item) => DONE.has(featuresByName.get(item.name)?.status))
147
+ }
148
+
149
+ /**
150
+ * The feature index, rebuilt from the READMEs and the plan (spec 4.3): a
151
+ * finished phase is one line; an open phase is a heading and a table.
152
+ */
153
+ export function buildIndex({ cli, phases, featuresByName }) {
154
+ const header = `# Features\nRebuilt by \`${cli} check --fix\` from each feature's README and \`build-plan.md\`. Don't edit by hand.\n`
155
+ if (phases.length === 0) {
156
+ return `${header}\nNo features yet. \`contextual-setup\` adds them from the build plan.\n`
157
+ }
158
+ const collapsed = []
159
+ const open = []
160
+ for (const phase of phases) {
161
+ if (phaseIsDone(phase, featuresByName)) {
162
+ collapsed.push(`Phase ${phase.number} — ${phase.name}: done · ${phase.items.map((i) => i.name).join(", ")}`)
163
+ continue
164
+ }
165
+ const rows = phase.items
166
+ .filter((item) => featuresByName.has(item.name))
167
+ .map((item) => {
168
+ const f = featuresByName.get(item.name)
169
+ return `| [${f.name}](${f.name}/README.md) | ${f.status} | ${f.summary} |`
170
+ })
171
+ open.push(`## Phase ${phase.number} — ${phase.name}\n| Feature | Status | What it does |\n|---|---|---|\n${rows.join("\n")}`)
172
+ }
173
+ let out = header
174
+ if (collapsed.length) out += `\n${collapsed.join("\n")}\n`
175
+ for (const section of open) out += `\n${section}\n`
176
+ return out
177
+ }
package/src/files.js ADDED
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { readdirSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".turbo", ".vercel", "dist", "build", "coverage"])
6
+
7
+ /**
8
+ * Every file in the project, relative to `root`, with forward slashes. Uses git
9
+ * when it can, so `.gitignore` applies; otherwise walks the folder.
10
+ */
11
+ export function listFiles(root) {
12
+ try {
13
+ const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
14
+ cwd: root,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ })
18
+ return out.split("\0").filter(Boolean)
19
+ } catch {
20
+ const files = []
21
+ const walk = (dir, prefix) => {
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ if (entry.isDirectory()) {
24
+ if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), `${prefix}${entry.name}/`)
25
+ } else {
26
+ files.push(`${prefix}${entry.name}`)
27
+ }
28
+ }
29
+ }
30
+ walk(root, "")
31
+ return files
32
+ }
33
+ }
34
+
35
+ /** The current git branch in `root`, or null when git can't tell (not a repo, detached HEAD). */
36
+ export function currentBranch(root) {
37
+ try {
38
+ const out = execFileSync("git", ["branch", "--show-current"], {
39
+ cwd: root,
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ }).trim()
43
+ if (out) return out
44
+ } catch {
45
+ // not a git repo
46
+ }
47
+ return process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || null
48
+ }
package/src/glob.js ADDED
@@ -0,0 +1,46 @@
1
+ /*
2
+ * Glob matching for `covers:` and `shared:` lines.
3
+ *
4
+ * `**` matches any number of folders (including none), `*` matches within one
5
+ * path segment, `?` matches one character. Everything else is literal,
6
+ * including the brackets and parentheses Next.js uses in route folders, so
7
+ * `src/app/(auth)/**` and `src/app/boards/[boardId]/page.tsx` mean what they say.
8
+ */
9
+
10
+ const cache = new Map()
11
+
12
+ export function globToRegExp(glob) {
13
+ let re = ""
14
+ for (let i = 0; i < glob.length; i++) {
15
+ const c = glob[i]
16
+ if (c === "*" && glob[i + 1] === "*") {
17
+ if (glob[i + 2] === "/") {
18
+ re += "(?:.*/)?" // "**/" is zero or more folders
19
+ i += 2
20
+ } else {
21
+ re += ".*" // a trailing "**" is everything below
22
+ i += 1
23
+ }
24
+ } else if (c === "*") {
25
+ re += "[^/]*"
26
+ } else if (c === "?") {
27
+ re += "[^/]"
28
+ } else {
29
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&")
30
+ }
31
+ }
32
+ return new RegExp(`^${re}$`)
33
+ }
34
+
35
+ export function matches(path, glob) {
36
+ let re = cache.get(glob)
37
+ if (!re) {
38
+ re = globToRegExp(glob)
39
+ cache.set(glob, re)
40
+ }
41
+ return re.test(path)
42
+ }
43
+
44
+ export function matchesAny(path, globs) {
45
+ return globs.some((glob) => matches(path, glob))
46
+ }
package/src/which.js ADDED
@@ -0,0 +1,31 @@
1
+ /*
2
+ * `usecontextual which <path>`: who owns a file (spec 3.8). A feature through
3
+ * its `covers:` globs, the `shared:` line in architecture.md, or no one.
4
+ */
5
+
6
+ import { existsSync } from "node:fs"
7
+ import { isAbsolute, join, relative, resolve } from "node:path"
8
+
9
+ import { readFeatures, readShared, readText } from "./context.js"
10
+ import { matches } from "./glob.js"
11
+
12
+ export function which({ root, path, cwd = root }) {
13
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path)
14
+ const rel = relative(root, absolute).split("\\").join("/")
15
+ if (rel === "" || rel.startsWith("../") || rel === ".." || isAbsolute(rel)) return { path: rel, outside: true }
16
+
17
+ let skillDirs = []
18
+ try {
19
+ skillDirs = JSON.parse(readText(join(root, "context", ".meta.json"))).skillDirs ?? []
20
+ } catch {
21
+ // check reports a broken .meta.json
22
+ }
23
+ const contextual =
24
+ rel.startsWith("context/") || rel === "AGENTS.md" || rel === "CLAUDE.md" || skillDirs.some((d) => rel.startsWith(`${d.replace(/\/$/, "")}/`))
25
+ if (contextual) return { path: rel, contextual: true, owners: [], shared: false }
26
+
27
+ const owners = readFeatures(root).filter((f) => f.covers.some((glob) => matches(rel, glob)))
28
+ const architecture = join(root, "context", "architecture.md")
29
+ const shared = existsSync(architecture) ? (readShared(readText(architecture)) ?? []) : []
30
+ return { path: rel, owners, shared: shared.some((glob) => matches(rel, glob)) }
31
+ }