inclusion-md 0.1.0 → 0.2.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/README.md CHANGED
@@ -94,11 +94,51 @@ npx inclusion-md init --variant design-system # start from a domain example
94
94
  npx inclusion-md init --out docs/INCLUSION.md # write somewhere else
95
95
  npx inclusion-md init --yes # accept defaults, non-interactive
96
96
  npx inclusion-md init --force # overwrite without prompting
97
+ npx inclusion-md update # re-run questionnaire on existing file
97
98
  npx inclusion-md --help
98
99
  ```
99
100
 
100
101
  Requires Node.js 16+. No dependencies.
101
102
 
103
+ #### The Design Decisions questionnaire
104
+
105
+ After the foundational questions, the CLI offers an opt-in **Design Decisions**
106
+ questionnaire - 13 short, skippable questions across six groups:
107
+
108
+ 1. **Core assumptions** - who you primarily build for, and what "default user"
109
+ assumptions live inside your product
110
+ 2. **Authentication & access** - how people get in, who can't, why
111
+ 3. **Information collection** - what you ask for, what's required, why
112
+ 4. **Interaction model** - how people interact, what patterns you don't support
113
+ 5. **Communication & language** - languages and tone you optimize for
114
+ 6. **Edge cases & intersections** - where the product breaks, who shows up
115
+ in unexpected ways
116
+
117
+ The questions are grounded in three sources:
118
+
119
+ - _ABLEIST: Measuring Ableist Harms in LLMs_ ([arxiv.org/abs/2510.10998](https://arxiv.org/abs/2510.10998))
120
+ - _Centering Disability Perspectives in LLM Research and Design_ (ACM)
121
+ - Kat Holmes, _Mismatch: How Inclusion Shapes Design_ (MIT Press)
122
+
123
+ The point isn't perfect answers. The point is **conscious, documented
124
+ tradeoffs**. Skipping a question is a valid answer; the doc just won't speak
125
+ to that dimension yet. Run `npx inclusion-md update` later to fill more in.
126
+
127
+ Answers land in your `INCLUSION.md` as a `### 1.B Design Decisions` subsection
128
+ under Project Context. Reviewers (human and AI) can flag generated output that
129
+ contradicts the realities you've documented.
130
+
131
+ #### Updating an existing INCLUSION.md
132
+
133
+ ```bash
134
+ npx inclusion-md update
135
+ ```
136
+
137
+ This re-runs the Project Context + Design Decisions + Maintenance questions
138
+ and rewrites **only** Sections 1 and 12 in place. Any edits you've made to
139
+ the rest of the file - your engineering guidance, your language heuristics,
140
+ your custom sections - are preserved.
141
+
102
142
  ### Option B: Copy the file by hand
103
143
 
104
144
  ```bash
@@ -152,7 +192,7 @@ finalizing any generated output in this repository.
152
192
  **Continue / Windsurf / Cody / etc.** - add `INCLUSION.md` to your workspace
153
193
  context configuration.
154
194
 
155
- ### 4. Treat it like the rest of your engineering docs
195
+ ### Treat it like the rest of your engineering docs
156
196
 
157
197
  - Name an owner.
158
198
  - Review on a cadence (quarterly recommended).
@@ -193,6 +233,37 @@ disabled people - with authority, budget, and time.
193
233
 
194
234
  ---
195
235
 
236
+ ## Troubleshooting
237
+
238
+ **"`npx inclusion-md` does nothing / hangs."**
239
+ Make sure you're on Node.js 16+ (`node --version`). The CLI uses only
240
+ built-in modules - no install step is required.
241
+
242
+ **"I can't find an existing `INCLUSION.md`" when running `update`.**
243
+ By default the CLI looks at `./INCLUSION.md`. If yours lives elsewhere,
244
+ pass `--out`:
245
+
246
+ ```bash
247
+ npx inclusion-md update --out docs/INCLUSION.md
248
+ ```
249
+
250
+ **The welcome animation looks weird in CI.**
251
+ It's automatically skipped when stdout isn't a TTY, when `--no-color` is
252
+ passed, or when `CI=1` is set. If you're piping output, pass `--no-color`
253
+ explicitly to be safe.
254
+
255
+ **I want to script this in a setup wizard.**
256
+ Pass `--yes` to accept all defaults and skip the optional Design Decisions
257
+ questionnaire. Combine with `--variant`, `--out`, and `--force` for a fully
258
+ non-interactive run.
259
+
260
+ **I edited my `INCLUSION.md` and I'm worried `update` will trash it.**
261
+ `update` only touches Section 1 (Project Context) and Section 12
262
+ (Maintenance). Everything else is preserved verbatim. The smoke tests
263
+ ([`test/smoke.test.js`](./test/smoke.test.js)) cover this contract.
264
+
265
+ ---
266
+
196
267
  ## Contributing
197
268
 
198
269
  Pull requests, issues, translations, domain-specific extensions, and critiques
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  /* eslint-disable no-console */
3
3
 
4
- const { init } = require("../lib/init");
4
+ const { init, update } = require("../lib/init");
5
5
 
6
- const HELP = `inclusion-md - scaffold an INCLUSION.md for your repository.
6
+ const HELP = `inclusion-md - scaffold and maintain an INCLUSION.md for your repository.
7
7
 
8
8
  Usage:
9
- npx inclusion-md init [options]
9
+ npx inclusion-md <command> [options]
10
10
  npx inclusion-md --help
11
11
 
12
12
  Commands:
13
13
  init Interactively generate an INCLUSION.md.
14
+ Includes an opt-in 13-question Design Decisions
15
+ questionnaire that surfaces the real tradeoffs
16
+ your product has made.
17
+ update Re-run the questionnaire against an existing
18
+ INCLUSION.md. Rewrites Section 1 (Project Context)
19
+ and Section 12 (Maintenance) in place. Everything
20
+ else - including your edits - is preserved.
14
21
 
15
22
  Options:
16
23
  -o, --out <path> Output path (default: ./INCLUSION.md).
17
- --variant <name> Start from a variant template:
24
+ --variant <name> Start from a variant template (init only):
18
25
  generic (default) | frontend-app | design-system | backend-api
19
26
  --force Overwrite an existing INCLUSION.md without prompting.
20
- --yes Accept defaults for any unanswered prompts.
21
- --no-color Disable ANSI color output.
27
+ -y, --yes Accept defaults for any unanswered prompts.
28
+ Skips the optional Design Decisions questionnaire.
29
+ --no-color Disable ANSI color output and skip the welcome animation.
22
30
  -h, --help Show this help.
23
31
  -v, --version Show CLI version.
24
32
 
@@ -26,8 +34,18 @@ Examples:
26
34
  npx inclusion-md init
27
35
  npx inclusion-md init --variant design-system
28
36
  npx inclusion-md init --out docs/INCLUSION.md --force
37
+ npx inclusion-md update
38
+ npx inclusion-md update --out docs/INCLUSION.md
39
+
40
+ Troubleshooting:
41
+ - "I can't find an existing INCLUSION.md" - run \`init\` first, or pass
42
+ --out to point at the right path.
43
+ - Animation looks off in CI - it's automatically skipped when stdout is
44
+ not a TTY, when --no-color is passed, or when CI=1 is set.
45
+ - Want to script this? Use --yes to skip prompts and accept defaults.
29
46
 
30
47
  Read more: https://github.com/BranonConor/inclusion.md
48
+ Companion essay: https://branon.dev/blog/posts/the-need-for-inclusion-md
31
49
  `;
