@stdd/plugin 0.9.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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +47 -0
  5. package/extensions/stdd.mjs +77 -0
  6. package/hooks/claude-hooks.json +28 -0
  7. package/hooks/codex-hooks.json +28 -0
  8. package/package.json +38 -0
  9. package/runtime/adapters/README.md +158 -0
  10. package/runtime/cli/check.mjs +555 -0
  11. package/runtime/cli/ci.mjs +190 -0
  12. package/runtime/cli/claude-hooks.mjs +689 -0
  13. package/runtime/cli/config.mjs +27 -0
  14. package/runtime/cli/evidence.mjs +249 -0
  15. package/runtime/cli/generated-files.mjs +1693 -0
  16. package/runtime/cli/held-fs.mjs +415 -0
  17. package/runtime/cli/init.mjs +883 -0
  18. package/runtime/cli/ledger.mjs +1470 -0
  19. package/runtime/cli/lib.mjs +909 -0
  20. package/runtime/cli/path-bytes.mjs +83 -0
  21. package/runtime/cli/policy.mjs +112 -0
  22. package/runtime/cli/recorders.mjs +188 -0
  23. package/runtime/cli/review-fs.mjs +825 -0
  24. package/runtime/cli/review.mjs +1065 -0
  25. package/runtime/cli/runtime.mjs +32 -0
  26. package/runtime/cli/scope.mjs +185 -0
  27. package/runtime/cli/snapshot.mjs +897 -0
  28. package/runtime/cli/state-validation.mjs +168 -0
  29. package/runtime/cli/status.mjs +580 -0
  30. package/runtime/cli/stdd.mjs +536 -0
  31. package/runtime/cli/worker-fs.mjs +971 -0
  32. package/runtime/cli/worker-metadata.mjs +139 -0
  33. package/runtime/cli/worker.mjs +779 -0
  34. package/runtime/method/README.md +634 -0
  35. package/runtime/method/reference-commands.md +147 -0
  36. package/runtime/method/reference-generated-state.md +151 -0
  37. package/runtime/method/reference-integration.md +233 -0
  38. package/runtime/package.json +65 -0
  39. package/runtime/playbooks/brainstorming.md +46 -0
  40. package/runtime/playbooks/debugging.md +36 -0
  41. package/runtime/playbooks/delegate-slice.md +129 -0
  42. package/runtime/playbooks/finish-change.md +46 -0
  43. package/runtime/playbooks/implement.md +26 -0
  44. package/runtime/playbooks/investigation.md +33 -0
  45. package/runtime/playbooks/managed-playbooks.json +14 -0
  46. package/runtime/playbooks/planning.md +177 -0
  47. package/runtime/playbooks/pr-green.md +50 -0
  48. package/runtime/playbooks/start-change.md +37 -0
  49. package/runtime/playbooks/worktrees.md +45 -0
  50. package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
  51. package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
  52. package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
  53. package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
  54. package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
  55. package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
  56. package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
  57. package/runtime/sdk/adapters.mjs +279 -0
  58. package/runtime/sdk/file-observation.mjs +12 -0
  59. package/runtime/sdk/index.d.ts +140 -0
  60. package/runtime/sdk/index.mjs +31 -0
  61. package/runtime/sdk/native-fs.mjs +1235 -0
  62. package/runtime/sdk/path.mjs +71 -0
  63. package/runtime/sdk/text.mjs +42 -0
  64. package/runtime/sdk/workflow.mjs +294 -0
  65. package/runtime/templates/deferred-design.md +47 -0
  66. package/runtime/templates/github-stdd.yml +42 -0
  67. package/runtime/templates/gitlab-stdd.yml +72 -0
  68. package/runtime/templates/pr-description.md +35 -0
  69. package/scripts/adopting-root.mjs +42 -0
  70. package/scripts/stdd-hook.mjs +72 -0
  71. package/skills/stdd-brainstorming/SKILL.md +48 -0
  72. package/skills/stdd-debugging/SKILL.md +38 -0
  73. package/skills/stdd-delegate-slice/SKILL.md +118 -0
  74. package/skills/stdd-finish-change/SKILL.md +40 -0
  75. package/skills/stdd-implement/SKILL.md +28 -0
  76. package/skills/stdd-investigation/SKILL.md +35 -0
  77. package/skills/stdd-planning/SKILL.md +165 -0
  78. package/skills/stdd-pr-green/SKILL.md +52 -0
  79. package/skills/stdd-start-change/SKILL.md +39 -0
  80. package/skills/stdd-worktrees/SKILL.md +46 -0
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { assertPrintableSingleLine } from "./text.mjs";
4
+
5
+ const SAFE_SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
6
+
7
+ /** Validate the directory name used by agent skill adapters. */
8
+ export function assertSkillName(name, label = "playbook name") {
9
+ if (typeof name !== "string" || !SAFE_SKILL_NAME.test(name)) {
10
+ throw new Error(
11
+ `${label} must be a safe lowercase skill name matching ${SAFE_SKILL_NAME}, got ${JSON.stringify(name)}`,
12
+ );
13
+ }
14
+ return name;
15
+ }
16
+
17
+ /**
18
+ * Validate a slash-normalized repository-relative path at the shared
19
+ * printable-text boundary before applying lexical containment rules.
20
+ */
21
+ export function assertRepoRelativePath(relative, label = "path") {
22
+ const candidate = assertPrintableSingleLine(relative, label);
23
+ if (candidate.includes("\\") || path.posix.isAbsolute(candidate) || path.win32.isAbsolute(candidate)) {
24
+ throw new Error(`${label} must be a safe repository-relative path: ${JSON.stringify(candidate)}`);
25
+ }
26
+ const segments = candidate.split("/");
27
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
28
+ throw new Error(`${label} must be a safe repository-relative path: ${JSON.stringify(candidate)}`);
29
+ }
30
+ return candidate;
31
+ }
32
+
33
+ /**
34
+ * Resolve a validated repository-relative path below the supplied root.
35
+ */
36
+ export function resolveRepoPath(root, relative, label = "path") {
37
+ const candidate = assertRepoRelativePath(relative, label);
38
+ const segments = candidate.split("/");
39
+ const absoluteRoot = path.resolve(root);
40
+ const target = path.resolve(absoluteRoot, ...segments);
41
+ const fromRoot = path.relative(absoluteRoot, target);
42
+ if (fromRoot === ".." || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot)) {
43
+ throw new Error(`${label} must be a safe repository-relative path: ${JSON.stringify(candidate)}`);
44
+ }
45
+ return target;
46
+ }
47
+
48
+ /**
49
+ * The write/delete variant also rejects an existing symlink anywhere below
50
+ * the repository root, so lexical containment cannot be redirected outside.
51
+ */
52
+ export function resolveWritableRepoPath(root, relative, label = "path") {
53
+ const absoluteRoot = path.resolve(root);
54
+ const target = resolveRepoPath(absoluteRoot, relative, label);
55
+ let cursor = absoluteRoot;
56
+ for (const segment of path.relative(absoluteRoot, target).split(path.sep)) {
57
+ if (!segment) continue;
58
+ cursor = path.join(cursor, segment);
59
+ let observed;
60
+ try {
61
+ observed = fs.lstatSync(cursor);
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") continue;
64
+ throw err;
65
+ }
66
+ if (observed.isSymbolicLink()) {
67
+ throw new Error(`${label} crosses a symlink and is unsafe to write: ${JSON.stringify(relative)}`);
68
+ }
69
+ }
70
+ return target;
71
+ }
@@ -0,0 +1,42 @@
1
+ /** Shared boundary for identifiers and inline text that may reach logs or generated files. */
2
+ const CONTROL_OR_SEPARATOR = /[\p{Cc}\p{Cs}\p{Zl}\p{Zp}\p{Bidi_Control}]/u;
3
+
4
+ // Fixed instead of `\p{Cf}`: join controls, variation selectors, emoji tags,
5
+ // and visible script formatting remain valid ordinary Unicode.
6
+ const INVISIBLE_FORMAT_CONTROL =
7
+ /(?:\u00ad|\u034f|\u180e|\u200b|[\u2060-\u206f]|\ufeff|[\ufff9-\ufffb]|[\u{1bca0}-\u{1bcaf}]|[\u{1d173}-\u{1d17a}]|\u{e0001})/u;
8
+
9
+ function isNonPrintableSingleLineScalar(value) {
10
+ return CONTROL_OR_SEPARATOR.test(value) || INVISIBLE_FORMAT_CONTROL.test(value);
11
+ }
12
+
13
+ /** Escape only scalars that could split, repaint, hide, or reorder one displayed line. */
14
+ export function escapeNonPrintableSingleLine(value) {
15
+ if (typeof value !== "string") {
16
+ throw new TypeError("value must be a string");
17
+ }
18
+ let escaped = "";
19
+ for (const scalar of value) {
20
+ if (!isNonPrintableSingleLineScalar(scalar)) {
21
+ escaped += scalar;
22
+ continue;
23
+ }
24
+ const codePoint = scalar.codePointAt(0);
25
+ escaped +=
26
+ codePoint <= 0xffff
27
+ ? `\\u${codePoint.toString(16).padStart(4, "0")}`
28
+ : `\\u{${codePoint.toString(16)}}`;
29
+ }
30
+ return escaped;
31
+ }
32
+
33
+ export function isPrintableSingleLine(value) {
34
+ return typeof value === "string" && value.trim() !== "" && !isNonPrintableSingleLineScalar(value);
35
+ }
36
+
37
+ export function assertPrintableSingleLine(value, label = "value") {
38
+ if (!isPrintableSingleLine(value)) {
39
+ throw new TypeError(`${label} must be a non-empty single printable line`);
40
+ }
41
+ return value;
42
+ }
@@ -0,0 +1,294 @@
1
+ import { isPrintableSingleLine } from "./text.mjs";
2
+
3
+ function isCanonicalIsoTimestamp(value) {
4
+ if (!isPrintableSingleLine(value)) return false;
5
+ try {
6
+ return new Date(value).toISOString() === value;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ function isPlainRecord(value) {
13
+ if (typeof value !== "object" || value === null) return false;
14
+ try {
15
+ const prototype = Object.getPrototypeOf(value);
16
+ return prototype === Object.prototype || prototype === null;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function invalidTaskState(reason, boundary = null) {
23
+ return { state: "invalid", task: null, boundary, reason };
24
+ }
25
+
26
+ function snapshotEventRecord(event, index) {
27
+ if (!isPlainRecord(event)) {
28
+ return {
29
+ invalid: invalidTaskState(`task event at index ${index} must be a plain record object`, event),
30
+ };
31
+ }
32
+ let descriptors;
33
+ try {
34
+ descriptors = Object.getOwnPropertyDescriptors(event);
35
+ } catch {
36
+ return {
37
+ invalid: invalidTaskState(
38
+ `task event at index ${index} could not be safely inspected as a plain record object`,
39
+ event,
40
+ ),
41
+ };
42
+ }
43
+ const data = Object.create(null);
44
+ for (const key of Reflect.ownKeys(descriptors)) {
45
+ const descriptor = descriptors[key];
46
+ if (!Object.hasOwn(descriptor, "value")) {
47
+ return {
48
+ invalid: invalidTaskState(
49
+ `task event at index ${index} property ${String(key)} is an accessor; accessors are not allowed`,
50
+ event,
51
+ ),
52
+ };
53
+ }
54
+ if (typeof key === "string" && descriptor.enumerable) data[key] = descriptor.value;
55
+ }
56
+ return { record: { original: event, data } };
57
+ }
58
+
59
+ function snapshotTaskEvents(events) {
60
+ let array;
61
+ try {
62
+ array = Array.isArray(events);
63
+ } catch {
64
+ return { invalid: invalidTaskState("task events could not be safely inspected as an array") };
65
+ }
66
+ if (!array) return { invalid: invalidTaskState("task events must be an array") };
67
+ let descriptors;
68
+ try {
69
+ descriptors = Object.getOwnPropertyDescriptors(events);
70
+ } catch {
71
+ return { invalid: invalidTaskState("task events could not be safely inspected as an array") };
72
+ }
73
+ const lengthDescriptor = descriptors.length;
74
+ if (
75
+ !lengthDescriptor ||
76
+ !Object.hasOwn(lengthDescriptor, "value") ||
77
+ !Number.isSafeInteger(lengthDescriptor.value) ||
78
+ lengthDescriptor.value < 0
79
+ ) {
80
+ return { invalid: invalidTaskState("task events must expose a safe data length") };
81
+ }
82
+ const records = [];
83
+ for (let index = 0; index < lengthDescriptor.value; index++) {
84
+ const descriptor = descriptors[String(index)];
85
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) {
86
+ return {
87
+ invalid: invalidTaskState(
88
+ `task event at index ${index} must be an own enumerable data property`,
89
+ ),
90
+ };
91
+ }
92
+ const snapshot = snapshotEventRecord(descriptor.value, index);
93
+ if (snapshot.invalid) return snapshot;
94
+ records.push(snapshot.record);
95
+ }
96
+ return { records };
97
+ }
98
+
99
+ /**
100
+ * Pure loop derivation shared by the CLI and integrations. Snapshots are
101
+ * opaque equality tokens; callers decide how a checkout is fingerprinted.
102
+ */
103
+ export function deriveLoopState(events, currentSnapshot, nonDocChanged = false) {
104
+ const lastRedIdx = events.findLastIndex(
105
+ (event) => event.event === "red" && event.exit !== 0 && event.genuine !== "no",
106
+ );
107
+ const redEvent = lastRedIdx === -1 ? null : events[lastRedIdx];
108
+ const recordedVerify =
109
+ events
110
+ .slice(lastRedIdx + 1)
111
+ .filter((event) => event.event === "verify" && event.exit === 0)
112
+ .at(-1) ?? null;
113
+ const redImported = Boolean(redEvent?.workerId);
114
+ const redLegacy = Boolean(redEvent && !redEvent.snapshot && !redImported);
115
+ const implementationObserved = redEvent
116
+ ? redImported || redLegacy
117
+ ? nonDocChanged
118
+ : redEvent.snapshot !== currentSnapshot
119
+ : false;
120
+ const verifyStale = Boolean(
121
+ recordedVerify?.workerId ||
122
+ (recordedVerify?.snapshot && recordedVerify.snapshot !== currentSnapshot),
123
+ );
124
+ const verifyEvent = verifyStale ? null : recordedVerify;
125
+ return {
126
+ lastRedIdx,
127
+ redEvent,
128
+ redLegacy,
129
+ recordedVerify,
130
+ verifyEvent,
131
+ verifyStale,
132
+ implementationObserved,
133
+ loop: {
134
+ red: redEvent
135
+ ? {
136
+ done: true,
137
+ genuine: redEvent.genuine,
138
+ cmd: redEvent.cmd,
139
+ exit: redEvent.exit,
140
+ legacy: redLegacy,
141
+ ...(redImported ? { imported: true } : {}),
142
+ }
143
+ : { done: false },
144
+ impl: { done: implementationObserved },
145
+ verify: verifyEvent
146
+ ? {
147
+ done: true,
148
+ cmd: verifyEvent.cmd,
149
+ exit: verifyEvent.exit,
150
+ legacy: !verifyEvent.snapshot,
151
+ stale: false,
152
+ }
153
+ : { done: false, stale: verifyStale },
154
+ },
155
+ };
156
+ }
157
+
158
+ function deriveTaskStateFromRecords(records) {
159
+ const boundaries = records.filter(
160
+ ({ data }) =>
161
+ data.event === "task-start" || data.event === "task-finish" || data.event === "task-reset",
162
+ );
163
+ if (boundaries.length === 0) return { state: "legacy", task: null };
164
+ let state = { state: "idle", task: null, boundary: null };
165
+ const seenTaskIds = new Set();
166
+ for (const { original, data: boundary } of boundaries) {
167
+ const boundaryId = boundary.event === "task-start" ? boundary.id : boundary.taskId;
168
+ if (!isPrintableSingleLine(boundaryId)) {
169
+ return {
170
+ state: "invalid",
171
+ task: null,
172
+ boundary: original,
173
+ reason: `${boundary.event} needs a non-empty ${
174
+ boundary.event === "task-start" ? "id" : "taskId"
175
+ } that is a single printable line`,
176
+ };
177
+ }
178
+ if (boundary.branch !== undefined && !isPrintableSingleLine(boundary.branch)) {
179
+ return {
180
+ state: "invalid",
181
+ task: null,
182
+ boundary: original,
183
+ reason: `${boundary.event} branch must be a non-empty single printable line when present`,
184
+ };
185
+ }
186
+ if (boundary.ts !== undefined && !isCanonicalIsoTimestamp(boundary.ts)) {
187
+ return {
188
+ state: "invalid",
189
+ task: null,
190
+ boundary: original,
191
+ reason: `${boundary.event} timestamp must be a canonical ISO timestamp when present`,
192
+ };
193
+ }
194
+ if (boundary.event === "task-start") {
195
+ if (seenTaskIds.has(boundaryId)) {
196
+ return {
197
+ state: "invalid",
198
+ task: null,
199
+ boundary: original,
200
+ reason: `task-start reuses task ID ${boundaryId}`,
201
+ };
202
+ }
203
+ if (state.state === "active") {
204
+ return {
205
+ state: "invalid",
206
+ task: null,
207
+ boundary: original,
208
+ reason: `task-start ${boundaryId} cannot replace active task ${state.task.id}`,
209
+ };
210
+ }
211
+ if (!isPrintableSingleLine(boundary.name)) {
212
+ return {
213
+ state: "invalid",
214
+ task: null,
215
+ boundary: original,
216
+ reason: "task-start name is required and must be a non-empty single printable line",
217
+ };
218
+ }
219
+ if (
220
+ !Object.hasOwn(boundary, "planBaseline") ||
221
+ (boundary.planBaseline !== null &&
222
+ (typeof boundary.planBaseline !== "string" ||
223
+ !/^sha256:[0-9a-f]{64}$/u.test(boundary.planBaseline)))
224
+ ) {
225
+ return {
226
+ state: "invalid",
227
+ task: null,
228
+ boundary: original,
229
+ reason: "task-start planBaseline is required and must be a sha256 snapshot or null",
230
+ };
231
+ }
232
+ state = {
233
+ state: "active",
234
+ task: {
235
+ id: boundaryId,
236
+ name: boundary.name,
237
+ branch: boundary.branch,
238
+ startedAt: boundary.ts,
239
+ planBaseline: boundary.planBaseline ?? null,
240
+ },
241
+ boundary: original,
242
+ };
243
+ seenTaskIds.add(boundaryId);
244
+ continue;
245
+ }
246
+ if (state.state !== "active") {
247
+ return {
248
+ state: "invalid",
249
+ task: null,
250
+ boundary: original,
251
+ reason: `${boundary.event} ${boundaryId} has no active task to close`,
252
+ };
253
+ }
254
+ if (boundaryId !== state.task.id) {
255
+ return {
256
+ state: "invalid",
257
+ task: null,
258
+ boundary: original,
259
+ reason: `${boundary.event} names ${boundaryId}, but ${state.task.id} is active`,
260
+ };
261
+ }
262
+ state = { state: "idle", task: null, boundary: original };
263
+ }
264
+ return state;
265
+ }
266
+
267
+ /** Derive the active task boundary without reading files or git state. */
268
+ export function deriveTaskState(events) {
269
+ const snapshot = snapshotTaskEvents(events);
270
+ if (snapshot.invalid) return snapshot.invalid;
271
+ return deriveTaskStateFromRecords(snapshot.records);
272
+ }
273
+
274
+ /** Keep only the active task's events; legacy branch-only ledgers pass through. */
275
+ export function scopeTaskEvents(events) {
276
+ const snapshot = snapshotTaskEvents(events);
277
+ if (snapshot.invalid) return { state: snapshot.invalid, events: [] };
278
+ const state = deriveTaskStateFromRecords(snapshot.records);
279
+ if (state.state === "legacy") {
280
+ return { state, events: snapshot.records.map(({ original }) => original) };
281
+ }
282
+ if (state.state === "idle" || state.state === "invalid") return { state, events: [] };
283
+ const boundaryIndex = snapshot.records.findLastIndex(({ original }) => original === state.boundary);
284
+ return {
285
+ state,
286
+ events: snapshot.records
287
+ .slice(boundaryIndex)
288
+ .filter(
289
+ ({ data: event }) =>
290
+ (event.event === "task-start" && event.id === state.task.id) || event.taskId === state.task.id,
291
+ )
292
+ .map(({ original }) => original),
293
+ };
294
+ }
@@ -0,0 +1,47 @@
1
+ # Deferred Design Template
2
+
3
+ A design for work that is agreed but not yet scheduled. Use this template only
4
+ when `.stdd/config.json` keeps `projectLog.enabled` set to `true`. It lives as
5
+ a **dated entry** in the project log (e.g. `docs/project/`), never as a spec
6
+ file next to canonical docs. When the policy is disabled, keep the design
7
+ outside the tracked tree instead. Delete the entry when the work ships or is
8
+ abandoned.
9
+
10
+ The frontmatter is mandatory: it is the machine-readable marker that keeps
11
+ agent retrieval authority-aware — canonical docs never carry
12
+ `authority: non-canonical`, project-log entries always do.
13
+
14
+ ```markdown
15
+ ---
16
+ authority: non-canonical
17
+ status: deferred
18
+ ---
19
+
20
+ # <Title>
21
+
22
+ Last updated: <YYYY-MM-DD>
23
+
24
+ - Status: Deferred | Decision needed | Ready | Blocked
25
+ - Priority: High | Medium | Low
26
+
27
+ ## Problem
28
+
29
+ <What hurts and who it hurts. Impact if never done.>
30
+
31
+ ## Agreed direction
32
+
33
+ <The design, as rules precise enough to implement from. Note explicitly that
34
+ this describes FUTURE behavior — canonical docs still describe the present.>
35
+
36
+ ## Why deferred
37
+
38
+ <The reason it is not being done now.>
39
+
40
+ ## Resume condition
41
+
42
+ <The concrete trigger that should restart this work.>
43
+
44
+ ## Acceptance criteria
45
+
46
+ <How we will know it is done.>
47
+ ```
@@ -0,0 +1,42 @@
1
+ # __STAMP__
2
+ #
3
+ # Validates the PR body fetched LIVE from the API, not the event payload —
4
+ # the payload is frozen at trigger time, so a body-only edit would never be
5
+ # re-checked. The `edited` trigger re-runs this workflow on body changes.
6
+ name: STDD
7
+ on:
8
+ pull_request:
9
+ types: [opened, edited, synchronize, reopened]
10
+
11
+ permissions:
12
+ contents: read
13
+ pull-requests: read
14
+
15
+ jobs:
16
+ stdd:
17
+ name: STDD Contract
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0 # check-pr --base diffs against the base ref
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: 22
26
+ - name: stdd check
27
+ run: npx --yes @stdd/cli@__VERSION__ check .
28
+ - name: PR docs evidence (live body)
29
+ env:
30
+ GH_TOKEN: ${{ github.token }}
31
+ PR_NUMBER: ${{ github.event.pull_request.number }}
32
+ # node, not gh: node is already required for stdd, while self-hosted
33
+ # runners often lack the gh CLI. pipefail: a failed fetch must fail
34
+ # the gate as a fetch error, not feed check-pr an empty body.
35
+ run: |
36
+ set -o pipefail
37
+ node --input-type=module -e '
38
+ const url = "https://api.github.com/repos/" + process.env.GITHUB_REPOSITORY + "/pulls/" + process.env.PR_NUMBER;
39
+ const res = await fetch(url, { headers: { authorization: "Bearer " + process.env.GH_TOKEN, accept: "application/vnd.github+json" } });
40
+ if (!res.ok) { console.error("GitHub API " + res.status + " for " + url); process.exit(1); }
41
+ process.stdout.write((await res.json()).body ?? "");
42
+ ' | npx --yes @stdd/cli@__VERSION__ check-pr - --base "origin/$GITHUB_BASE_REF"
@@ -0,0 +1,72 @@
1
+ # __STAMP__
2
+ #
3
+ # Include this file from the repository's root .gitlab-ci.yml:
4
+ # include:
5
+ # - local: .gitlab/stdd.gitlab-ci.yml
6
+ #
7
+ # The job defaults to GitLab's reserved .pre stage, so this include stays valid
8
+ # when a consumer defines custom stages without "test". To run STDD in an
9
+ # existing consumer stage, override the included job in the root .gitlab-ci.yml.
10
+ # The override must name a stage declared by that pipeline:
11
+ #
12
+ # stdd:
13
+ # stage: verify
14
+ #
15
+ # Same-project merge requests use CI_JOB_TOKEN. A fork pipeline runs in its
16
+ # source project, so the target must allowlist that project under CI/CD job
17
+ # token permissions. Only for a controlled, trusted source project, an
18
+ # optional masked and hidden STDD_GITLAB_READ_API_TOKEN may carry a
19
+ # target-project access token with read_api. Never expose a target token to
20
+ # untrusted fork code.
21
+ stdd:
22
+ stage: .pre
23
+ image: node:22
24
+ variables:
25
+ GIT_DEPTH: "0"
26
+ rules:
27
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
28
+ script:
29
+ - npx --yes @stdd/cli@__VERSION__ check .
30
+ - |
31
+ set -o pipefail
32
+ node --input-type=module -e '
33
+ const targetProjectId = process.env.CI_MERGE_REQUEST_PROJECT_ID;
34
+ const pipelineProjectId = process.env.CI_PROJECT_ID;
35
+ const crossProject = Boolean(
36
+ pipelineProjectId && targetProjectId && pipelineProjectId !== targetProjectId
37
+ );
38
+ const readApiToken = crossProject
39
+ ? process.env.STDD_GITLAB_READ_API_TOKEN
40
+ : "";
41
+ const headers = readApiToken
42
+ ? { "PRIVATE-TOKEN": readApiToken, accept: "application/json" }
43
+ : { "JOB-TOKEN": process.env.CI_JOB_TOKEN, accept: "application/json" };
44
+ const url = process.env.CI_API_V4_URL + "/projects/" +
45
+ encodeURIComponent(targetProjectId) + "/merge_requests/" +
46
+ process.env.CI_MERGE_REQUEST_IID;
47
+ const res = await fetch(url, { headers });
48
+ if (!res.ok) {
49
+ console.error("GitLab API " + res.status + " for " + url);
50
+ if (crossProject && !readApiToken) {
51
+ console.error(
52
+ "Fork or cross-project MR pipeline project " + pipelineProjectId +
53
+ " cannot read target project " + targetProjectId +
54
+ ". Add pipeline project " + pipelineProjectId +
55
+ " to the target CI/CD job token allowlist, or only for a trusted source project " +
56
+ "define masked and hidden STDD_GITLAB_READ_API_TOKEN with target-project read_api."
57
+ );
58
+ } else if (crossProject) {
59
+ console.error(
60
+ "STDD_GITLAB_READ_API_TOKEN cannot read target project " + targetProjectId +
61
+ "; verify that it is a target-project token with read_api."
62
+ );
63
+ } else {
64
+ console.error(
65
+ "The same-project CI_JOB_TOKEN cannot read this merge request; " +
66
+ "verify the triggering user and job-token permissions."
67
+ );
68
+ }
69
+ process.exit(1);
70
+ }
71
+ process.stdout.write((await res.json()).description ?? "");
72
+ ' | npx --yes @stdd/cli@__VERSION__ check-pr - --base "$CI_MERGE_REQUEST_DIFF_BASE_SHA"
@@ -0,0 +1,35 @@
1
+ # PR Description Template
2
+
3
+ The PR description is the durable home for everything that is not a rule:
4
+ rationale, scope decisions, rejected alternatives. Copy, fill, delete unused
5
+ sections.
6
+
7
+ ```markdown
8
+ ## Why
9
+
10
+ <The problem, one paragraph. Link the discussion if there was one.>
11
+
12
+ ## What changed
13
+
14
+ <Behavior first, implementation second. Bullets.>
15
+
16
+ ## Decisions and alternatives
17
+
18
+ <Choices made and what was rejected, with the reason. This is the section
19
+ future readers will thank you for — it never goes in the docs tree.>
20
+
21
+ ## Out of scope
22
+
23
+ <What this PR deliberately does not do.>
24
+
25
+ ## Docs evidence
26
+
27
+ <Exactly one of:>
28
+ Docs updated first: <list of changed docs>
29
+ Docs checked, no change needed: <docs + reason>
30
+ Docs not applicable: <why implementation-only>
31
+
32
+ ## Verification
33
+
34
+ <Commands run and their results. Screenshots for visual work.>
35
+ ```
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function enclosingGitMarkerRoot(start) {
6
+ let candidate = path.resolve(start);
7
+ while (true) {
8
+ try {
9
+ fs.lstatSync(path.join(candidate, ".git"));
10
+ return candidate;
11
+ } catch (error) {
12
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return candidate;
13
+ }
14
+ const parent = path.dirname(candidate);
15
+ if (parent === candidate) return null;
16
+ candidate = parent;
17
+ }
18
+ }
19
+
20
+ export function resolveAdoptingRoot(cwd) {
21
+ const start = path.resolve(cwd);
22
+ const gitMarkerRoot = enclosingGitMarkerRoot(start);
23
+ const top = spawnSync("git", ["-C", start, "rev-parse", "--show-toplevel"], {
24
+ encoding: "utf8",
25
+ });
26
+ const gitRootResolved = !top.error && top.status === 0;
27
+ if (gitRootResolved) {
28
+ const candidate = top.stdout.trim();
29
+ const markerMatches =
30
+ !gitMarkerRoot || path.resolve(gitMarkerRoot) === path.resolve(candidate || ".");
31
+ return candidate && markerMatches && fs.existsSync(path.join(candidate, ".stdd")) ? candidate : null;
32
+ }
33
+ if (gitMarkerRoot) return null;
34
+
35
+ let candidate = start;
36
+ while (true) {
37
+ if (fs.existsSync(path.join(candidate, ".stdd"))) return candidate;
38
+ const parent = path.dirname(candidate);
39
+ if (parent === candidate) return null;
40
+ candidate = parent;
41
+ }
42
+ }