dw-sdlc 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n }\n }\n}";
2
+ export type Bucket = "pass" | "fail" | "pending" | "skipping";
3
+ export type CiCheck = {
4
+ name: string;
5
+ bucket: Bucket;
6
+ required: boolean;
7
+ link: string | null;
8
+ /** The command that prints a failed GitHub Actions job's log; null for any other CI. */
9
+ log: string[] | null;
10
+ startedAt: string | null;
11
+ };
12
+ export type Thread = {
13
+ id: string;
14
+ path: string;
15
+ line: number | null;
16
+ author: string;
17
+ body: string;
18
+ /** Whether the viewer, the account gh reads as, wrote the thread's last comment. */
19
+ lastByViewer: boolean;
20
+ };
21
+ declare const STATES: readonly ["OPEN", "CLOSED", "MERGED"];
22
+ declare const MERGEABLE: readonly ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
23
+ declare const REVIEW_DECISIONS: readonly ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
24
+ export type PrState = {
25
+ url: string;
26
+ repo: string;
27
+ number: number;
28
+ state: (typeof STATES)[number];
29
+ draft: boolean;
30
+ mergeable: (typeof MERGEABLE)[number];
31
+ /** BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN or UNSTABLE, as GitHub reports it. */
32
+ mergeStateStatus: string;
33
+ reviewDecision: (typeof REVIEW_DECISIONS)[number] | null;
34
+ viewerDidAuthor: boolean;
35
+ head: string;
36
+ headCommittedAt: string;
37
+ branch: string;
38
+ base: string;
39
+ mergeCommit: string | null;
40
+ checks: CiCheck[];
41
+ /** The threads neither resolved nor outdated. */
42
+ unresolvedThreads: Thread[];
43
+ };
44
+ /** A reply that is not the shape the query asks for; the message names the field. */
45
+ export declare class ReplyError extends Error {
46
+ }
47
+ /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
48
+ export declare function parseView(reply: string): {
49
+ repo: string;
50
+ number: number;
51
+ };
52
+ /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
53
+ export declare function parsePr(reply: string, repo: string): PrState;
54
+ export {};
@@ -0,0 +1,220 @@
1
+ // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
+ // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
3
+ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
4
+ repository(owner: $owner, name: $name) {
5
+ pullRequest(number: $number) {
6
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
7
+ viewerDidAuthor mergeCommit { oid }
8
+ reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
9
+ comments(first: 1) { nodes { author { login } body } }
10
+ latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
11
+ commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
12
+ contexts(first: 50) { nodes {
13
+ ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
14
+ ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
15
+ }
16
+ }
17
+ }`;
18
+ const STATES = ["OPEN", "CLOSED", "MERGED"];
19
+ const MERGEABLE = ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
20
+ const REVIEW_DECISIONS = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
21
+ /** A reply that is not the shape the query asks for; the message names the field. */
22
+ export class ReplyError extends Error {
23
+ }
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function shown(value) {
28
+ return value === undefined ? "missing" : JSON.stringify(value);
29
+ }
30
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
31
+ function rootOf(reply, what) {
32
+ let json;
33
+ try {
34
+ json = JSON.parse(reply);
35
+ }
36
+ catch {
37
+ throw new ReplyError(`${what} is not JSON`);
38
+ }
39
+ if (!isRecord(json)) {
40
+ throw new ReplyError(`${what} is not a JSON object`);
41
+ }
42
+ return { value: json, at: "" };
43
+ }
44
+ function fieldOf(node, key) {
45
+ return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
46
+ }
47
+ function object(value, at) {
48
+ if (!isRecord(value)) {
49
+ throw new ReplyError(`${at} is ${shown(value)}, not an object`);
50
+ }
51
+ return { value, at };
52
+ }
53
+ function child(node, key) {
54
+ const { value, at } = fieldOf(node, key);
55
+ return object(value, at);
56
+ }
57
+ function childOrNull(node, key) {
58
+ const { value, at } = fieldOf(node, key);
59
+ return value === null ? null : object(value, at);
60
+ }
61
+ function string(node, key) {
62
+ const { value, at } = fieldOf(node, key);
63
+ if (typeof value !== "string") {
64
+ throw new ReplyError(`${at} is ${shown(value)}, not a string`);
65
+ }
66
+ return value;
67
+ }
68
+ function stringOrNull(node, key) {
69
+ return node.value[key] === null ? null : string(node, key);
70
+ }
71
+ function boolean(node, key) {
72
+ const { value, at } = fieldOf(node, key);
73
+ if (typeof value !== "boolean") {
74
+ throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
75
+ }
76
+ return value;
77
+ }
78
+ function integer(node, key) {
79
+ const { value, at } = fieldOf(node, key);
80
+ if (typeof value !== "number" || !Number.isInteger(value)) {
81
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
82
+ }
83
+ return value;
84
+ }
85
+ function integerOrNull(node, key) {
86
+ const { value, at } = fieldOf(node, key);
87
+ if (value !== null && !Number.isInteger(value)) {
88
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
89
+ }
90
+ return typeof value === "number" ? value : null;
91
+ }
92
+ /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
93
+ function time(node, key) {
94
+ const value = string(node, key);
95
+ if (Number.isNaN(Date.parse(value))) {
96
+ throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
97
+ }
98
+ return value;
99
+ }
100
+ function timeOrNull(node, key) {
101
+ return node.value[key] === null ? null : time(node, key);
102
+ }
103
+ function isOneOf(values, value) {
104
+ return values.some((known) => known === value);
105
+ }
106
+ function oneOf(node, key, values) {
107
+ const { value, at } = fieldOf(node, key);
108
+ if (!isOneOf(values, value)) {
109
+ throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
110
+ }
111
+ return value;
112
+ }
113
+ /** The objects of a connection's `nodes`. */
114
+ function nodes(node, key) {
115
+ const { value, at } = fieldOf(child(node, key), "nodes");
116
+ if (!Array.isArray(value)) {
117
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
118
+ }
119
+ return value.map((item, index) => object(item, `${at}[${index}]`));
120
+ }
121
+ /** The first object of a connection's `nodes`; an empty connection does not parse. */
122
+ function firstNode(node, key) {
123
+ const [first] = nodes(node, key);
124
+ if (first === undefined) {
125
+ throw new ReplyError(`${node.at}.${key}.nodes is empty`);
126
+ }
127
+ return first;
128
+ }
129
+ const FAIL = new Set(["ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
130
+ const SKIPPING = new Set(["SKIPPED", "NEUTRAL"]);
131
+ /** A check's bucket as `gh pr checks` sorts it: every word not passed, failed or skipped is pending. */
132
+ function bucketOf(word) {
133
+ if (word === "SUCCESS") {
134
+ return "pass";
135
+ }
136
+ if (word !== null && FAIL.has(word)) {
137
+ return "fail";
138
+ }
139
+ return word !== null && SKIPPING.has(word) ? "skipping" : "pending";
140
+ }
141
+ function logCommand(link, repo) {
142
+ const job = link?.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1];
143
+ return job === undefined ? null : ["gh", "run", "view", "--job", job, "-R", repo, "--log-failed"];
144
+ }
145
+ /** A CheckRun has a name and a status; a StatusContext has a context and a state. */
146
+ function checkOf(context, repo) {
147
+ if ("name" in context.value) {
148
+ const status = string(context, "status");
149
+ const link = stringOrNull(context, "detailsUrl");
150
+ return {
151
+ name: string(context, "name"),
152
+ bucket: bucketOf(status === "COMPLETED" ? stringOrNull(context, "conclusion") : status),
153
+ required: boolean(context, "isRequired"),
154
+ link,
155
+ log: logCommand(link, repo),
156
+ startedAt: timeOrNull(context, "startedAt")
157
+ };
158
+ }
159
+ if ("context" in context.value) {
160
+ const link = stringOrNull(context, "targetUrl");
161
+ return {
162
+ name: string(context, "context"),
163
+ bucket: bucketOf(string(context, "state")),
164
+ required: boolean(context, "isRequired"),
165
+ link,
166
+ log: logCommand(link, repo),
167
+ startedAt: time(context, "createdAt")
168
+ };
169
+ }
170
+ throw new ReplyError(`${context.at} is neither a CheckRun nor a StatusContext`);
171
+ }
172
+ /** A thread by its opening comment; a deleted account's comment has no author and reads as GitHub's ghost. */
173
+ function threadOf(thread) {
174
+ const first = firstNode(thread, "comments");
175
+ const author = childOrNull(first, "author");
176
+ return {
177
+ id: string(thread, "id"),
178
+ path: string(thread, "path"),
179
+ line: integerOrNull(thread, "line"),
180
+ author: author === null ? "ghost" : string(author, "login"),
181
+ body: string(first, "body"),
182
+ lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
183
+ };
184
+ }
185
+ /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
186
+ export function parseView(reply) {
187
+ const url = string(rootOf(reply, "the reply of gh pr view"), "url");
188
+ const [, repo, number] = /^https:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)$/.exec(url) ?? [];
189
+ if (repo === undefined || number === undefined) {
190
+ throw new ReplyError(`url is ${JSON.stringify(url)}, not a pull request's URL`);
191
+ }
192
+ return { repo, number: Number(number) };
193
+ }
194
+ /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
195
+ export function parsePr(reply, repo) {
196
+ const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
197
+ const commit = child(firstNode(pr, "commits"), "commit");
198
+ const rollup = childOrNull(commit, "statusCheckRollup");
199
+ const mergeCommit = childOrNull(pr, "mergeCommit");
200
+ return {
201
+ url: string(pr, "url"),
202
+ repo,
203
+ number: integer(pr, "number"),
204
+ state: oneOf(pr, "state", STATES),
205
+ draft: boolean(pr, "isDraft"),
206
+ mergeable: oneOf(pr, "mergeable", MERGEABLE),
207
+ mergeStateStatus: string(pr, "mergeStateStatus"),
208
+ reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
209
+ viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
210
+ head: string(pr, "headRefOid"),
211
+ headCommittedAt: time(commit, "committedDate"),
212
+ branch: string(pr, "headRefName"),
213
+ base: string(pr, "baseRefName"),
214
+ mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
215
+ checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
216
+ unresolvedThreads: nodes(pr, "reviewThreads")
217
+ .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
218
+ .map(threadOf)
219
+ };
220
+ }
@@ -1,4 +1,6 @@
1
- /** The committed config, the only file the key is read from. */
1
+ /** The committed file the References are read from; no local layer adds to it. */
2
+ export declare const REFERENCES_FILE = ".ai/references.json";
3
+ /** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
2
4
  export declare const CONFIG = ".ai/agentic.config.json";
3
5
  export type Reference = {
4
6
  dir: string;
@@ -20,12 +22,29 @@ export declare function parseReferences(value: unknown): {
20
22
  } | {
21
23
  problem: KeyProblem;
22
24
  };
25
+ /** The References the file's parsed `value` declares, or the first problem in it; `""` names its root. */
26
+ export declare function parseFile(value: unknown): {
27
+ references: Reference[];
28
+ } | {
29
+ problem: KeyProblem;
30
+ };
31
+ /** The `references` key the agentic config at `root` still holds, `undefined` without one or the config. */
32
+ export declare function leftoverKey(root: string): unknown;
33
+ /** Why a `references` key left in the config stops a read, by whether the file is there beside it. */
34
+ export declare function leftoverProblem(fileExists: boolean): string;
23
35
  /**
24
- * The References the committed config at `root` declares, `undefined` without the file or the key,
25
- * or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
36
+ * The References the file at `root` declares, `undefined` without the file, or the problem that stops
37
+ * the read: a `references` key left in the agentic config, a file that is not JSON, or one the parser
38
+ * refuses. A config that is not JSON is the guards' to report, not the read's.
26
39
  */