32
50
 
33
51
  function parseArgs(argv) {
@@ -74,14 +92,16 @@ async function main() {
74
92
  }
75
93
  if (!args.command) args.command = "init";
76
94
 
77
- if (args.command !== "init") {
95
+ const COMMANDS = { init, update };
96
+ const handler = COMMANDS[args.command];
97
+ if (!handler) {
78
98
  console.error(`Unknown command: ${args.command}`);
79
99
  process.stdout.write("\n" + HELP);
80
100
  process.exit(2);
81
101
  }
82
102
 
83
103
  try {
84
- await init(args);
104
+ await handler(args);
85
105
  } catch (err) {
86
106
  if (err && err.code === "USER_CANCELLED") {
87
107
  console.error("\nCancelled. No file was written.");
@@ -92,4 +112,8 @@ async function main() {
92
112
  }
93
113
  }
94
114
 
95
- main();
115
+ if (require.main === module) {
116
+ main();
117
+ }
118
+
119
+ module.exports = { parseArgs, HELP };
package/lib/ascii.js ADDED
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+
3
+ const c = require("./colors");
4
+
5
+ /**
6
+ * Gentle 3-frame welcome animation. Conveys "coming together" via dots that
7
+ * gradually connect. ASCII-only, no flashing, no inverted colors, no Unicode.
8
+ *
9
+ * Accessibility:
10
+ * - Skipped automatically when stdout is not a TTY (CI, piped output).
11
+ * - Skipped when --no-color is passed (color is the cue, not motion).
12
+ * - Skipped when --yes is passed (non-interactive runs).
13
+ * - Frames change appearance gradually (dim -> normal -> bold), not flashing.
14
+ * - Total runtime under 1 second.
15
+ */
16
+
17
+ const FRAMES = [
18
+ [" . . . ", " ", " . . . "],
19
+ [" o o o ", " ", " o o o "],
20
+ [" o-----o-----o ", " | ", " o-----o-----o "],
21
+ ];
22
+
23
+ function renderFrame(frame, intensity) {
24
+ // intensity: 0 = dim, 1 = normal, 2 = bold
25
+ const stylize =
26
+ intensity === 0 ? c.dim : intensity === 2 ? c.bold : (s) => s;
27
+ return frame.map((line) => stylize(line)).join("\n");
28
+ }
29
+
30
+ function clearFrame(lineCount) {
31
+ for (let i = 0; i < lineCount; i++) {
32
+ process.stdout.write("\x1b[1A\x1b[2K");
33
+ }
34
+ }
35
+
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ async function play({ enabled = true, frameMs = 220 } = {}) {
41
+ if (!enabled) return;
42
+ for (let i = 0; i < FRAMES.length; i++) {
43
+ const intensity = i === FRAMES.length - 1 ? 2 : i;
44
+ process.stdout.write(renderFrame(FRAMES[i], intensity) + "\n");
45
+ if (i < FRAMES.length - 1) {
46
+ await sleep(frameMs);
47
+ clearFrame(FRAMES[i].length + 1);
48
+ }
49
+ }
50
+ }
51
+
52
+ function shouldAnimate({ noColor, yes }) {
53
+ if (yes) return false;
54
+ if (noColor) return false;
55
+ if (!process.stdout.isTTY) return false;
56
+ if (process.env.CI) return false;
57
+ return true;
58
+ }
59
+
60
+ module.exports = { play, shouldAnimate, FRAMES };
package/lib/init.js CHANGED
@@ -6,33 +6,142 @@ const path = require("path");
6
6
  const c = require("./colors");
7
7
  const { createPrompter } = require("./prompt");
8
8
  const { loadTemplate, renderGeneric, VARIANTS } = require("./template");
9
+ const ascii = require("./ascii");
10
+ const { runQuestionnaire, TOTAL_QUESTIONS } = require("./questionnaire");
9
11
 
10
- const BANNER = `
11
- ${c.magenta(c.bold("INCLUSION.md"))} ${c.dim("scaffolder")}
12
- ${c.dim("An LLM/agent context convention for model biases - https://github.com/BranonConor/inclusion.md")}
13
- `;
12
+ const REPO_URL = "https://github.com/BranonConor/inclusion.md";
13
+ const ESSAY_URL = "https://branon.dev/blog/posts/the-need-for-inclusion-md";
14
14
 
15
- async function init(args) {
15
+ async function welcome(args) {
16
16
  c.set(args.color);
17
+ await ascii.play({
18
+ enabled: ascii.shouldAnimate({ noColor: !args.color, yes: args.yes }),
19
+ });
20
+ process.stdout.write(
21
+ "\n" +
22
+ c.magenta(c.bold("Welcome to the INCLUSION.md CLI!")) +
23
+ " " +
24
+ c.dim("(v" + require("../package.json").version + ")") +
25
+ "\n\n",
26
+ );
27
+ }
28
+
29
+ async function askProjectContext(p) {
30
+ process.stdout.write(
31
+ "\n" +
32
+ c.bold("Project Context") +
33
+ "\n" +
34
+ c.dim(
35
+ "These fill in Section 1. Be specific - generic answers make for generic guidance.\n",
36
+ ) +
37
+ "\n",
38
+ );
39
+
40
+ const product = await p.text("What does this project do?", {
41
+ default: "A short description of what this software does.",
42
+ });
43
+ const primaryUsers = await p.text(
44
+ "Who have you intentionally designed for?",
45
+ { default: "Describe your primary users." },
46
+ );
47
+ const underservedUsers = await p.text(
48
+ "Who do you know you haven't designed for well yet?",
49
+ {
50
+ default:
51
+ "Document a real gap (e.g. screen reader users on the dashboard; Spanish-language onboarding).",
52
+ },
53
+ );
54
+ const distribution = await p.text(
55
+ "Where is this software used? (geography, devices, network, assistive tech)",
56
+ {
57
+ default:
58
+ "Web/iOS/Android, primary regions, languages, common assistive technologies.",
59
+ },
60
+ );
61
+ const stakes = await p.text(
62
+ "What happens when this software excludes someone?",
63
+ {
64
+ default:
65
+ "Describe the real-world cost (loss of access to healthcare, employment, civic services).",
66
+ },
67
+ );
68
+
69
+ return { product, primaryUsers, underservedUsers, distribution, stakes };
70
+ }
71
+
72
+ async function askMaintenance(p) {
73
+ process.stdout.write("\n" + c.bold("Maintenance") + "\n\n");
74
+ const owner = await p.text("Who owns this file? (person or team)", {
75
+ default: "An accountable person or team",
76
+ });
77
+ const cadence = await p.choice(
78
+ "How often will this file be reviewed?",
79
+ [
80
+ { value: "Monthly.", label: "Monthly" },
81
+ { value: "Quarterly.", label: "Quarterly", hint: "Recommended." },
82
+ { value: "Twice a year.", label: "Twice a year" },
83
+ { value: "Annually (minimum).", label: "Annually (minimum)" },
84
+ ],
85
+ { default: 1 },
86
+ );
87
+ const feedback = await p.text(
88
+ "How can users and contributors report exclusionary patterns?",
89
+ {
90
+ default:
91
+ "Open an issue on this repository, or contact the owner directly.",
92
+ },
93
+ );
94
+ return { owner, cadence, feedback };
95
+ }
96
+
97
+ async function init(args) {
98
+ await welcome(args);
17
99
 
18
- process.stdout.write(BANNER + "\n");
19
100
  process.stdout.write(
20
- c.dim(
21
- "I'll ask a few questions about your project, then write a customized\n" +
22
- "INCLUSION.md to disk. Press Ctrl+C any time to bail.\n"
23
- ) + "\n"
101
+ "Let's generate an inclusion context document for your project. There are\n" +
102
+ "two simple parts to this:\n\n" +
103
+ ` ${c.cyan("Part 1")} I'll start from a foundational template with general\n` +
104
+ ` inclusion guidance to help AI agents reason about their\n` +
105
+ ` training-data biases.\n\n` +
106
+ ` ${c.cyan("Part 2")} Your turn. A short Project Context questionnaire (and an\n` +
107
+ ` optional, deeper Design Decisions one) so I can fold your\n` +
108
+ ` project-specific reality into the doc.\n\n` +
109
+ "Then we're done.\n\n",
24
110
  );
25
111
 
26
112
  const p = createPrompter({ acceptDefaults: !!args.yes });
27
113
 
28
114
  try {
115
+ const proceed = await p.confirm("Should we dive in?", { default: true });
116
+ if (!proceed) {
117
+ process.stdout.write(
118
+ "\n" +
119
+ c.cyan(
120
+ "No worries! Come back whenever you're ready. Your future users will thank you.",
121
+ ) +
122
+ "\n",
123
+ );
124
+ p.close();
125
+ return;
126
+ }
127
+
128
+ process.stdout.write(
129
+ "\n" +
130
+ c.green("✓ ") +
131
+ "Awesome. Step 1: loading the foundational template + general inclusion guidance...\n",
132
+ );
133
+
29
134
  // --- variant ----------------------------------------------------------
30
135
  let variant = args.variant;
31
136
  if (!variant) {
32
137
  variant = await p.choice(
33
138
  "Which template do you want to start from?",
34
139
  [
35
- { value: "generic", label: "Generic", hint: "Default. Good for most repos." },
140
+ {
141
+ value: "generic",
142
+ label: "Generic",
143
+ hint: "Default. Good for most repos.",
144
+ },
36
145
  {
37
146
  value: "frontend-app",
38
147
  label: "Frontend web app",
@@ -49,145 +158,179 @@ async function init(args) {
49
158
  hint: "Schema, errors, auth, telemetry.",
50
159
  },
51
160
  ],
52
- { default: 0 }
161
+ { default: 0 },
53
162
  );
54
163
  }
55
164
  if (!VARIANTS[variant]) {
56
165
  throw new Error(
57
- `Unknown variant "${variant}". Choose one of: ${Object.keys(VARIANTS).join(", ")}.`
166
+ `Unknown variant "${variant}". Choose one of: ${Object.keys(VARIANTS).join(", ")}.`,
58
167
  );
59
168
  }
60
169
 
61
- process.stdout.write("\n" + c.bold("Project context") + "\n");
170
+ const projectContext = await askProjectContext(p);
62
171
  process.stdout.write(
63
- c.dim(
64
- "These answers fill in Section 1. Be specific - generic answers make for generic guidance.\n"
65
- ) + "\n"
172
+ "\n" +
173
+ c.green(" ") +
174
+ "Great, that's the foundational context.\n",
66
175
  );
67
176
 
68
- const product = await p.text("What does this project do?", {
69
- default: "A short description of what this software does.",
70
- });
71
- const primaryUsers = await p.text("Who have you intentionally designed for?", {
72
- default: "Describe your primary users.",
73
- });
74
- const underservedUsers = await p.text(
75
- "Who do you know you haven't designed for well yet?",
76
- {
77
- default:
78
- "Document a real gap here (e.g. screen reader users on the dashboard; Spanish-language onboarding).",
79
- }
80
- );
81
- const distribution = await p.text(
82
- "Where is this software used? (geography, devices, network, assistive tech)",
83
- {
84
- default:
85
- "Web/iOS/Android, primary regions, languages, common assistive technologies.",
86
- }
87
- );
88
- const stakes = await p.text(
89
- "What happens when this software excludes someone?",
90
- {
91
- default:
92
- "Describe the real-world cost of exclusion (loss of access to healthcare, employment, civic services, etc.).",
93
- }
94
- );
177
+ // --- Design Decisions (opt-in) ---------------------------------------
178
+ let designDecisions = {};
179
+ const wantsDecisions = args.yes
180
+ ? false
181
+ : await p.confirm(
182
+ `Want to also fill in the Design Decisions questionnaire? (${TOTAL_QUESTIONS} short questions, all skippable)`,
183
+ { default: true },
184
+ );
95
185
 
96
- process.stdout.write("\n" + c.bold("Maintenance") + "\n\n");
186
+ if (wantsDecisions) {
187
+ designDecisions = await runQuestionnaire(p);
188
+ process.stdout.write(
189
+ c.green("✓ ") + "Done. Folding your answers into the doc.\n",
190
+ );
191
+ }
97
192
 
98
- const owner = await p.text("Who owns this file? (person or team)", {
99
- default: "An accountable person or team",
100
- });
101
- const cadence = await p.choice(
102
- "How often will this file be reviewed?",
103
- [
104
- { value: "Monthly.", label: "Monthly" },
105
- { value: "Quarterly.", label: "Quarterly", hint: "Recommended." },
106
- { value: "Twice a year.", label: "Twice a year" },
107
- { value: "Annually (minimum).", label: "Annually (minimum)" },
108
- ],
109
- { default: 1 }
110
- );
111
- const feedback = await p.text(
112
- "How can users and contributors report exclusionary patterns?",
113
- {
114
- default:
115
- "Open an issue on this repository, or contact the owner directly.",
116
- }
117
- );
193
+ const maintenance = await askMaintenance(p);
118
194
 
119
195
  // --- output path ------------------------------------------------------
120
- const outPath = path.resolve(
121
- process.cwd(),
122
- args.out || "INCLUSION.md"
123
- );
196
+ const outPath = path.resolve(process.cwd(), args.out || "INCLUSION.md");
197
+ const allAnswers = { ...projectContext, ...maintenance, designDecisions };
124
198
 
125
199
  if (fs.existsSync(outPath) && !args.force) {
126
- const overwrite = await p.confirm(
127
- `${path.relative(process.cwd(), outPath) || outPath} already exists. Overwrite?`,
128
- { default: false }
200
+ const choice = await p.choice(
201
+ `${path.relative(process.cwd(), outPath) || outPath} already exists. What now?`,
202
+ [
203
+ {
204
+ value: "update",
205
+ label: "Update it in place",
206
+ hint: "Keeps your edits; only rewrites Section 1 + Section 12.",
207
+ },
208
+ {
209
+ value: "overwrite",
210
+ label: "Overwrite with a fresh template",
211
+ hint: "Discards all existing content.",
212
+ },
213
+ { value: "abort", label: "Cancel" },
214
+ ],
215
+ { default: 0 },
129
216
  );
130
- if (!overwrite) {
217
+ if (choice === "abort") {
131
218
  process.stdout.write(
132
- c.yellow("\nAborted. ") + "Existing file left untouched.\n"
219
+ c.yellow("\nAborted. ") + "Existing file left untouched.\n",
133
220
  );
134
221
  p.close();
135
222
  return;
136
223
  }
224
+ if (choice === "update") {
225
+ const existing = fs.readFileSync(outPath, "utf8");
226
+ const rendered = renderGeneric(existing, allAnswers);
227
+ fs.writeFileSync(outPath, rendered, "utf8");
228
+ p.close();
229
+ printSuccess(outPath, rendered, "updated");
230
+ return;
231
+ }
137
232
  }
138
233
 
139
- // --- render -----------------------------------------------------------
140
234
  const template = loadTemplate(variant);
141
- const answers = {
142
- product,
143
- primaryUsers,
144
- underservedUsers,
145
- distribution,
146
- stakes,
147
- owner,
148
- cadence,
149
- feedback,
150
- };
151
-
152
- let rendered;
153
- if (variant === "generic") {
154
- rendered = renderGeneric(template, answers);
155
- } else {
156
- rendered = prependPersonalizedContext(template, answers);
157
- }
235
+ const rendered = renderGeneric(template, allAnswers);
158
236
 
159
237
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
160
238
  fs.writeFileSync(outPath, rendered, "utf8");
161
239
 
162
240
  p.close();
241
+ printSuccess(outPath, rendered, "wrote");
242
+ } finally {
243
+ p.close();
244
+ }
245
+ }
163
246
 
164
- const rel = path.relative(process.cwd(), outPath) || outPath;
247
+ async function update(args) {
248
+ await welcome(args);
249
+
250
+ const outPath = path.resolve(process.cwd(), args.out || "INCLUSION.md");
251
+ if (!fs.existsSync(outPath)) {
165
252
  process.stdout.write(
166
- "\n" +
167
- c.green("✓ ") +
168
- c.bold(`Wrote ${rel}`) +
169
- c.dim(` (${formatBytes(Buffer.byteLength(rendered))})`) +
170
- "\n\n"
253
+ c.yellow("Hmm, I can't find an INCLUSION.md at ") +
254
+ c.bold(path.relative(process.cwd(), outPath) || outPath) +
255
+ ".\n" +
256
+ c.dim("Run ") +
257
+ c.cyan("npx inclusion-md init") +
258
+ c.dim(" to create one, or pass ") +
259
+ c.cyan("--out <path>") +
260
+ c.dim(" to point at the right location.\n"),
171
261
  );
262
+ process.exit(1);
263
+ }
172
264
 
173
- printNextSteps(rel);
265
+ process.stdout.write(
266
+ "Updating " +
267
+ c.bold(path.relative(process.cwd(), outPath) || outPath) +
268
+ ".\n" +
269
+ c.dim(
270
+ "I'll re-ask the questionnaire and rewrite Section 1 (Project Context) + Section 12 (Maintenance).\n" +
271
+ "Everything else - including any edits you've made - is preserved.\n",
272
+ ) +
273
+ "\n",
274
+ );
275
+
276
+ const p = createPrompter({ acceptDefaults: !!args.yes });
277
+
278
+ try {
279
+ const proceed = await p.confirm("Cool to proceed?", { default: true });
280
+ if (!proceed) {
281
+ process.stdout.write(c.yellow("\nAborted.\n"));
282
+ p.close();
283
+ return;
284
+ }
285
+
286
+ const projectContext = await askProjectContext(p);
287
+
288
+ let designDecisions = {};
289
+ const wantsDecisions = args.yes
290
+ ? false
291
+ : await p.confirm("Update the Design Decisions section too?", {
292
+ default: true,
293
+ });
294
+ if (wantsDecisions) {
295
+ designDecisions = await runQuestionnaire(p);
296
+ }
297
+
298
+ const maintenance = await askMaintenance(p);
299
+
300
+ const existing = fs.readFileSync(outPath, "utf8");
301
+ const rendered = renderGeneric(existing, {
302
+ ...projectContext,
303
+ ...maintenance,
304
+ designDecisions,
305
+ });
306
+
307
+ fs.writeFileSync(outPath, rendered, "utf8");
308
+ p.close();
309
+ printSuccess(outPath, rendered, "updated");
174
310
  } finally {
175
311
  p.close();
176
312
  }
177
313
  }
178
314
 
179
- function prependPersonalizedContext(template, answers) {
180
- // For non-generic variants, the canonical template already has its own
181
- // Section 1. We replace it with the user's answers.
182
- return renderGeneric(template, answers);
183
- }
184
-
185
315
  function formatBytes(n) {
186
316
  if (n < 1024) return `${n} B`;
187
317
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
188
318
  return `${(n / 1024 / 1024).toFixed(2)} MB`;
189
319
  }
190
320
 
321
+ function printSuccess(outPath, rendered, verb) {
322
+ const rel = path.relative(process.cwd(), outPath) || outPath;
323
+ process.stdout.write(
324
+ "\n" +
325
+ c.green("✓ ") +
326
+ c.bold(`${verb === "updated" ? "Updated" : "Wrote"} ${rel}`) +
327
+ c.dim(` (${formatBytes(Buffer.byteLength(rendered))})`) +
328
+ "\n\n",
329
+ );
330
+
331
+ printNextSteps(rel);
332
+ }
333
+
191
334
  function printNextSteps(file) {
192
335
  process.stdout.write(
193
336
  c.bold("Next steps") +
@@ -200,11 +343,11 @@ function printNextSteps(file) {
200
343
  ` Always read and follow /INCLUSION.md.\n\n` +
201
344
  ` ${c.dim("# Claude Code - CLAUDE.md")}\n` +
202
345
  ` Read /INCLUSION.md and apply its review prompts.\n\n` +
203
- ` 3. Commit it. Treat it like the rest of your engineering docs.\n\n` +
204
- c.dim(
205
- "Companion essay: https://branon.dev/blog/posts/the-need-for-inclusion-md\n"
206
- )
346
+ ` 3. Commit it. Treat it like the rest of your engineering docs.\n` +
347
+ ` 4. Run ${c.cyan("npx inclusion-md update")} on a cadence (quarterly is a good default).\n\n` +
348
+ c.dim(`Companion essay: ${ESSAY_URL}\n`) +
349
+ c.dim(`Repo: ${REPO_URL}\n`),
207
350
  );
208
351
  }
209
352
 
210
- module.exports = { init };
353
+ module.exports = { init, update };
package/lib/prompt.js CHANGED
@@ -17,13 +17,26 @@ function createPrompter({ acceptDefaults = false } = {}) {
17
17
  throw err;
18
18
  });
19
19
 
20
+ // Track the in-flight ask() so a single 'close' listener can reject it.
21
+ // Registering rl.once("close", ...) inside ask() leaked one listener per
22
+ // prompt and triggered MaxListenersExceededWarning around question ~11.
23
+ let pendingReject = null;
24
+ rl.on("close", () => {
25
+ if (pendingReject) {
26
+ const err = new Error("Cancelled by user");
27
+ err.code = "USER_CANCELLED";
28
+ const reject = pendingReject;
29
+ pendingReject = null;
30
+ reject(err);
31
+ }
32
+ });
33
+
20
34
  function ask(question) {
21
35
  return new Promise((resolve, reject) => {
22
- rl.question(question, (answer) => resolve(answer));
23
- rl.once("close", () => {
24
- const err = new Error("Cancelled by user");
25
- err.code = "USER_CANCELLED";
26
- reject(err);
36
+ pendingReject = reject;
37
+ rl.question(question, (answer) => {
38
+ pendingReject = null;
39
+ resolve(answer);
27
40
  });
28
41
  });
29
42
  }
@@ -36,7 +49,9 @@ function createPrompter({ acceptDefaults = false } = {}) {
36
49
  async function text(label, { default: def = "", required = false } = {}) {
37
50
  if (acceptDefaults) return def;
38
51
  while (true) {
39
- const raw = await ask(`${c.cyan("?")} ${c.bold(label)}${fmtDefault(def)}: `);
52
+ const raw = await ask(
53
+ `${c.cyan("?")} ${c.bold(label)}${fmtDefault(def)}: `,
54
+ );
40
55
  const val = (raw || "").trim();
41
56
  if (val) return val;
42
57
  if (def) return def;
@@ -49,10 +64,12 @@ function createPrompter({ acceptDefaults = false } = {}) {
49
64
  if (acceptDefaults) return def;
50
65
  process.stdout.write(
51
66
  `${c.cyan("?")} ${c.bold(label)}\n` +
52
- c.dim(" (one item per line; empty line to finish")
67
+ c.dim(" (one item per line; empty line to finish"),
53
68
  );
54
69
  if (def)
55
- process.stdout.write(c.dim(`; press enter immediately to accept default`));
70
+ process.stdout.write(
71
+ c.dim(`; press enter immediately to accept default`),
72
+ );
56
73
  process.stdout.write(c.dim(")\n"));
57
74
  const lines = [];
58
75
  while (true) {
@@ -73,18 +90,24 @@ function createPrompter({ acceptDefaults = false } = {}) {
73
90
  process.stdout.write(
74
91
  ` ${marker} ${c.bold(String(i + 1))}) ${opt.label}` +
75
92
  (opt.hint ? c.dim(` - ${opt.hint}`) : "") +
76
- "\n"
93
+ "\n",
77
94
  );
78
95
  });
79
96
  while (true) {
80
- const raw = await ask(c.dim(` choose 1-${options.length} `) + fmtDefault(defIndex + 1) + ": ");
97
+ const raw = await ask(
98
+ c.dim(` choose 1-${options.length} `) +
99
+ fmtDefault(defIndex + 1) +
100
+ ": ",
101
+ );
81
102
  const val = (raw || "").trim();
82
103
  if (val === "") return options[defIndex].value;
83
104
  const n = parseInt(val, 10);
84
105
  if (Number.isInteger(n) && n >= 1 && n <= options.length) {
85
106
  return options[n - 1].value;
86
107
  }
87
- process.stdout.write(c.yellow(` Please enter a number 1-${options.length}.\n`));
108
+ process.stdout.write(
109
+ c.yellow(` Please enter a number 1-${options.length}.\n`),
110
+ );
88
111
  }
89
112
  }
90
113
 
@@ -92,7 +115,9 @@ function createPrompter({ acceptDefaults = false } = {}) {
92
115
  if (acceptDefaults) return def;
93
116
  const hint = def ? "Y/n" : "y/N";
94
117
  while (true) {
95
- const raw = await ask(`${c.cyan("?")} ${c.bold(label)} ${c.dim(`(${hint})`)}: `);
118
+ const raw = await ask(
119
+ `${c.cyan("?")} ${c.bold(label)} ${c.dim(`(${hint})`)}: `,
120
+ );
96
121
  const val = (raw || "").trim().toLowerCase();
97
122
  if (val === "") return def;
98
123
  if (val === "y" || val === "yes") return true;
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+
3
+ const c = require("./colors");
4
+
5
+ /**
6
+ * Design Decisions questionnaire. 13 questions across 6 groups, each
7
+ * skippable. Surfaces the real tradeoffs your product makes - who you've
8
+ * intentionally designed for, who you know is excluded, and why.
9
+ *
10
+ * Grounded in:
11
+ * - ABLEIST: Measuring Ableist Harms in LLMs (arxiv.org/abs/2510.10998)
12
+ * - Centering Disability Perspectives in LLM Research and Design (ACM)
13
+ * - Kat Holmes, Mismatch: How Inclusion Shapes Design (MIT Press)
14
+ *
15
+ * The point isn't perfect answers. It's conscious, documented tradeoffs.
16
+ */
17
+
18
+ const GROUPS = [
19
+ {
20
+ title: "Group 1: Core Assumptions",
21
+ blurb:
22
+ "What you assume about the people using your product. These shape every other decision.",
23
+ questions: [
24
+ {
25
+ key: "primaryAudience",
26
+ label: "Who do you primarily build for?",
27
+ hint:
28
+ "Describe assumptions like language fluency, device type, network speed, technical expertise. " +
29
+ "Example: 'People with reliable internet, modern browsers, English fluency.'",
30
+ },
31
+ {
32
+ key: "implicitDefaults",
33
+ label:
34
+ "What 'default user' assumptions live inside your product today?",
35
+ hint:
36
+ "The non-disabled, English-fluent, neurotypical user with a fast connection is a statistical artifact, not a person. " +
37
+ "Where does that default show up in yours?",
38
+ },
39
+ ],
40
+ },
41
+ {
42
+ title: "Group 2: Authentication & Access",
43
+ blurb: "Where exclusion often happens first.",
44
+ questions: [
45
+ {
46
+ key: "authMethods",
47
+ label:
48
+ "How do people get into your product? What auth methods do you support?",
49
+ hint:
50
+ "Examples: email/password, email + SMS, social login, passkey, SSO, magic link.",
51
+ },
52
+ {
53
+ key: "authExcluded",
54
+ label:
55
+ "Is there anyone who can't use your authentication system right now?",
56
+ hint:
57
+ "Don't say 'no one' - there's always someone. Examples: people without reliable email, " +
58
+ "people without a phone, people in regions without SMS, people who share a device.",
59
+ },
60
+ {
61
+ key: "authReason",
62
+ label: "Why did you choose that authentication method?",
63
+ hint:
64
+ "Be honest: easiest to implement, most widely supported, security requirement, industry standard.",
65
+ },
66
+ ],
67
+ },
68
+ {
69
+ title: "Group 3: Information Collection",
70
+ blurb:
71
+ "What you ask for shapes who can show up. Required vs. optional matters.",
72
+ questions: [
73
+ {
74
+ key: "infoCollected",
75
+ label: "What personal information do you ask people to provide?",
76
+ hint:
77
+ "Examples: name, email, phone, DOB, location, video ID, gender (and what options?), disability status. " +
78
+ "Note required vs. optional.",
79
+ },
80
+ {
81
+ key: "infoRationale",
82
+ label: "For any required personal information, why is it required?",
83
+ hint:
84
+ "For each field: could we work without this? Example: 'Email required for notifications - could SMS work instead?'",
85
+ },
86
+ ],
87
+ },
88
+ {
89
+ title: "Group 4: Interaction Model",
90
+ blurb: "How people actually use the product.",
91
+ questions: [
92
+ {
93
+ key: "interactionModes",
94
+ label: "How do people interact with your product?",
95
+ hint:
96
+ "Pick all that apply: text, voice, visual/color-based, video, synchronous, asynchronous, " +
97
+ "touch, keyboard, motor coordination, screen reader.",
98
+ },
99
+ {
100
+ key: "unsupportedPatterns",
101
+ label: "Are there interaction patterns you consciously don't support?",
102
+ hint:
103
+ "Examples: video verification (cost), voice (bias concerns), offline mode, keyboard-only nav, " +
104
+ "text-to-speech, RTL languages, slow networks, mobile-only.",
105
+ },
106
+ ],
107
+ },
108
+ {
109
+ title: "Group 5: Communication & Language",
110
+ blurb: "Tone and language are inclusion choices, not aesthetic ones.",
111
+ questions: [
112
+ {
113
+ key: "languages",
114
+ label: "What language(s) do you currently support?",
115
+ hint:
116
+ "English only, English + [list], multilingual from day 1, plan to add languages in [timeframe].",
117
+ },
118
+ {
119
+ key: "communicationStyle",
120
+ label: "What communication style(s) are you optimizing for?",
121
+ hint:
122
+ "Formal/professional, casual/friendly, technical, plain-language, direct. " +
123
+ "Example: 'Casual + friendly, but avoid metaphors that might be ableist.'",
124
+ },
125
+ ],
126
+ },
127
+ {
128
+ title: "Group 6: Edge Cases & Intersections",
129
+ blurb:
130
+ "Specific scenarios where the product breaks - or where unexpected users show up.",
131
+ questions: [
132
+ {
133
+ key: "knownBreakage",
134
+ label: "What's one scenario where your product might not work?",
135
+ hint:
136
+ "Be specific, not vague. Example: 'People in rural areas with <1 Mbps internet' or 'Left-handed users on touch devices.'",
137
+ },
138
+ {
139
+ key: "unexpectedUsers",
140
+ label: "Who might use your product in ways you didn't expect?",
141
+ hint:
142
+ "Examples: older relatives, people in different countries, people with low vision zooming, " +
143
+ "screen reader users, people with limited energy, teams in different time zones, non-native speakers.",
144
+ },
145
+ ],
146
+ },
147
+ ];
148
+
149
+ const TOTAL_QUESTIONS = GROUPS.reduce((n, g) => n + g.questions.length, 0);
150
+
151
+ async function runQuestionnaire(p) {
152
+ process.stdout.write(
153
+ "\n" +
154
+ c.bold("Design Decisions") +
155
+ " " +
156
+ c.dim(`(${TOTAL_QUESTIONS} questions, all skippable)`) +
157
+ "\n" +
158
+ c.dim(
159
+ "These surface the real tradeoffs your product makes. Skip any with an empty answer.\n",
160
+ ) +
161
+ "\n",
162
+ );
163
+
164
+ const answers = {};
165
+ let asked = 0;
166
+
167
+ for (let g = 0; g < GROUPS.length; g++) {
168
+ const group = GROUPS[g];
169
+ process.stdout.write(
170
+ c.cyan(group.title) +
171
+ "\n" +
172
+ c.dim(` ${group.blurb}`) +
173
+ "\n\n",
174
+ );
175
+
176
+ for (const q of group.questions) {
177
+ asked++;
178
+ process.stdout.write(
179
+ c.dim(`Question ${asked} of ${TOTAL_QUESTIONS}`) + "\n",
180
+ );
181
+ if (q.hint) {
182
+ process.stdout.write(c.dim(` ${q.hint}`) + "\n");
183
+ }
184
+ const val = await p.text(q.label, { default: "", required: false });
185
+ if (val) {
186
+ answers[q.key] = val;
187
+ } else {
188
+ process.stdout.write(
189
+ c.dim(" (skipped - you can fill this in later)\n"),
190
+ );
191
+ }
192
+ process.stdout.write("\n");
193
+ }
194
+
195
+ if (g < GROUPS.length - 1) {
196
+ process.stdout.write(c.dim(" ---\n\n"));
197
+ }
198
+ }
199
+
200
+ return answers;
201
+ }
202
+
203
+ function hasAnyAnswers(designDecisions) {
204
+ return designDecisions && Object.keys(designDecisions).length > 0;
205
+ }
206
+
207
+ function renderDesignDecisionsMarkdown(answers) {
208
+ if (!hasAnyAnswers(answers)) return "";
209
+
210
+ const lines = [];
211
+ lines.push("### 1.B Design Decisions");
212
+ lines.push("");
213
+ lines.push(
214
+ "Conscious tradeoffs documented as part of this project's inclusion posture. " +
215
+ "These are not aspirations - they're current realities, surfaced so reviewers " +
216
+ "(human and AI) can flag when generated output assumes otherwise.",
217
+ );
218
+ lines.push("");
219
+
220
+ const sectionMap = [
221
+ {
222
+ heading: "**Core assumptions**",
223
+ keys: [
224
+ ["primaryAudience", "Primary audience"],
225
+ ["implicitDefaults", "Implicit defaults"],
226
+ ],
227
+ },
228
+ {
229
+ heading: "**Authentication & access**",
230
+ keys: [
231
+ ["authMethods", "Methods supported"],
232
+ ["authExcluded", "Known exclusion"],
233
+ ["authReason", "Reason for choice"],
234
+ ],
235
+ },
236
+ {
237
+ heading: "**Information collection**",
238
+ keys: [
239
+ ["infoCollected", "Information collected"],
240
+ ["infoRationale", "Why it's required"],
241
+ ],
242
+ },
243
+ {
244
+ heading: "**Interaction model**",
245
+ keys: [
246
+ ["interactionModes", "Interaction modes"],
247
+ ["unsupportedPatterns", "Patterns not supported"],
248
+ ],
249
+ },
250
+ {
251
+ heading: "**Communication & language**",
252
+ keys: [
253
+ ["languages", "Languages supported"],
254
+ ["communicationStyle", "Communication style"],
255
+ ],
256
+ },
257
+ {
258
+ heading: "**Edge cases & intersections**",
259
+ keys: [
260
+ ["knownBreakage", "Known breakage scenarios"],
261
+ ["unexpectedUsers", "Unexpected users"],
262
+ ],
263
+ },
264
+ ];
265
+
266
+ for (const section of sectionMap) {
267
+ const entries = section.keys.filter(([k]) => answers[k]);
268
+ if (entries.length === 0) continue;
269
+ lines.push(section.heading);
270
+ for (const [key, label] of entries) {
271
+ lines.push(`- _${label}:_ ${answers[key]}`);
272
+ }
273
+ lines.push("");
274
+ }
275
+
276
+ return lines.join("\n");
277
+ }
278
+
279
+ module.exports = {
280
+ runQuestionnaire,
281
+ renderDesignDecisionsMarkdown,
282
+ hasAnyAnswers,
283
+ GROUPS,
284
+ TOTAL_QUESTIONS,
285
+ };
package/lib/template.js CHANGED
@@ -3,6 +3,11 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
 
6
+ const {
7
+ renderDesignDecisionsMarkdown,
8
+ hasAnyAnswers,
9
+ } = require("./questionnaire");
10
+
6
11
  const VARIANTS = {
7
12
  generic: "INCLUSION.md",
8
13
  "frontend-app": "examples/frontend-app/INCLUSION.md",
@@ -14,7 +19,7 @@ function loadTemplate(variant) {
14
19
  const rel = VARIANTS[variant];
15
20
  if (!rel) {
16
21
  throw new Error(
17
- `Unknown variant "${variant}". Choose one of: ${Object.keys(VARIANTS).join(", ")}.`
22
+ `Unknown variant "${variant}". Choose one of: ${Object.keys(VARIANTS).join(", ")}.`,
18
23
  );
19
24
  }
20
25
  // Resolve relative to the package root (one level up from /lib).
@@ -36,17 +41,33 @@ function renderGeneric(template, answers) {
36
41
  let out = template;
37
42
 
38
43
  // --- Section 1: Project Context -----------------------------------------
44
+ const projectContextLines = [
45
+ `## 1. Project Context`,
46
+ ``,
47
+ `### 1.A Overview`,
48
+ ``,
49
+ `- **Product:** ${answers.product}`,
50
+ `- **Primary users:** ${answers.primaryUsers}`,
51
+ `- **Known underserved users:** ${answers.underservedUsers}`,
52
+ `- **Distribution context:** ${answers.distribution}`,
53
+ `- **Stakes:** ${answers.stakes}`,
54
+ ``,
55
+ `Generated code, copy, and interaction patterns should be evaluated against the`,
56
+ `context above before being merged.`,
57
+ ];
58
+
59
+ const designDecisionsBlock = hasAnyAnswers(answers.designDecisions)
60
+ ? "\n\n" + renderDesignDecisionsMarkdown(answers.designDecisions).trimEnd()
61
+ : "";
62
+
39
63
  const newProjectContext =
40
- `## 1. Project Context\n\n` +
41
- `- **Product:** ${answers.product}\n` +
42
- `- **Primary users:** ${answers.primaryUsers}\n` +
43
- `- **Known underserved users:** ${answers.underservedUsers}\n` +
44
- `- **Distribution context:** ${answers.distribution}\n` +
45
- `- **Stakes:** ${answers.stakes}\n\n` +
46
- `Generated code, copy, and interaction patterns should be evaluated against the\n` +
47
- `context above before being merged.`;
64
+ projectContextLines.join("\n") + designDecisionsBlock;
48
65
 
49
- out = replaceSection(out, /^## 1\. Project Context[\s\S]*?(?=^---\s*$)/m, newProjectContext + "\n\n");
66
+ out = replaceSection(
67
+ out,
68
+ /^## 1\. Project Context[\s\S]*?(?=^---\s*$)/m,
69
+ newProjectContext + "\n\n",
70
+ );
50
71
 
51
72
  // --- Section 12: Maintenance --------------------------------------------
52
73
  const newMaintenance =
@@ -57,7 +78,11 @@ function renderGeneric(template, answers) {
57
78
  ` [\`CHANGELOG.md\`](./CHANGELOG.md) or in repository releases.\n` +
58
79
  `- **Feedback:** ${answers.feedback}`;
59
80
 
60
- out = replaceSection(out, /^## 12\. Maintenance[\s\S]*?(?=^---\s*$)/m, newMaintenance + "\n\n");
81
+ out = replaceSection(
82
+ out,
83
+ /^## 12\. Maintenance[\s\S]*?(?=^---\s*$)/m,
84
+ newMaintenance + "\n\n",
85
+ );
61
86
 
62
87
  return out;
63
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inclusion-md",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Scaffold an INCLUSION.md - a context engineering doc that gives AI coding assistants inclusion-oriented guidance during code generation.",
5
5
  "keywords": [
6
6
  "inclusion",
@@ -41,6 +41,7 @@
41
41
  "node": ">=16"
42
42
  },
43
43
  "scripts": {
44
- "test": "node bin/inclusion-md.js --help"
44
+ "test": "node --test test/",
45
+ "help": "node bin/inclusion-md.js --help"
45
46
  }
46
47
  }