@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.
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- 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
|
+
}
|