@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,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { escapeNonPrintableSingleLine, isPrintableSingleLine } from "../sdk/text.mjs";
4
+ import { globToRegExp } from "./lib.mjs";
5
+
6
+ /**
7
+ * Split a Buffer of NUL-delimited records into per-record Buffers. Git's
8
+ * `-z` output is raw pathname bytes (a path is any byte sequence but NUL),
9
+ * so records must be sliced on the byte, never decoded first.
10
+ */
11
+ export function splitNul(buf) {
12
+ const out = [];
13
+ let start = 0;
14
+ for (let i = 0; i < buf.length; i++) {
15
+ if (buf[i] === 0) {
16
+ out.push(buf.subarray(start, i));
17
+ start = i + 1;
18
+ }
19
+ }
20
+ if (start < buf.length) out.push(buf.subarray(start));
21
+ return out;
22
+ }
23
+
24
+ // A git path is arbitrary bytes. Across the review subsystem the byte-exact
25
+ // latin1 decode (a bijection: distinct paths never collapse, ASCII structure
26
+ // — the `/`, `.md`, directory names a glob keys on — is preserved) is the
27
+ // match/dedupe/snapshot key; the UTF-8 view, escaped by displayPath, is what
28
+ // a human reads. A glob is source text (Unicode), so it too is encoded to
29
+ // its byte form before compiling, or a non-ASCII glob literal (docs/über/**)
30
+ // would never match its latin1 pathname.
31
+ export const pathForMatch = (buf) => buf.toString("latin1");
32
+ export const pathForView = (latin1) => Buffer.from(latin1, "latin1").toString("utf8");
33
+ export const latinGlob = (glob) => globToRegExp(Buffer.from(glob, "utf8").toString("latin1"));
34
+ export const absPathBuf = (cwd, latin1) =>
35
+ Buffer.concat([Buffer.from(`${cwd}/`), Buffer.from(latin1, "latin1")]);
36
+
37
+ export function parentPathBuf(absolute) {
38
+ const separator = path.sep.charCodeAt(0);
39
+ const index = absolute.lastIndexOf(separator);
40
+ return index === 0 ? absolute.subarray(0, 1) : absolute.subarray(0, index);
41
+ }
42
+
43
+ export function realPathBuf(value) {
44
+ return fs.realpathSync(value, { encoding: "buffer" });
45
+ }
46
+
47
+ export function bufferPathIsWithin(root, candidate) {
48
+ if (candidate.equals(root)) return true;
49
+ const separator = Buffer.from(path.sep);
50
+ const prefix =
51
+ root.length === separator.length && root.equals(separator) ? root : Buffer.concat([root, separator]);
52
+ return candidate.length > prefix.length && candidate.subarray(0, prefix.length).equals(prefix);
53
+ }
54
+
55
+ // Present a path as a quoted literal whenever it contains syntax or a scalar
56
+ // that the shared single-line boundary rejects. Every unsafe scalar is made
57
+ // visible, so a filename cannot split, repaint, hide, or reorder the brief.
58
+ export function displayPath(p) {
59
+ const escaped = escapeNonPrintableSingleLine(p);
60
+ if (escaped === p && isPrintableSingleLine(p) && !p.includes('"') && !p.includes("\\")) return p;
61
+ let quoted = '"';
62
+ for (const scalar of p) {
63
+ if (scalar === '"' || scalar === "\\") quoted += `\\${scalar}`;
64
+ else quoted += escapeNonPrintableSingleLine(scalar);
65
+ }
66
+ return `${quoted}"`;
67
+ }
68
+ // The human view of a latin1 (byte-exact) path. A valid-UTF-8 path renders
69
+ // as its text; a path that is not valid UTF-8 is shown byte-escaped and
70
+ // quoted (`"…\xff.md"`), so distinct invalid byte sequences stay
71
+ // distinguishable in the brief instead of both collapsing to U+FFFD.
72
+ export function viewPath(latin1) {
73
+ const buf = Buffer.from(latin1, "latin1");
74
+ const utf8 = pathForView(latin1);
75
+ if (Buffer.from(utf8, "utf8").equals(buf)) return displayPath(utf8);
76
+ let out = '"';
77
+ for (const b of buf) {
78
+ if (b === 0x22 || b === 0x5c) out += `\\${String.fromCharCode(b)}`;
79
+ else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b);
80
+ else out += `\\x${b.toString(16).padStart(2, "0")}`;
81
+ }
82
+ return `${out}"`;
83
+ }
@@ -0,0 +1,112 @@
1
+ // --- the project policy document: standing decisions, bounded grants ---
2
+ // Policy is durable repository state, not task state: it needs no active task
3
+ // and never touches the ledger. It is tracked, so it is published the way every
4
+ // other tracked file is — identity-bound and atomic, never written in place.
5
+ // Callers translate these errors into CLI diagnostics; the module itself stays
6
+ // testable in process.
7
+ import fs from "node:fs";
8
+ import { resolveWritableRepoPath } from "../sdk/path.mjs";
9
+ import {
10
+ openNativeRepoMutation,
11
+ publishNativeRepoFile,
12
+ readOptionalNativeRepoFile,
13
+ } from "./held-fs.mjs";
14
+ import {
15
+ appendPolicyNote,
16
+ appendPolicyPermission,
17
+ assertPolicyAction,
18
+ POLICY_ACTIONS,
19
+ parsePolicy,
20
+ } from "./lib.mjs";
21
+ import { readWorkerMetadata } from "./worker-metadata.mjs";
22
+
23
+ const POLICY_REL = ".stdd/policy.md";
24
+
25
+ /**
26
+ * Apply one append to the policy document. The document is read and republished
27
+ * inside a single native session: the destination is bound to the identity and
28
+ * bytes observed at read time, so a concurrent edit or a target swapped after
29
+ * resolution fails the publication instead of silently losing the other write.
30
+ */
31
+ async function mutatePolicy(cwd, transform) {
32
+ // Proven state, not a bare marker: a malformed `.stdd/worker.json` in an
33
+ // owning checkout must surface as invalid metadata rather than as the false
34
+ // claim that this is a sandbox.
35
+ if (readWorkerMetadata(cwd)) {
36
+ throw new Error(
37
+ "policy is owned by the source checkout — a managed worker cannot record standing decisions",
38
+ );
39
+ }
40
+ resolveWritableRepoPath(cwd, POLICY_REL, "policy path");
41
+ const context = await openNativeRepoMutation(cwd, "native filesystem helper for policy");
42
+ try {
43
+ const state = await readOptionalNativeRepoFile(context, POLICY_REL, { label: "policy path" });
44
+ const next = transform(state ? state.bytes.toString("utf8") : "");
45
+ await publishNativeRepoFile(context, POLICY_REL, next, {
46
+ mode: 0o644,
47
+ tempPrefix: ".policy-",
48
+ directoryMode: 0o755,
49
+ expectedTarget: state ? state.file.observation.identity : null,
50
+ ...(state ? { expectedBytes: state.bytes } : {}),
51
+ });
52
+ } finally {
53
+ await context.close();
54
+ }
55
+ return POLICY_REL;
56
+ }
57
+
58
+ /**
59
+ * `stdd policy show` — the enforcing read of the policy document.
60
+ *
61
+ * A session must consult policy through this command rather than reading the
62
+ * markdown itself: the closed action set, the printable-line rule and the
63
+ * section boundary live in `parsePolicy`, and a reader that skips it is back to
64
+ * trusting whatever the file happens to say.
65
+ */
66
+ export function policyShow(cwd) {
67
+ const target = resolveWritableRepoPath(cwd, POLICY_REL, "policy path");
68
+ if (!fs.existsSync(target)) {
69
+ console.log("no project policy recorded — stdd policy add <text> starts one");
70
+ return 0;
71
+ }
72
+ const policy = parsePolicy(fs.readFileSync(target, "utf8"));
73
+ if (policy.permissions.length === 0) console.log("permissions: none");
74
+ else {
75
+ console.log("permissions (verify the condition before acting):");
76
+ for (const { action, condition } of policy.permissions) {
77
+ console.log(` ${action} — when: ${condition}`);
78
+ }
79
+ }
80
+ if (policy.notes.length > 0) {
81
+ console.log("notes (advisory — they grant nothing):");
82
+ for (const note of policy.notes) console.log(` ${note}`);
83
+ }
84
+ if (policy.rejected.length > 0) {
85
+ console.log(
86
+ `ignored — ${policy.rejected.length} entr${policy.rejected.length === 1 ? "y names" : "ies name"} an action this kit does not know:`,
87
+ );
88
+ for (const entry of policy.rejected) console.log(` ${entry}`);
89
+ console.log(` known actions: ${POLICY_ACTIONS.join(", ")}`);
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ /** `stdd policy add <text>` — record project nuance. Grants nothing. */
95
+ export async function policyAdd(cwd, text) {
96
+ return await mutatePolicy(cwd, (content) => appendPolicyNote(content, text));
97
+ }
98
+
99
+ /**
100
+ * `stdd policy allow <action> --when <condition>` — pre-authorize one outward
101
+ * effect. The action must come from the closed set, and the condition is
102
+ * mandatory: a grant the session cannot verify is not a grant.
103
+ */
104
+ export async function policyAllow(cwd, action, condition) {
105
+ assertPolicyAction(action);
106
+ if (typeof condition !== "string" || condition.trim() === "") {
107
+ throw new Error(
108
+ `every permission names what to verify: policy allow <action> --when "<verifiable condition>"`,
109
+ );
110
+ }
111
+ return await mutatePolicy(cwd, (content) => appendPolicyPermission(content, action, condition.trim()));
112
+ }
@@ -0,0 +1,188 @@
1
+ // Checkout docs/run recorders; intentionally independent of the CLI entry module.
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { resolveRepoPath } from "../sdk/path.mjs";
5
+ import { assertPrintableSingleLine } from "../sdk/text.mjs";
6
+ import { loadConfig } from "./config.mjs";
7
+ import {
8
+ appendLedger,
9
+ DOCS_DECISIONS,
10
+ ledgerAppendContext,
11
+ withCapturedLedgerIdentity,
12
+ } from "./ledger.mjs";
13
+ import { redGenuine } from "./lib.mjs";
14
+ import { fail, MAX_SUBPROCESS_BUFFER } from "./runtime.mjs";
15
+ import { checkoutSnapshot } from "./snapshot.mjs";
16
+
17
+ const EXCERPT_LIMIT = 2000;
18
+
19
+ /** `stdd docs <decision> [paths…] [--reason <why>]` — record the docs decision. */
20
+ export function recordDocs(cwd, decision, paths, reason) {
21
+ if (!DOCS_DECISIONS.includes(decision)) {
22
+ // Free text is the recurring mistake — answer with the exact forms
23
+ // and, when a decision word is buried in the prose, the corrected call.
24
+ const joined = [decision ?? "", ...paths].join(" ");
25
+ const stem = /not[- ]applicable/i.test(joined)
26
+ ? "not-applicable"
27
+ : /updated[- ]first/i.test(joined)
28
+ ? "updated-first"
29
+ : /\bchecked\b/i.test(joined)
30
+ ? "checked"
31
+ : null;
32
+ fail(
33
+ `unknown docs decision "${decision ?? ""}" — the decision is one word, then its arguments:\n` +
34
+ " stdd docs updated-first <paths…>\n" +
35
+ " stdd docs checked <paths…> --reason <why>\n" +
36
+ " stdd docs not-applicable --reason <why>" +
37
+ (stem ? `\ndid you mean: stdd docs ${stem} …` : ""),
38
+ );
39
+ }
40
+ if (reason !== null) {
41
+ try {
42
+ assertPrintableSingleLine(reason, "docs reason");
43
+ } catch {
44
+ fail("docs reason must be a non-empty single printable line without control characters");
45
+ }
46
+ }
47
+ if (decision === "not-applicable") {
48
+ if (paths.length > 0) {
49
+ fail(
50
+ "not-applicable takes no paths — put the why into --reason:\n" +
51
+ ' stdd docs not-applicable --reason "<why implementation-only>"',
52
+ );
53
+ }
54
+ if (!reason) fail("not-applicable needs --reason <why implementation-only>");
55
+ } else if (paths.length === 0) {
56
+ fail(`${decision} needs at least one docs path`);
57
+ }
58
+ if (decision === "checked" && !reason) {
59
+ fail("checked needs --reason <why no change is needed>");
60
+ }
61
+ for (const docPath of paths) {
62
+ try {
63
+ resolveRepoPath(cwd, docPath, `docs path ${JSON.stringify(docPath)}`);
64
+ } catch (err) {
65
+ fail(err.message);
66
+ }
67
+ }
68
+ const docsContext = ledgerAppendContext(cwd, { event: "docs" });
69
+ const event = {
70
+ event: "docs",
71
+ decision,
72
+ paths,
73
+ snapshot: checkoutSnapshot(cwd),
74
+ ...(reason ? { reason } : {}),
75
+ ...(docsContext.task ? { taskId: docsContext.task.id } : {}),
76
+ };
77
+ try {
78
+ withCapturedLedgerIdentity(
79
+ cwd,
80
+ {
81
+ expectedBranch: docsContext.branch,
82
+ expectedTaskState: docsContext.taskState,
83
+ subject: "docs evidence",
84
+ retry: "stdd docs",
85
+ },
86
+ () =>
87
+ appendLedger(cwd, event, {
88
+ preserveTaskScope: true,
89
+ lockHeld: true,
90
+ expectedBranch: docsContext.branch,
91
+ }),
92
+ );
93
+ } catch (err) {
94
+ fail(err.message);
95
+ }
96
+ console.log(`stdd docs: recorded ${decision}${paths.length ? ` (${paths.join(", ")})` : ""}`);
97
+ }
98
+
99
+ /**
100
+ * `stdd red|verify -- <cmd>` — run the command, record {cmd, exit, excerpt}
101
+ * verbatim, pass the exit code through. Output flows to the caller unchanged.
102
+ */
103
+ export function recordRun(cwd, kind, argv) {
104
+ // A single whitespace-carrying word after -- is a description, not a
105
+ // command — it can never spawn. Reject with the corrected form and
106
+ // record nothing: prose in the ledger verifies nothing.
107
+ if (argv.length === 1 && /\s/.test(argv[0])) {
108
+ fail(
109
+ `${kind} takes the command and its arguments, never prose — nothing was recorded\n` +
110
+ ` e.g.: stdd ${kind} -- pnpm --filter api test\n` +
111
+ ` shell constructs: stdd ${kind} -- sh -c "<cmd>"`,
112
+ );
113
+ }
114
+ // A recorder must know it can persist the result before it launches a
115
+ // command with arbitrary side effects.
116
+ const runContext = ledgerAppendContext(cwd, { event: kind });
117
+ const config = loadConfig(cwd);
118
+ const result = spawnSync(argv[0], argv.slice(1), {
119
+ encoding: "utf8",
120
+ maxBuffer: MAX_SUBPROCESS_BUFFER,
121
+ });
122
+ let exit = result.status ?? 1;
123
+ let output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
124
+ if (result.error) {
125
+ exit = 127;
126
+ output += `${result.error.message}\n`;
127
+ }
128
+ if (result.stdout) process.stdout.write(result.stdout);
129
+ if (result.stderr) process.stderr.write(result.stderr);
130
+ if (result.error) {
131
+ console.error(
132
+ `stdd ${kind}: ${result.error.message}` +
133
+ (result.error.code === "ENOENT"
134
+ ? " — command not found; is the worktree ready (stdd doctor --readiness)? " +
135
+ "Shell constructs need sh -c"
136
+ : ""),
137
+ );
138
+ }
139
+
140
+ const event = {
141
+ event: kind,
142
+ cmd: argv.join(" "),
143
+ exit,
144
+ excerpt: output.slice(-EXCERPT_LIMIT),
145
+ snapshot: checkoutSnapshot(cwd),
146
+ };
147
+ if (kind === "red") {
148
+ event.genuine = redGenuine(exit, output, config.redPattern ?? null);
149
+ if (exit === 0) {
150
+ console.error('stdd red: the command exited 0 — that is green, not red (recorded genuine: "no")');
151
+ } else if (event.genuine === "unknown") {
152
+ console.error(
153
+ 'stdd red: no redPattern in .stdd/config.json — cannot assert genuine-red (recorded genuine: "unknown")',
154
+ );
155
+ } else if (event.genuine === "no") {
156
+ console.error(
157
+ 'stdd red: output does not match redPattern — this looks like an environment error, not a genuine red (recorded genuine: "no")',
158
+ );
159
+ }
160
+ }
161
+ try {
162
+ withCapturedLedgerIdentity(
163
+ cwd,
164
+ {
165
+ expectedBranch: runContext.branch,
166
+ expectedTaskState: runContext.taskState,
167
+ subject: `${kind} result`,
168
+ retry: `stdd ${kind}`,
169
+ },
170
+ () =>
171
+ appendLedger(
172
+ cwd,
173
+ {
174
+ ...event,
175
+ ...(runContext.task ? { taskId: runContext.task.id } : {}),
176
+ },
177
+ {
178
+ preserveTaskScope: true,
179
+ lockHeld: true,
180
+ expectedBranch: runContext.branch,
181
+ },
182
+ ),
183
+ );
184
+ } catch (err) {
185
+ fail(err.message);
186
+ }
187
+ process.exit(exit);
188
+ }