27
40
  export declare function readReferences(root: string): {
28
41
  references: Reference[] | undefined;
29
42
  } | {
30
43
  problem: string;
31
44
  };
45
+ /** The References the file at `root` declares, `undefined` without it, the agentic config unread. */
46
+ export declare function readFile(root: string): {
47
+ references: Reference[] | undefined;
48
+ } | {
49
+ problem: string;
50
+ };
@@ -1,10 +1,13 @@
1
- // The strict parser of the `references` key, the authority at runtime, and the reader of the committed
2
- // config that holds it. `config/references.schema.json` documents the same contract, and
3
- // `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
1
+ // The strict parser of `.ai/references.json`, the authority at runtime, and its reader. The file's
2
+ // `references` key holds the entries. `config/references.schema.json` documents the same contract,
3
+ // and `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- /** The committed config, the only file the key is read from. */
6
+ /** The committed file the References are read from; no local layer adds to it. */
7
+ export const REFERENCES_FILE = ".ai/references.json";
8
+ /** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
7
9
  export const CONFIG = ".ai/agentic.config.json";
10
+ const FILE_FIELDS = ["$schema", "version", "references"];
8
11
  const FIELDS = ["dir", "repository", "ref", "only", "skip", "what", "when"];
9
12
  const REQUIRED = ["dir", "repository", "ref", "what", "when"];
10
13
  const DIR_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
@@ -119,31 +122,78 @@ export function parseReferences(value) {
119
122
  throw error;
120
123
  }
121
124
  }
122
- /**
123
- * The References the committed config at `root` declares, `undefined` without the file or the key,
124
- * or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
125
- */
126
- export function readReferences(root) {
127
- const path = join(root, CONFIG);
125
+ /** The References the file's parsed `value` declares, or the first problem in it; `""` names its root. */
126
+ export function parseFile(value) {
127
+ if (!isObject(value)) {
128
+ return { problem: { path: "", message: `must be an object, not ${kind(value)}` } };
129
+ }
130
+ const unknown = Object.keys(value).find((field) => !FILE_FIELDS.includes(field));
131
+ if (unknown !== undefined) {
132
+ return { problem: { path: "", message: `unknown key ${JSON.stringify(unknown)}` } };
133
+ }
134
+ const missing = ["version", "references"].find((field) => !Object.hasOwn(value, field));
135
+ if (missing !== undefined) {
136
+ return { problem: { path: "", message: `missing key ${JSON.stringify(missing)}` } };
137
+ }
138
+ if (value.$schema !== undefined && typeof value.$schema !== "string") {
139
+ return { problem: { path: "$schema", message: `must be a string, not ${kind(value.$schema)}` } };
140
+ }
141
+ if (value.version !== 1) {
142
+ return { problem: { path: "version", message: `must be 1, not ${JSON.stringify(value.version)}` } };
143
+ }
144
+ return parseReferences(value.references);
145
+ }
146
+ /** The JSON object at `path`, `undefined` without the file, or why it is not one. */
147
+ function readObject(path, name) {
128
148
  if (!existsSync(path)) {
129
- return { references: undefined };
149
+ return { value: undefined };
130
150
  }
131
- let config;
151
+ let value;
132
152
  try {
133
- config = JSON.parse(readFileSync(path, "utf8"));
153
+ value = JSON.parse(readFileSync(path, "utf8"));
134
154
  }
135
155
  catch (error) {
136
- return { problem: `${CONFIG}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
156
+ return { problem: `${name}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
157
+ }
158
+ return isObject(value) ? { value } : { problem: `${name}: not a JSON object` };
159
+ }
160
+ /** The `references` key the agentic config at `root` still holds, `undefined` without one or the config. */
161
+ export function leftoverKey(root) {
162
+ const read = readObject(join(root, CONFIG), CONFIG);
163
+ return "value" in read && read.value !== undefined && Object.hasOwn(read.value, "references")
164
+ ? read.value.references
165
+ : undefined;
166
+ }
167
+ /** Why a `references` key left in the config stops a read, by whether the file is there beside it. */
168
+ export function leftoverProblem(fileExists) {
169
+ return fileExists
170
+ ? `${CONFIG} has a references key beside ${REFERENCES_FILE}; merge it into the file and remove the key by hand`
171
+ : `${CONFIG}: the references key belongs in ${REFERENCES_FILE}; move it there with \`dw-sdlc references setup\``;
172
+ }
173
+ /**
174
+ * The References the file at `root` declares, `undefined` without the file, or the problem that stops
175
+ * the read: a `references` key left in the agentic config, a file that is not JSON, or one the parser
176
+ * refuses. A config that is not JSON is the guards' to report, not the read's.
177
+ */
178
+ export function readReferences(root) {
179
+ if (leftoverKey(root) !== undefined) {
180
+ return { problem: leftoverProblem(existsSync(join(root, REFERENCES_FILE))) };
137
181
  }
138
- if (!isObject(config)) {
139
- return { problem: `${CONFIG}: not a JSON object` };
182
+ return readFile(root);
183
+ }
184
+ /** The References the file at `root` declares, `undefined` without it, the agentic config unread. */
185
+ export function readFile(root) {
186
+ const read = readObject(join(root, REFERENCES_FILE), REFERENCES_FILE);
187
+ if ("problem" in read) {
188
+ return read;
140
189
  }
141
- if (!Object.hasOwn(config, "references")) {
190
+ if (read.value === undefined) {
142
191
  return { references: undefined };
143
192
  }
144
- const parsed = parseReferences(config.references);
193
+ const parsed = parseFile(read.value);
145
194
  if ("problem" in parsed) {
146
- return { problem: `${CONFIG}: ${parsed.problem.path}: ${parsed.problem.message}` };
195
+ const where = parsed.problem.path === "" ? "" : `${parsed.problem.path}: `;
196
+ return { problem: `${REFERENCES_FILE}: ${where}${parsed.problem.message}` };
147
197
  }
148
198
  return { references: parsed.references };
149
199
  }
@@ -1,5 +1,5 @@
1
1
  import type { Reference } from "./references-key.ts";
2
- /** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
2
+ /** The block the file gives: one row per Reference in the file's order, or one line when it has none. */
3
3
  export declare function renderBlock(references: Reference[]): string;
4
4
  /**
5
5
  * The Router's text with `block` in place of the one between its markers: as it is when the two say
@@ -1,12 +1,12 @@
1
- // The Router's block: a table of each Reference's `dir` and `when`, written from the `references` key
1
+ // The Router's block: a table of each Reference's `dir` and `when`, written from `.ai/references.json`
2
2
  // between two markers. The setup writes it and the Sync checks it; the rest of the Router is the
3
- // author's. A block is in step when its rows say what the key says, not its bytes, which a formatter pads.
4
- const START = "<!-- references:start — written from .ai/agentic.config.json by the setup; edit the key -->";
3
+ // author's. A block is in step when its rows say what the file says, not its bytes, which a formatter pads.
4
+ const START = "<!-- references:start — written from .ai/references.json by the setup; edit the file -->";
5
5
  const END = "<!-- references:end -->";
6
6
  const START_LINE = /^<!-- references:start\b.*-->\s*$/;
7
7
  const END_LINE = /^<!-- references:end -->\s*$/;
8
8
  const NONE = "No Reference is declared yet: `/sdlc-references-add <repository>` adds one.";
9
- /** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
9
+ /** The block the file gives: one row per Reference in the file's order, or one line when it has none. */
10
10
  export function renderBlock(references) {
11
11
  const body = references.length === 0
12
12
  ? [NONE]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.5.1",
4
- "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
3
+ "version": "0.7.0",
4
+ "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,11 +9,11 @@ file routes reference material only.
9
9
 
10
10
  ## The list
11
11
 
12
- The table below is written from the `references` key of `.ai/agentic.config.json` by the setup, so
13
- edit the key, never the table; the key also holds `what` each Reference is, its repository, its ref
14
- and the paths its clone skips. Before you research, open the clones whose `when` matches the task.
12
+ The table below is written from `.ai/references.json` by the setup, so edit the file, never the
13
+ table; the file also holds `what` each Reference is, its repository, its ref and the paths its clone
14
+ skips. Before you research, open the clones whose `when` matches the task.
15
15
 
16
- <!-- references:start — written from .ai/agentic.config.json by the setup; edit the key -->
16
+ <!-- references:start — written from .ai/references.json by the setup; edit the file -->
17
17
 
18
18
  No Reference is declared yet: `/sdlc-references-add <repository>` adds one.
19
19
  <!-- references:end -->