@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,1470 @@
|
|
|
1
|
+
// --- the session ledger (see method: "The session ledger and stdd status") ---
|
|
2
|
+
//
|
|
3
|
+
// Owns the ledger's durable shape end to end: the event schema, branch and
|
|
4
|
+
// task scoping, the lock/transaction/recovery machinery, the task lifecycle,
|
|
5
|
+
// the durable plan, and `stdd defer`. It has no dependency on the entry module.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { sameFileIdentity } from "../sdk/file-observation.mjs";
|
|
12
|
+
import { assertPrintableSingleLine, isPrintableSingleLine } from "../sdk/text.mjs";
|
|
13
|
+
import { deriveTaskState, scopeTaskEvents } from "../sdk/workflow.mjs";
|
|
14
|
+
import { loadConfig } from "./config.mjs";
|
|
15
|
+
import {
|
|
16
|
+
openOrCreateNativeRepoDirectory,
|
|
17
|
+
readNativeFile,
|
|
18
|
+
readOptionalNativeRepoFile,
|
|
19
|
+
verifyNativeRepoDirectory,
|
|
20
|
+
writeNativeFileContent,
|
|
21
|
+
} from "./held-fs.mjs";
|
|
22
|
+
import { appendDeferred, deriveReviewVerdict, parseReviewResult, sha256 } from "./lib.mjs";
|
|
23
|
+
import { splitNul } from "./path-bytes.mjs";
|
|
24
|
+
import { git, MAX_SUBPROCESS_BUFFER, statePath } from "./runtime.mjs";
|
|
25
|
+
import {
|
|
26
|
+
isLedgerStringArray,
|
|
27
|
+
isPlainLedgerRecord,
|
|
28
|
+
MANIFEST_HASH_PATTERN,
|
|
29
|
+
sameReviewPrivateState,
|
|
30
|
+
} from "./state-validation.mjs";
|
|
31
|
+
import { findWorkerRoot, readWorkerMetadata, WORKER_ID_PATTERN } from "./worker-metadata.mjs";
|
|
32
|
+
|
|
33
|
+
export const LEDGER_REL = ".stdd/ledger.jsonl";
|
|
34
|
+
export const PLAN_REL = ".stdd/plan.md";
|
|
35
|
+
export const STATE_EXEMPT = [LEDGER_REL, PLAN_REL];
|
|
36
|
+
export const LEGACY_LEDGER_RESET_TEMP_IGNORE = ".stdd/.ledger-reset-*.tmp";
|
|
37
|
+
export const LEDGER_RESET_TEMP_GIT_GLOB = `.stdd/.ledger-reset-${"[0-9a-f]".repeat(32)}.tmp`;
|
|
38
|
+
const LEDGER_INTERNAL_TEMP_PREFIXES = [
|
|
39
|
+
"ledger-reset",
|
|
40
|
+
"ledger-prepared",
|
|
41
|
+
"ledger-recovered",
|
|
42
|
+
"ledger-aborted",
|
|
43
|
+
];
|
|
44
|
+
export const LEDGER_INTERNAL_TEMP_GIT_GLOBS = LEDGER_INTERNAL_TEMP_PREFIXES.map(
|
|
45
|
+
(prefix) => `.stdd/.${prefix}-${"[0-9a-f]".repeat(32)}.tmp`,
|
|
46
|
+
);
|
|
47
|
+
const LEDGER_ACTIVE_TEMP_BASENAME = /^\.(?:ledger-reset|ledger-prepared)-[0-9a-f]{32}\.tmp$/;
|
|
48
|
+
export const LEDGER_INTERNAL_TEMP_RELATIVE =
|
|
49
|
+
/^\.stdd\/\.(?:ledger-reset|ledger-prepared)-[0-9a-f]{32}\.tmp$/;
|
|
50
|
+
const LEDGER_RETAINED_FILE_RELATIVE =
|
|
51
|
+
/^\.stdd\/ledger-quarantines\/(\.ledger-recovered-([0-9a-f]{32})\.tmp)\/(inventory\.json|payload)$/;
|
|
52
|
+
const LF_BYTE = 0x0a;
|
|
53
|
+
const LEGACY_FLOW_MARKER = "taskless-v1";
|
|
54
|
+
const LEGACY_RECORDER_EVENTS = new Set(["docs", "red", "verify", "note"]);
|
|
55
|
+
const LEDGER_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
56
|
+
const LEDGER_PARTIAL_LOCK_GRACE_MS = 1_000;
|
|
57
|
+
const MAX_LEDGER_BYTES = 16 * 1024 * 1024;
|
|
58
|
+
const LEDGER_MODULE = import.meta.url;
|
|
59
|
+
export const REVIEW_VIAS = ["subagent", "codex", "claude"];
|
|
60
|
+
export const DOCS_DECISIONS = ["updated-first", "checked", "not-applicable"];
|
|
61
|
+
export const LABEL_TO_DECISION = {
|
|
62
|
+
"Docs updated first": "updated-first",
|
|
63
|
+
"Docs checked, no change needed": "checked",
|
|
64
|
+
"Docs not applicable": "not-applicable",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function fail(message) {
|
|
68
|
+
fs.writeSync(process.stderr.fd, `stdd: ${message}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** UTF-8 git paths without core.quotePath/C-style quoting. */
|
|
73
|
+
export function gitChangedPaths(repoDir, range) {
|
|
74
|
+
return splitNul(
|
|
75
|
+
execFileSync("git", ["-C", repoDir, "diff", "--name-only", "-z", "--end-of-options", range], {
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
77
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
78
|
+
}),
|
|
79
|
+
).map((entry) => entry.toString("utf8"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Staged, unstaged, and untracked paths, with NUL-safe Git output. */
|
|
83
|
+
export function gitWorkingPaths(repoDir) {
|
|
84
|
+
const tracked = splitNul(
|
|
85
|
+
execFileSync("git", ["-C", repoDir, "diff", "--name-only", "-z", "HEAD"], {
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
const untracked = splitNul(
|
|
91
|
+
execFileSync("git", ["-C", repoDir, "ls-files", "--others", "--exclude-standard", "-z"], {
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
return [...tracked, ...untracked].map((entry) => entry.toString("utf8"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ownedMode(observed, kind, mode, currentUid) {
|
|
100
|
+
const shape =
|
|
101
|
+
(kind === "file" ? observed.isFile() : observed.isDirectory()) &&
|
|
102
|
+
!observed.isSymbolicLink() &&
|
|
103
|
+
(kind !== "file" || observed.nlink === 1);
|
|
104
|
+
if (!shape) return false;
|
|
105
|
+
// Node exposes synthetic POSIX mode/uid values on Windows. The native
|
|
106
|
+
// creator already enforces a protected current-user DACL; read-only
|
|
107
|
+
// inventory recognition can bind only the exact token/provenance/shape.
|
|
108
|
+
if (process.platform === "win32") return true;
|
|
109
|
+
return (observed.mode & 0o777) === mode && (currentUid === null || observed.uid === currentUid);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trustedLedgerQuarantine(cwd, containerName, token) {
|
|
113
|
+
try {
|
|
114
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
115
|
+
const root = path.join(cwd, ".stdd", "ledger-quarantines");
|
|
116
|
+
const container = path.join(root, containerName);
|
|
117
|
+
if (!ownedMode(fs.lstatSync(root), "directory", 0o700, currentUid)) return null;
|
|
118
|
+
if (!ownedMode(fs.lstatSync(container), "directory", 0o700, currentUid)) return null;
|
|
119
|
+
const names = fs.readdirSync(container).sort();
|
|
120
|
+
if (names.length !== 2 || names[0] !== "inventory.json" || names[1] !== "payload") return null;
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
if (!ownedMode(fs.lstatSync(path.join(container, name)), "file", 0o600, currentUid)) return null;
|
|
123
|
+
}
|
|
124
|
+
const inventory = JSON.parse(fs.readFileSync(path.join(container, "inventory.json"), "utf8"));
|
|
125
|
+
const retained = `.stdd/ledger-quarantines/${containerName}/payload`;
|
|
126
|
+
if (
|
|
127
|
+
inventory?.schema !== 1 ||
|
|
128
|
+
inventory.kind !== "ledger-transaction-temp" ||
|
|
129
|
+
inventory.phase !== "recovered" ||
|
|
130
|
+
!new RegExp(`^\\.stdd/\\.ledger-(?:reset|prepared)-${token}\\.tmp$`).test(inventory.original) ||
|
|
131
|
+
inventory.retained !== retained ||
|
|
132
|
+
inventory.identity?.version !== 2 ||
|
|
133
|
+
inventory.identity.kind !== "file"
|
|
134
|
+
) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return inventory;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function ledgerQuarantineInventory(cwd) {
|
|
144
|
+
const root = path.join(cwd, ".stdd", "ledger-quarantines");
|
|
145
|
+
let names;
|
|
146
|
+
try {
|
|
147
|
+
names = fs.readdirSync(root);
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
return names
|
|
152
|
+
.map((containerName) => {
|
|
153
|
+
const match = containerName.match(/^\.ledger-recovered-([0-9a-f]{32})\.tmp$/);
|
|
154
|
+
if (!match || !trustedLedgerQuarantine(cwd, containerName, match[1])) return null;
|
|
155
|
+
return {
|
|
156
|
+
relative: `.stdd/ledger-quarantines/${containerName}`,
|
|
157
|
+
provenance: "ledger recovery inventory",
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isTrustedLedgerInternalTemp(cwd, file) {
|
|
165
|
+
const retained = file.match(LEDGER_RETAINED_FILE_RELATIVE);
|
|
166
|
+
if (retained) return trustedLedgerQuarantine(cwd, retained[1], retained[2]) !== null;
|
|
167
|
+
if (!LEDGER_INTERNAL_TEMP_RELATIVE.test(file)) return false;
|
|
168
|
+
try {
|
|
169
|
+
const observed = fs.lstatSync(path.join(cwd, file));
|
|
170
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
171
|
+
return ownedMode(observed, "file", 0o600, currentUid);
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function isStateExemptPath(cwd, file) {
|
|
178
|
+
return STATE_EXEMPT.includes(file) || isTrustedLedgerInternalTemp(cwd, file);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The ledger and config anchor to the repository, never the shell's cwd —
|
|
183
|
+
* a recorder run from a subdirectory must not create a nested `.stdd/`.
|
|
184
|
+
* Resolution: the git toplevel when it holds `.stdd/` (or when none exists
|
|
185
|
+
* yet), otherwise the nearest ancestor holding `.stdd/`. Outside a git
|
|
186
|
+
* repo the cwd is returned unchanged — recorders require git anyway.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveRepoDir(cwd) {
|
|
189
|
+
let top;
|
|
190
|
+
try {
|
|
191
|
+
top = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
194
|
+
}).trim();
|
|
195
|
+
} catch {
|
|
196
|
+
return findWorkerRoot(cwd) ?? cwd;
|
|
197
|
+
}
|
|
198
|
+
if (fs.existsSync(path.join(top, ".stdd"))) return top;
|
|
199
|
+
let dir = path.resolve(cwd);
|
|
200
|
+
while (dir !== top && path.dirname(dir) !== dir) {
|
|
201
|
+
if (fs.existsSync(path.join(dir, ".stdd"))) return dir;
|
|
202
|
+
dir = path.dirname(dir);
|
|
203
|
+
}
|
|
204
|
+
return top;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Current printable branch name, or null outside a git repo; hostile names fail before use. */
|
|
208
|
+
export function currentBranch(cwd) {
|
|
209
|
+
let branch;
|
|
210
|
+
try {
|
|
211
|
+
branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
212
|
+
encoding: "utf8",
|
|
213
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
214
|
+
}).trim();
|
|
215
|
+
} catch {
|
|
216
|
+
return readWorkerMetadata(cwd)?.source.branch ?? null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
return assertPrintableSingleLine(branch, "current Git branch");
|
|
220
|
+
} catch {
|
|
221
|
+
fail("current Git branch must be a non-empty single printable line without control characters");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function requireBranch(cwd) {
|
|
226
|
+
const branch = currentBranch(cwd);
|
|
227
|
+
if (!branch) fail("the ledger needs a git repository with at least one commit");
|
|
228
|
+
return branch;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- the durable event schema ---
|
|
232
|
+
|
|
233
|
+
function isOptionalPrintable(value) {
|
|
234
|
+
return value === undefined || isPrintableSingleLine(value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isOptionalSnapshot(value) {
|
|
238
|
+
return value === undefined || isPrintableSingleLine(value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isCanonicalLedgerTimestamp(value) {
|
|
242
|
+
if (value === undefined) return true;
|
|
243
|
+
if (!isPrintableSingleLine(value)) return false;
|
|
244
|
+
try {
|
|
245
|
+
return new Date(value).toISOString() === value;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isLedgerReviewFindings(summary, findings) {
|
|
252
|
+
return parseReviewResult(JSON.stringify({ summary, findings })) !== null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isLedgerReviewRunner(value) {
|
|
256
|
+
return (
|
|
257
|
+
value === undefined ||
|
|
258
|
+
(isPlainLedgerRecord(value) &&
|
|
259
|
+
isPrintableSingleLine(value.command) &&
|
|
260
|
+
(value.exit === null || Number.isSafeInteger(value.exit)) &&
|
|
261
|
+
isOptionalPrintable(value.error))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isLedgerScopeBaseline(value) {
|
|
266
|
+
if (
|
|
267
|
+
!isPlainLedgerRecord(value) ||
|
|
268
|
+
!isPrintableSingleLine(value.head) ||
|
|
269
|
+
!isPlainLedgerRecord(value.dirty)
|
|
270
|
+
) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return Object.values(value.dirty).every(
|
|
274
|
+
(fingerprint) => fingerprint === null || typeof fingerprint === "string",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function isStateLedgerEvent(event) {
|
|
279
|
+
const taskBoundary =
|
|
280
|
+
event.event === "task-start" || event.event === "task-finish" || event.event === "task-reset";
|
|
281
|
+
if (
|
|
282
|
+
!isPrintableSingleLine(event.event) ||
|
|
283
|
+
!isPrintableSingleLine(event.branch) ||
|
|
284
|
+
!isCanonicalLedgerTimestamp(event.ts) ||
|
|
285
|
+
(!taskBoundary && !isOptionalPrintable(event.taskId)) ||
|
|
286
|
+
(event.legacyFlow !== undefined && event.legacyFlow !== LEGACY_FLOW_MARKER)
|
|
287
|
+
) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
switch (event.event) {
|
|
291
|
+
case "task-start":
|
|
292
|
+
case "task-finish":
|
|
293
|
+
case "task-reset":
|
|
294
|
+
// deriveTaskState owns the full task-boundary schema and preserves
|
|
295
|
+
// its field-specific repair reason. Every state consumer passes
|
|
296
|
+
// these records through that validator before using them.
|
|
297
|
+
return true;
|
|
298
|
+
case "docs":
|
|
299
|
+
if (
|
|
300
|
+
!DOCS_DECISIONS.includes(event.decision) ||
|
|
301
|
+
!isLedgerStringArray(event.paths) ||
|
|
302
|
+
!isOptionalSnapshot(event.snapshot) ||
|
|
303
|
+
!isOptionalPrintable(event.reason)
|
|
304
|
+
) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return event.decision === "not-applicable"
|
|
308
|
+
? event.paths.length === 0 && isPrintableSingleLine(event.reason)
|
|
309
|
+
: event.paths.length > 0 &&
|
|
310
|
+
(event.decision !== "checked" || isPrintableSingleLine(event.reason));
|
|
311
|
+
case "red":
|
|
312
|
+
return (
|
|
313
|
+
typeof event.cmd === "string" &&
|
|
314
|
+
event.cmd.length > 0 &&
|
|
315
|
+
Number.isSafeInteger(event.exit) &&
|
|
316
|
+
event.exit >= 0 &&
|
|
317
|
+
typeof event.excerpt === "string" &&
|
|
318
|
+
isOptionalSnapshot(event.snapshot) &&
|
|
319
|
+
["yes", "no", "unknown"].includes(event.genuine)
|
|
320
|
+
);
|
|
321
|
+
case "verify":
|
|
322
|
+
return (
|
|
323
|
+
typeof event.cmd === "string" &&
|
|
324
|
+
event.cmd.length > 0 &&
|
|
325
|
+
Number.isSafeInteger(event.exit) &&
|
|
326
|
+
event.exit >= 0 &&
|
|
327
|
+
typeof event.excerpt === "string" &&
|
|
328
|
+
isOptionalSnapshot(event.snapshot)
|
|
329
|
+
);
|
|
330
|
+
case "note":
|
|
331
|
+
return typeof event.text === "string" && event.text.length > 0;
|
|
332
|
+
case "scope":
|
|
333
|
+
return (
|
|
334
|
+
isLedgerStringArray(event.frozenPaths) &&
|
|
335
|
+
isLedgerStringArray(event.allowedPaths) &&
|
|
336
|
+
event.frozenPaths.length + event.allowedPaths.length > 0 &&
|
|
337
|
+
isLedgerScopeBaseline(event.baseline)
|
|
338
|
+
);
|
|
339
|
+
case "worker-create":
|
|
340
|
+
return (
|
|
341
|
+
WORKER_ID_PATTERN.test(event.workerId) &&
|
|
342
|
+
MANIFEST_HASH_PATTERN.test(event.metadataHash) &&
|
|
343
|
+
isPrintableSingleLine(event.sourceHead)
|
|
344
|
+
);
|
|
345
|
+
case "review-request":
|
|
346
|
+
return (
|
|
347
|
+
isPrintableSingleLine(event.id) &&
|
|
348
|
+
REVIEW_VIAS.includes(event.via) &&
|
|
349
|
+
isOptionalSnapshot(event.snapshot) &&
|
|
350
|
+
isOptionalPrintable(event.brief) &&
|
|
351
|
+
isOptionalPrintable(event.briefHash) &&
|
|
352
|
+
(event.brief !== undefined || event.briefHash !== undefined) &&
|
|
353
|
+
isPrintableSingleLine(event.briefPath) &&
|
|
354
|
+
isOptionalPrintable(event.forced) &&
|
|
355
|
+
(event.privateState === undefined ||
|
|
356
|
+
sameReviewPrivateState(event.privateState, event.privateState))
|
|
357
|
+
);
|
|
358
|
+
case "review":
|
|
359
|
+
if (
|
|
360
|
+
!isOptionalPrintable(event.request) ||
|
|
361
|
+
!REVIEW_VIAS.includes(event.via) ||
|
|
362
|
+
!["approved", "changes-requested", "error"].includes(event.verdict) ||
|
|
363
|
+
!isOptionalSnapshot(event.snapshot) ||
|
|
364
|
+
!isLedgerReviewRunner(event.runner)
|
|
365
|
+
) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (event.verdict === "error") return isPrintableSingleLine(event.reason);
|
|
369
|
+
return (
|
|
370
|
+
isLedgerReviewFindings(event.summary, event.findings) &&
|
|
371
|
+
deriveReviewVerdict(event.findings) === event.verdict
|
|
372
|
+
);
|
|
373
|
+
case "review-cancelled":
|
|
374
|
+
return (
|
|
375
|
+
isPrintableSingleLine(event.request) &&
|
|
376
|
+
REVIEW_VIAS.includes(event.via) &&
|
|
377
|
+
isPrintableSingleLine(event.reason)
|
|
378
|
+
);
|
|
379
|
+
default:
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* State derivation must preserve every non-blank ledger line. The public
|
|
386
|
+
* parseLedger helper remains tolerant for callers that only inspect evidence,
|
|
387
|
+
* but silently dropping a torn write here could revive older legacy state.
|
|
388
|
+
* A syntax error or event without trustworthy branch metadata is represented
|
|
389
|
+
* by a non-object event so deriveTaskState reports the ledger as invalid
|
|
390
|
+
* through the same boundary contract.
|
|
391
|
+
*/
|
|
392
|
+
export function parseStateLedger(text, branch) {
|
|
393
|
+
const events = [];
|
|
394
|
+
for (const line of text.split("\n")) {
|
|
395
|
+
if (!line.trim()) continue;
|
|
396
|
+
let event;
|
|
397
|
+
try {
|
|
398
|
+
event = JSON.parse(line);
|
|
399
|
+
} catch {
|
|
400
|
+
events.push(null);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
404
|
+
events.push(event);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (!isPrintableSingleLine(event.branch)) {
|
|
408
|
+
events.push(null);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (event.branch !== branch) continue;
|
|
412
|
+
if (!isStateLedgerEvent(event)) {
|
|
413
|
+
events.push(null);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
events.push(event);
|
|
417
|
+
}
|
|
418
|
+
return events;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function rawLedger(cwd, branch) {
|
|
422
|
+
const ledgerPath = statePath(cwd, LEDGER_REL, "ledger path");
|
|
423
|
+
if (!fs.existsSync(ledgerPath)) return [];
|
|
424
|
+
return parseStateLedger(fs.readFileSync(ledgerPath, "utf8"), branch);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Branch named by a local or remote base ref, or null for tags/expressions. */
|
|
428
|
+
function baseRefBranch(cwd, baseRef) {
|
|
429
|
+
if (!baseRef) return null;
|
|
430
|
+
let fullRef;
|
|
431
|
+
try {
|
|
432
|
+
fullRef = git("-C", cwd, "rev-parse", "--symbolic-full-name", "--verify", baseRef);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (fullRef.startsWith("refs/heads/")) return fullRef.slice("refs/heads/".length);
|
|
437
|
+
if (!fullRef.startsWith("refs/remotes/")) return null;
|
|
438
|
+
const remoteAndBranch = fullRef.slice("refs/remotes/".length);
|
|
439
|
+
const separator = remoteAndBranch.indexOf("/");
|
|
440
|
+
return separator < 0 ? null : remoteAndBranch.slice(separator + 1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function legacyCheckoutIsIdle(cwd, branch, config) {
|
|
444
|
+
const baseRef = config.baseRef ?? "";
|
|
445
|
+
const isBaseBranch = baseRefBranch(cwd, baseRef) === branch;
|
|
446
|
+
if (!isBaseBranch) return false;
|
|
447
|
+
try {
|
|
448
|
+
const changed = [
|
|
449
|
+
...new Set([...gitChangedPaths(cwd, `${config.baseRef}...HEAD`), ...gitWorkingPaths(cwd)]),
|
|
450
|
+
].filter((file) => !isStateExemptPath(cwd, file));
|
|
451
|
+
return changed.length === 0;
|
|
452
|
+
} catch {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Apply task boundaries and the clean-base legacy compatibility rule once. */
|
|
458
|
+
export function scopeLedgerForCheckout(
|
|
459
|
+
cwd,
|
|
460
|
+
branch,
|
|
461
|
+
events = rawLedger(cwd, branch),
|
|
462
|
+
config = loadConfig(cwd),
|
|
463
|
+
) {
|
|
464
|
+
const scoped = scopeTaskEvents(events);
|
|
465
|
+
const managedLegacyFlow = events.some((event) => event?.legacyFlow === LEGACY_FLOW_MARKER);
|
|
466
|
+
if (
|
|
467
|
+
scoped.state.state === "legacy" &&
|
|
468
|
+
events.length > 0 &&
|
|
469
|
+
!managedLegacyFlow &&
|
|
470
|
+
legacyCheckoutIsIdle(cwd, branch, config)
|
|
471
|
+
) {
|
|
472
|
+
return {
|
|
473
|
+
state: { state: "idle", task: null, reason: "clean-base-legacy" },
|
|
474
|
+
events: [],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return scoped;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function taskLifecycleState(events) {
|
|
481
|
+
const state = deriveTaskState(events);
|
|
482
|
+
if (state.state === "invalid") {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`malformed task boundary in .stdd/ledger.jsonl: ${state.reason} — ` +
|
|
485
|
+
"repair .stdd/ledger.jsonl before changing task lifecycle",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return state;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function inspectLedgerAppendContext(
|
|
492
|
+
cwd,
|
|
493
|
+
event,
|
|
494
|
+
{ allowHistoricalTask = false, allowBranchScopedHistorical = false } = {},
|
|
495
|
+
) {
|
|
496
|
+
const branch = currentBranch(cwd);
|
|
497
|
+
if (!branch) {
|
|
498
|
+
throw new Error("the ledger needs a git repository with at least one commit");
|
|
499
|
+
}
|
|
500
|
+
const taskState = scopeLedgerForCheckout(cwd, branch).state;
|
|
501
|
+
if (taskState.state === "invalid") {
|
|
502
|
+
throw new Error(`malformed task boundary in .stdd/ledger.jsonl: ${taskState.reason}`);
|
|
503
|
+
}
|
|
504
|
+
const task = taskState.state === "active" ? taskState.task : null;
|
|
505
|
+
const closesHistoricalEvent =
|
|
506
|
+
(typeof event.taskId === "string" && (event.event === "review-cancelled" || allowHistoricalTask)) ||
|
|
507
|
+
(event.event === "review-cancelled" && allowBranchScopedHistorical);
|
|
508
|
+
const emptyCleanBaseReview =
|
|
509
|
+
event.event === "review-request" &&
|
|
510
|
+
taskState.state === "legacy" &&
|
|
511
|
+
legacyCheckoutIsIdle(cwd, branch, loadConfig(cwd));
|
|
512
|
+
if (
|
|
513
|
+
(taskState.state === "idle" || emptyCleanBaseReview) &&
|
|
514
|
+
event.event !== "task-start" &&
|
|
515
|
+
!closesHistoricalEvent
|
|
516
|
+
) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
'no active task — run `stdd task start "<short name>"` before recording new evidence',
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return { branch, taskState, task };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function ledgerAppendContext(cwd, event, options = {}) {
|
|
525
|
+
try {
|
|
526
|
+
return inspectLedgerAppendContext(cwd, event, options);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
fail(err.message);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// --- the ledger lock, its recovery, and the append/transaction path ---
|
|
533
|
+
|
|
534
|
+
function ledgerLockPath(cwd) {
|
|
535
|
+
const repo = fs.realpathSync(cwd);
|
|
536
|
+
const key = createHash("sha256").update(repo).digest("hex").slice(0, 32);
|
|
537
|
+
return path.join(os.tmpdir(), `stdd-ledger-${key}.lock`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// OS-temp lock cleanup is outside the repository capability boundary. It
|
|
541
|
+
// still conditions retirement on the exact inode observed by this process.
|
|
542
|
+
function retirePortableTempMetadata(
|
|
543
|
+
filePath,
|
|
544
|
+
observed,
|
|
545
|
+
{ preserve = false, prefix = ".stdd-remove" } = {},
|
|
546
|
+
) {
|
|
547
|
+
let quarantine = null;
|
|
548
|
+
try {
|
|
549
|
+
const parent = path.dirname(filePath);
|
|
550
|
+
const parentObserved = fs.lstatSync(parent);
|
|
551
|
+
if (!parentObserved.isDirectory() || parentObserved.isSymbolicLink()) return false;
|
|
552
|
+
const current = fs.lstatSync(filePath);
|
|
553
|
+
if (!sameFileIdentity(current, observed)) return false;
|
|
554
|
+
quarantine = path.join(parent, `${prefix}-${randomBytes(16).toString("hex")}.tmp`);
|
|
555
|
+
fs.renameSync(filePath, quarantine);
|
|
556
|
+
const parentAfter = fs.lstatSync(parent);
|
|
557
|
+
const moved = fs.lstatSync(quarantine);
|
|
558
|
+
if (!sameFileIdentity(parentObserved, parentAfter) || !sameFileIdentity(current, moved)) {
|
|
559
|
+
if (!fs.existsSync(filePath)) fs.renameSync(quarantine, filePath);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
if (!preserve) {
|
|
563
|
+
// Lock/owner files contain only STDD coordination metadata and
|
|
564
|
+
// live in a sticky/private temp directory. The unpredictable
|
|
565
|
+
// quarantine name narrows cleanup to the inode just moved out of
|
|
566
|
+
// the authoritative lock path. Repository data uses preserve=true
|
|
567
|
+
// because concurrent user edits must never be unlinked.
|
|
568
|
+
try {
|
|
569
|
+
fs.unlinkSync(quarantine);
|
|
570
|
+
} catch {
|
|
571
|
+
// The lock name is already retired. A cleanup remnant is inert
|
|
572
|
+
// and preferable to making lock release fail after the action.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
quarantine = null;
|
|
576
|
+
return true;
|
|
577
|
+
} catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function recoverAbandonedLedgerLock(lockPath) {
|
|
583
|
+
let observed;
|
|
584
|
+
let owner = null;
|
|
585
|
+
try {
|
|
586
|
+
observed = fs.lstatSync(lockPath);
|
|
587
|
+
if (!observed.isFile() || observed.isSymbolicLink()) return false;
|
|
588
|
+
try {
|
|
589
|
+
owner = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
590
|
+
} catch {
|
|
591
|
+
// Legacy lock acquisition exposed this inode before writing owner
|
|
592
|
+
// metadata. A grace period protects a live, just-created lock.
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
const validOwner =
|
|
598
|
+
Number.isSafeInteger(owner?.pid) &&
|
|
599
|
+
owner.pid > 0 &&
|
|
600
|
+
typeof owner.token === "string" &&
|
|
601
|
+
/^[0-9a-f]{32}$/.test(owner.token);
|
|
602
|
+
if (!validOwner) {
|
|
603
|
+
const age = Date.now() - Math.max(observed.mtimeMs, observed.ctimeMs);
|
|
604
|
+
return age >= LEDGER_PARTIAL_LOCK_GRACE_MS ? retirePortableTempMetadata(lockPath, observed) : false;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
process.kill(owner.pid, 0);
|
|
608
|
+
return false;
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (err.code !== "ESRCH") return false;
|
|
611
|
+
}
|
|
612
|
+
const recovered = retirePortableTempMetadata(lockPath, observed);
|
|
613
|
+
if (recovered) {
|
|
614
|
+
const ownerPath = `${lockPath}.${owner.pid}.${owner.token}.owner`;
|
|
615
|
+
retirePortableTempMetadata(ownerPath, observed);
|
|
616
|
+
}
|
|
617
|
+
return recovered;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function sameNativeIdentity(left, right) {
|
|
621
|
+
return (
|
|
622
|
+
left?.version === right?.version &&
|
|
623
|
+
left?.platform === right?.platform &&
|
|
624
|
+
left?.volume === right?.volume &&
|
|
625
|
+
left?.fileId === right?.fileId &&
|
|
626
|
+
left?.kind === right?.kind
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function listNativeDirectory(context, directory) {
|
|
631
|
+
const entries = [];
|
|
632
|
+
let cursor = null;
|
|
633
|
+
do {
|
|
634
|
+
const page = await context.session.list(directory.cap, { cursor, limit: 256 });
|
|
635
|
+
entries.push(...page.entries);
|
|
636
|
+
cursor = page.cursor;
|
|
637
|
+
} while (cursor !== null);
|
|
638
|
+
return entries;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function assertPrivateLedgerTemp(context, entry) {
|
|
642
|
+
const observation = entry.observation;
|
|
643
|
+
if (
|
|
644
|
+
observation.identity.kind !== "file" ||
|
|
645
|
+
observation.linkCount !== "1" ||
|
|
646
|
+
(observation.identity.platform !== "win32" &&
|
|
647
|
+
observation.owner !== context.root.observation.owner) ||
|
|
648
|
+
(observation.identity.platform !== "win32" && (Number(observation.permissions) & 0o777) !== 0o600)
|
|
649
|
+
) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`unsafe ledger transaction temporary file ${JSON.stringify(`.stdd/${entry.name}`)} — ` +
|
|
652
|
+
"expected a regular owner-only (0600) file owned by the current user; remove it manually",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function closeNativeCapabilitiesBestEffort(context, capabilities) {
|
|
658
|
+
for (const cap of [...capabilities].reverse()) {
|
|
659
|
+
await context.session.closeCapability(cap).catch(() => {});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function quarantineLedgerTemp(context, stdd, source, capabilities, beforeCommit) {
|
|
664
|
+
const match = source.name.match(/^\.(ledger-reset|ledger-prepared)-([0-9a-f]{32})\.tmp$/);
|
|
665
|
+
if (!match) throw new Error(`unrecognized ledger transaction temporary ${source.name}`);
|
|
666
|
+
const [, sourcePhase, token] = match;
|
|
667
|
+
const root = await openOrCreateNativeRepoDirectory(context, ".stdd/ledger-quarantines", {
|
|
668
|
+
mode: 0o700,
|
|
669
|
+
label: "retained ledger quarantine root",
|
|
670
|
+
beforeCommit,
|
|
671
|
+
});
|
|
672
|
+
capabilities.add(root.cap);
|
|
673
|
+
if (
|
|
674
|
+
(root.observation.identity.platform !== "win32" &&
|
|
675
|
+
root.observation.owner !== context.root.observation.owner) ||
|
|
676
|
+
(root.observation.identity.platform !== "win32" &&
|
|
677
|
+
(Number(root.observation.permissions) & 0o777) !== 0o700)
|
|
678
|
+
) {
|
|
679
|
+
throw new Error("retained ledger quarantine root must be owner-private mode 0700");
|
|
680
|
+
}
|
|
681
|
+
// The source token makes even a crash immediately after mkdir attributable
|
|
682
|
+
// without scanning outside the one recognized quarantine root.
|
|
683
|
+
const containerName = `.ledger-recovered-${token}.tmp`;
|
|
684
|
+
let container;
|
|
685
|
+
try {
|
|
686
|
+
await beforeCommit("quarantine-container");
|
|
687
|
+
container = await context.session.createDirectory(root.cap, containerName, 0o700);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (error?.code !== "identity-conflict") throw error;
|
|
690
|
+
container = await context.session.openChild(root.cap, containerName);
|
|
691
|
+
}
|
|
692
|
+
capabilities.add(container.cap);
|
|
693
|
+
if (
|
|
694
|
+
container.observation.identity.kind !== "directory" ||
|
|
695
|
+
(container.observation.identity.platform !== "win32" &&
|
|
696
|
+
container.observation.owner !== context.root.observation.owner) ||
|
|
697
|
+
(container.observation.identity.platform !== "win32" &&
|
|
698
|
+
(Number(container.observation.permissions) & 0o777) !== 0o700)
|
|
699
|
+
) {
|
|
700
|
+
throw new Error(`retained ledger quarantine ${containerName} is not an owner-private directory`);
|
|
701
|
+
}
|
|
702
|
+
const retained = `.stdd/ledger-quarantines/${containerName}/payload`;
|
|
703
|
+
const provenance = Buffer.from(
|
|
704
|
+
`${JSON.stringify({
|
|
705
|
+
schema: 1,
|
|
706
|
+
kind: "ledger-transaction-temp",
|
|
707
|
+
phase: "recovered",
|
|
708
|
+
sourcePhase,
|
|
709
|
+
original: `.stdd/${source.name}`,
|
|
710
|
+
retained,
|
|
711
|
+
identity: source.observation.identity,
|
|
712
|
+
size: source.observation.size,
|
|
713
|
+
})}\n`,
|
|
714
|
+
);
|
|
715
|
+
let inventory = null;
|
|
716
|
+
try {
|
|
717
|
+
inventory = await context.session.openChild(container.cap, "inventory.json");
|
|
718
|
+
capabilities.add(inventory.cap);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error?.code !== "not-found") throw error;
|
|
721
|
+
}
|
|
722
|
+
if (!inventory) {
|
|
723
|
+
const stagedName = `.inventory-${token}.tmp`;
|
|
724
|
+
let staged;
|
|
725
|
+
try {
|
|
726
|
+
staged = await context.session.openChild(container.cap, stagedName);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
if (error?.code !== "not-found") throw error;
|
|
729
|
+
await beforeCommit("quarantine-inventory-create");
|
|
730
|
+
staged = await context.session.createFile(container.cap, stagedName, 0o600);
|
|
731
|
+
}
|
|
732
|
+
capabilities.add(staged.cap);
|
|
733
|
+
if (
|
|
734
|
+
staged.observation.identity.kind !== "file" ||
|
|
735
|
+
(staged.observation.identity.platform !== "win32" &&
|
|
736
|
+
staged.observation.owner !== context.root.observation.owner) ||
|
|
737
|
+
staged.observation.linkCount !== "1" ||
|
|
738
|
+
(staged.observation.identity.platform !== "win32" &&
|
|
739
|
+
(Number(staged.observation.permissions) & 0o777) !== 0o600)
|
|
740
|
+
) {
|
|
741
|
+
throw new Error(`retained ledger quarantine ${containerName} has an unsafe inventory temporary`);
|
|
742
|
+
}
|
|
743
|
+
await writeNativeFileContent(context, staged, provenance);
|
|
744
|
+
await beforeCommit("quarantine-inventory-publish");
|
|
745
|
+
await context.session.rename({
|
|
746
|
+
fromParent: container.cap,
|
|
747
|
+
from: stagedName,
|
|
748
|
+
expected: staged.observation.identity,
|
|
749
|
+
toParent: container.cap,
|
|
750
|
+
to: "inventory.json",
|
|
751
|
+
replace: "never",
|
|
752
|
+
});
|
|
753
|
+
await context.session.flush(container.cap, "namespace", container.observation.identity);
|
|
754
|
+
await context.session.flush(root.cap, "namespace", root.observation.identity);
|
|
755
|
+
inventory = await context.session.openChild(container.cap, "inventory.json");
|
|
756
|
+
capabilities.add(inventory.cap);
|
|
757
|
+
}
|
|
758
|
+
const existing = await readNativeFile(context, inventory);
|
|
759
|
+
if (!existing.equals(provenance)) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`retained ledger quarantine ${containerName} has provenance that does not match its active transaction`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
await beforeCommit("quarantine-payload");
|
|
765
|
+
await context.session.rename({
|
|
766
|
+
fromParent: stdd.cap,
|
|
767
|
+
from: source.name,
|
|
768
|
+
expected: source.observation.identity,
|
|
769
|
+
toParent: container.cap,
|
|
770
|
+
to: "payload",
|
|
771
|
+
replace: "never",
|
|
772
|
+
});
|
|
773
|
+
for (const directory of [stdd, container, root]) {
|
|
774
|
+
await context.session.flush(directory.cap, "namespace", directory.observation.identity);
|
|
775
|
+
}
|
|
776
|
+
await verifyNativeRepoDirectory(context, ".stdd", stdd.observation.identity, "ledger directory");
|
|
777
|
+
await verifyNativeRepoDirectory(
|
|
778
|
+
context,
|
|
779
|
+
`.stdd/ledger-quarantines/${containerName}`,
|
|
780
|
+
container.observation.identity,
|
|
781
|
+
"retained ledger quarantine",
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** One capability session performs recovery and at most one atomic ledger publication. */
|
|
786
|
+
export async function mutateLedgerWithNativeSession(context, records, { beforeCommit = () => {} } = {}) {
|
|
787
|
+
const capabilities = new Set();
|
|
788
|
+
try {
|
|
789
|
+
const stdd = await openOrCreateNativeRepoDirectory(context, ".stdd", {
|
|
790
|
+
mode: 0o755,
|
|
791
|
+
label: "ledger directory",
|
|
792
|
+
beforeCommit,
|
|
793
|
+
});
|
|
794
|
+
capabilities.add(stdd.cap);
|
|
795
|
+
const entries = await listNativeDirectory(context, stdd);
|
|
796
|
+
const active = entries
|
|
797
|
+
.filter((entry) => LEDGER_ACTIVE_TEMP_BASENAME.test(entry.name))
|
|
798
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
799
|
+
for (const entry of active) assertPrivateLedgerTemp(context, entry);
|
|
800
|
+
const original = await readOptionalNativeRepoFile(context, ".stdd/ledger.jsonl", {
|
|
801
|
+
label: "ledger",
|
|
802
|
+
maximum: MAX_LEDGER_BYTES,
|
|
803
|
+
});
|
|
804
|
+
if (original) {
|
|
805
|
+
capabilities.add(original.parent.cap);
|
|
806
|
+
capabilities.add(original.file.cap);
|
|
807
|
+
}
|
|
808
|
+
for (const entry of active) {
|
|
809
|
+
await quarantineLedgerTemp(context, stdd, entry, capabilities, beforeCommit);
|
|
810
|
+
}
|
|
811
|
+
if (records.length === 0) return;
|
|
812
|
+
|
|
813
|
+
const originalBytes = original?.bytes ?? Buffer.alloc(0);
|
|
814
|
+
const separator =
|
|
815
|
+
originalBytes.length > 0 && originalBytes.at(-1) !== LF_BYTE ? Buffer.from("\n") : Buffer.alloc(0);
|
|
816
|
+
const content = Buffer.concat([
|
|
817
|
+
originalBytes,
|
|
818
|
+
separator,
|
|
819
|
+
...records.map((record) => Buffer.from(`${record}\n`)),
|
|
820
|
+
]);
|
|
821
|
+
if (content.length > MAX_LEDGER_BYTES) {
|
|
822
|
+
throw new Error("ledger transaction exceeds the maximum supported size");
|
|
823
|
+
}
|
|
824
|
+
const token = randomBytes(16).toString("hex");
|
|
825
|
+
const resetName = `.ledger-reset-${token}.tmp`;
|
|
826
|
+
const preparedName = `.ledger-prepared-${token}.tmp`;
|
|
827
|
+
await beforeCommit("prepare");
|
|
828
|
+
const temporary = await context.session.createFile(stdd.cap, resetName, 0o600);
|
|
829
|
+
capabilities.add(temporary.cap);
|
|
830
|
+
await writeNativeFileContent(context, temporary, content);
|
|
831
|
+
await beforeCommit("pre-rename");
|
|
832
|
+
await context.session.rename({
|
|
833
|
+
fromParent: stdd.cap,
|
|
834
|
+
from: resetName,
|
|
835
|
+
expected: temporary.observation.identity,
|
|
836
|
+
toParent: stdd.cap,
|
|
837
|
+
to: preparedName,
|
|
838
|
+
replace: "never",
|
|
839
|
+
});
|
|
840
|
+
await context.session.flush(stdd.cap, "namespace", stdd.observation.identity);
|
|
841
|
+
await verifyNativeRepoDirectory(context, ".stdd", stdd.observation.identity, "ledger directory");
|
|
842
|
+
const prepared = await context.session.openChild(stdd.cap, preparedName);
|
|
843
|
+
capabilities.add(prepared.cap);
|
|
844
|
+
if (!sameNativeIdentity(prepared.observation.identity, temporary.observation.identity)) {
|
|
845
|
+
throw new Error("ledger transaction temporary file was replaced before commit");
|
|
846
|
+
}
|
|
847
|
+
const current = await readOptionalNativeRepoFile(context, ".stdd/ledger.jsonl", {
|
|
848
|
+
label: "ledger",
|
|
849
|
+
maximum: MAX_LEDGER_BYTES,
|
|
850
|
+
});
|
|
851
|
+
if (current) {
|
|
852
|
+
capabilities.add(current.parent.cap);
|
|
853
|
+
capabilities.add(current.file.cap);
|
|
854
|
+
}
|
|
855
|
+
if (
|
|
856
|
+
(original === null) !== (current === null) ||
|
|
857
|
+
(original &&
|
|
858
|
+
(!current ||
|
|
859
|
+
!sameNativeIdentity(original.file.observation.identity, current.file.observation.identity) ||
|
|
860
|
+
!original.bytes.equals(current.bytes)))
|
|
861
|
+
) {
|
|
862
|
+
throw new Error(
|
|
863
|
+
"ledger changed after its transaction snapshot; the prepared transaction was not committed",
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
await beforeCommit("commit");
|
|
867
|
+
try {
|
|
868
|
+
await context.session.rename({
|
|
869
|
+
fromParent: stdd.cap,
|
|
870
|
+
from: preparedName,
|
|
871
|
+
expected: prepared.observation.identity,
|
|
872
|
+
toParent: stdd.cap,
|
|
873
|
+
to: "ledger.jsonl",
|
|
874
|
+
replace: current ? "expected" : "never",
|
|
875
|
+
...(current ? { expectedTarget: current.file.observation.identity } : {}),
|
|
876
|
+
});
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (error?.mutation !== "possible" && error?.mutation !== "committed") throw error;
|
|
879
|
+
let published;
|
|
880
|
+
try {
|
|
881
|
+
published = await context.session.openChild(stdd.cap, "ledger.jsonl");
|
|
882
|
+
capabilities.add(published.cap);
|
|
883
|
+
} catch {
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
if (!sameNativeIdentity(published.observation.identity, prepared.observation.identity)) {
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
await context.session.flush(stdd.cap, "namespace", stdd.observation.identity);
|
|
891
|
+
await verifyNativeRepoDirectory(context, ".stdd", stdd.observation.identity, "ledger directory");
|
|
892
|
+
const published = await context.session.openChild(stdd.cap, "ledger.jsonl");
|
|
893
|
+
capabilities.add(published.cap);
|
|
894
|
+
if (!sameNativeIdentity(published.observation.identity, prepared.observation.identity)) {
|
|
895
|
+
const error = new Error("ledger transaction temporary file was replaced at commit");
|
|
896
|
+
error.mutation = "committed";
|
|
897
|
+
throw error;
|
|
898
|
+
}
|
|
899
|
+
} finally {
|
|
900
|
+
await closeNativeCapabilitiesBestEffort(context, capabilities);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const NATIVE_LEDGER_PROGRAM = `
|
|
905
|
+
import fs from "node:fs";
|
|
906
|
+
import {
|
|
907
|
+
currentBranch,
|
|
908
|
+
mutateLedgerWithNativeSession,
|
|
909
|
+
} from ${JSON.stringify(LEDGER_MODULE)};
|
|
910
|
+
import { openNativeRepoMutation } from ${JSON.stringify(new URL("./held-fs.mjs", import.meta.url).href)};
|
|
911
|
+
|
|
912
|
+
const request = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
913
|
+
let context;
|
|
914
|
+
try {
|
|
915
|
+
context = await openNativeRepoMutation(request.cwd, "ledger native filesystem helper");
|
|
916
|
+
const beforeCommit = !request.checkBranch || request.expectedBranch === null ? () => {} : () => {
|
|
917
|
+
if (currentBranch(request.cwd) !== request.expectedBranch) {
|
|
918
|
+
throw new Error("the checkout switched branches before the ledger transaction was recorded — nothing recorded; retry on " + request.expectedBranch);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
await mutateLedgerWithNativeSession(context, request.records, { beforeCommit });
|
|
922
|
+
} catch (error) {
|
|
923
|
+
globalThis.process.stderr.write(JSON.stringify({
|
|
924
|
+
message: error?.message ?? String(error),
|
|
925
|
+
code: error?.code ?? null,
|
|
926
|
+
operation: error?.operation ?? null,
|
|
927
|
+
mutation: error?.mutation ?? null,
|
|
928
|
+
}));
|
|
929
|
+
globalThis.process.exitCode = 1;
|
|
930
|
+
} finally {
|
|
931
|
+
if (context) await context.close().catch(() => {});
|
|
932
|
+
}
|
|
933
|
+
`;
|
|
934
|
+
|
|
935
|
+
function runNativeLedgerMutation(cwd, records, expectedBranch, checkBranch) {
|
|
936
|
+
try {
|
|
937
|
+
execFileSync(process.execPath, ["--input-type=module", "--eval", NATIVE_LEDGER_PROGRAM], {
|
|
938
|
+
input: JSON.stringify({ cwd, records, expectedBranch, checkBranch }),
|
|
939
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
940
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
941
|
+
});
|
|
942
|
+
} catch (error) {
|
|
943
|
+
const stderr = error.stderr?.toString("utf8").trim();
|
|
944
|
+
if (stderr) {
|
|
945
|
+
try {
|
|
946
|
+
const native = JSON.parse(stderr);
|
|
947
|
+
const metadata = [
|
|
948
|
+
native.code ? `code=${native.code}` : null,
|
|
949
|
+
native.operation ? `operation=${native.operation}` : null,
|
|
950
|
+
native.mutation ? `mutation=${native.mutation}` : null,
|
|
951
|
+
].filter(Boolean);
|
|
952
|
+
throw new Error(`${native.message}${metadata.length ? ` (${metadata.join(", ")})` : ""}`, {
|
|
953
|
+
cause: error,
|
|
954
|
+
});
|
|
955
|
+
} catch (parsedError) {
|
|
956
|
+
if (parsedError.cause === error) throw parsedError;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const outcome = error.signal
|
|
960
|
+
? `native ledger helper terminated by ${error.signal}; commit outcome is unknown — inspect ${LEDGER_REL} before retrying`
|
|
961
|
+
: "native ledger helper failed without a structured diagnostic; commit outcome is unknown";
|
|
962
|
+
throw new Error(outcome, { cause: error });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
let activeLedgerMutation = null;
|
|
967
|
+
|
|
968
|
+
export function withLedgerLock(cwd, action) {
|
|
969
|
+
const lockPath = ledgerLockPath(cwd);
|
|
970
|
+
const token = randomBytes(16).toString("hex");
|
|
971
|
+
const ownerPath = `${lockPath}.${process.pid}.${token}.owner`;
|
|
972
|
+
const deadline = Date.now() + 10_000;
|
|
973
|
+
let ownerFd;
|
|
974
|
+
let ownerStat;
|
|
975
|
+
let lockStat;
|
|
976
|
+
try {
|
|
977
|
+
ownerFd = fs.openSync(ownerPath, "wx", 0o600);
|
|
978
|
+
ownerStat = fs.fstatSync(ownerFd);
|
|
979
|
+
fs.writeFileSync(
|
|
980
|
+
ownerFd,
|
|
981
|
+
JSON.stringify({
|
|
982
|
+
pid: process.pid,
|
|
983
|
+
token,
|
|
984
|
+
createdAt: new Date().toISOString(),
|
|
985
|
+
}),
|
|
986
|
+
);
|
|
987
|
+
fs.fsyncSync(ownerFd);
|
|
988
|
+
fs.closeSync(ownerFd);
|
|
989
|
+
ownerFd = null;
|
|
990
|
+
} catch (err) {
|
|
991
|
+
if (ownerFd !== null && ownerFd !== undefined) {
|
|
992
|
+
try {
|
|
993
|
+
fs.closeSync(ownerFd);
|
|
994
|
+
} catch {
|
|
995
|
+
// The unique owner path is removed below.
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (ownerStat && !retirePortableTempMetadata(ownerPath, ownerStat)) {
|
|
999
|
+
throw new Error(
|
|
1000
|
+
`${err.message}; additionally, could not retire the private ledger lock owner file at ${ownerPath}`,
|
|
1001
|
+
{ cause: err },
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
1006
|
+
let result;
|
|
1007
|
+
let actionFailure = null;
|
|
1008
|
+
let nativeFailure = null;
|
|
1009
|
+
try {
|
|
1010
|
+
for (;;) {
|
|
1011
|
+
try {
|
|
1012
|
+
// The shared name appears atomically only after complete owner
|
|
1013
|
+
// metadata has been written and flushed to its inode.
|
|
1014
|
+
fs.linkSync(ownerPath, lockPath);
|
|
1015
|
+
lockStat = ownerStat;
|
|
1016
|
+
if (!retirePortableTempMetadata(ownerPath, ownerStat)) {
|
|
1017
|
+
const lockRetired = retirePortableTempMetadata(lockPath, lockStat);
|
|
1018
|
+
if (lockRetired) lockStat = null;
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
lockRetired
|
|
1021
|
+
? "could not retire the private ledger lock owner file"
|
|
1022
|
+
: "could not retire the private ledger lock owner file or release the shared lock",
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
ownerStat = null;
|
|
1026
|
+
break;
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
if (err.code !== "EEXIST") throw err;
|
|
1029
|
+
if (recoverAbandonedLedgerLock(lockPath)) continue;
|
|
1030
|
+
if (Date.now() >= deadline) {
|
|
1031
|
+
throw new Error("the ledger is busy in another stdd process; retry the command");
|
|
1032
|
+
}
|
|
1033
|
+
Atomics.wait(LEDGER_LOCK_WAIT, 0, 0, 20);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (activeLedgerMutation !== null) throw new Error("nested ledger mutation is unsupported");
|
|
1037
|
+
activeLedgerMutation = {
|
|
1038
|
+
cwd,
|
|
1039
|
+
records: [],
|
|
1040
|
+
expectedBranch: null,
|
|
1041
|
+
checkBranch: true,
|
|
1042
|
+
committed: false,
|
|
1043
|
+
};
|
|
1044
|
+
try {
|
|
1045
|
+
result = action();
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
actionFailure = err;
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
if (!activeLedgerMutation.committed) {
|
|
1051
|
+
const records = actionFailure === null ? activeLedgerMutation.records : [];
|
|
1052
|
+
const expectedBranch = actionFailure === null ? activeLedgerMutation.expectedBranch : null;
|
|
1053
|
+
const checkBranch = actionFailure === null ? activeLedgerMutation.checkBranch : false;
|
|
1054
|
+
runNativeLedgerMutation(cwd, records, expectedBranch, checkBranch);
|
|
1055
|
+
}
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
nativeFailure = err;
|
|
1058
|
+
} finally {
|
|
1059
|
+
activeLedgerMutation = null;
|
|
1060
|
+
}
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
if (actionFailure === null) actionFailure = err;
|
|
1063
|
+
}
|
|
1064
|
+
if (nativeFailure && actionFailure === null) actionFailure = nativeFailure;
|
|
1065
|
+
else if (nativeFailure && actionFailure) {
|
|
1066
|
+
actionFailure = new Error(`${actionFailure.message}; additionally, ${nativeFailure.message}`, {
|
|
1067
|
+
cause: actionFailure,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
const releaseFailures = [];
|
|
1071
|
+
if (lockStat && !retirePortableTempMetadata(lockPath, lockStat)) {
|
|
1072
|
+
releaseFailures.push("shared lock");
|
|
1073
|
+
}
|
|
1074
|
+
if (ownerStat && !retirePortableTempMetadata(ownerPath, ownerStat)) {
|
|
1075
|
+
releaseFailures.push("owner file");
|
|
1076
|
+
}
|
|
1077
|
+
if (releaseFailures.length > 0) {
|
|
1078
|
+
const releaseMessage = `could not release the ledger ${releaseFailures.join(
|
|
1079
|
+
" and ",
|
|
1080
|
+
)} safely; inspect ${lockPath}`;
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
actionFailure ? `${actionFailure.message}; additionally, ${releaseMessage}` : releaseMessage,
|
|
1083
|
+
actionFailure ? { cause: actionFailure } : undefined,
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
if (actionFailure) throw actionFailure;
|
|
1087
|
+
return result;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export function sameTaskBoundary(expected, current) {
|
|
1091
|
+
if (expected.state !== current.state) return false;
|
|
1092
|
+
if (expected.state === "legacy" || expected.state === "idle") return true;
|
|
1093
|
+
return expected.state === "active" && current.task.id === expected.task.id;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export function withCapturedLedgerIdentity(
|
|
1097
|
+
cwd,
|
|
1098
|
+
{ expectedBranch, expectedTaskState, subject, retry = "stdd review" },
|
|
1099
|
+
action,
|
|
1100
|
+
) {
|
|
1101
|
+
return withLedgerLock(cwd, () => {
|
|
1102
|
+
const branch = currentBranch(cwd);
|
|
1103
|
+
if (branch !== expectedBranch) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
`the checkout switched branches before the ${subject} was recorded — nothing recorded; ` +
|
|
1106
|
+
`rerun \`${retry}\` on ${expectedBranch}`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
const currentTaskState = deriveTaskState(rawLedger(cwd, expectedBranch));
|
|
1110
|
+
if (!sameTaskBoundary(expectedTaskState, currentTaskState)) {
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`the active task changed before the ${subject} was recorded — nothing recorded; ` +
|
|
1113
|
+
`rerun \`${retry}\` for the current task`,
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return action();
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function jsonlRecordSeparator(byteLength, finalByte) {
|
|
1121
|
+
return byteLength > 0 && finalByte !== LF_BYTE ? "\n" : "";
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
export function ledgerFileRecordSeparator(ledgerPath) {
|
|
1125
|
+
let fd;
|
|
1126
|
+
try {
|
|
1127
|
+
fd = fs.openSync(ledgerPath, "r");
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
if (err.code === "ENOENT") return "";
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
try {
|
|
1133
|
+
const size = fs.fstatSync(fd).size;
|
|
1134
|
+
if (size === 0) return "";
|
|
1135
|
+
const finalByte = Buffer.allocUnsafe(1);
|
|
1136
|
+
if (fs.readSync(fd, finalByte, 0, 1, size - 1) !== 1) {
|
|
1137
|
+
throw new Error("could not read the final ledger byte before appending");
|
|
1138
|
+
}
|
|
1139
|
+
return jsonlRecordSeparator(size, finalByte[0]);
|
|
1140
|
+
} finally {
|
|
1141
|
+
fs.closeSync(fd);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export function appendLedger(
|
|
1146
|
+
cwd,
|
|
1147
|
+
event,
|
|
1148
|
+
{
|
|
1149
|
+
allowHistoricalTask = false,
|
|
1150
|
+
allowBranchScopedHistorical = false,
|
|
1151
|
+
preserveTaskScope = false,
|
|
1152
|
+
lockHeld = false,
|
|
1153
|
+
expectedBranch = null,
|
|
1154
|
+
} = {},
|
|
1155
|
+
) {
|
|
1156
|
+
const write = () => {
|
|
1157
|
+
const { branch, taskState, task } = inspectLedgerAppendContext(cwd, event, {
|
|
1158
|
+
allowHistoricalTask,
|
|
1159
|
+
allowBranchScopedHistorical,
|
|
1160
|
+
});
|
|
1161
|
+
if (expectedBranch !== null && branch !== expectedBranch) {
|
|
1162
|
+
throw new Error(
|
|
1163
|
+
`the checkout switched branches before ${event.event} was recorded — nothing recorded; ` +
|
|
1164
|
+
`retry on ${expectedBranch}`,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
const recordedBranch = expectedBranch ?? branch;
|
|
1168
|
+
if (taskState.state === "legacy" && event.event !== "task-start") {
|
|
1169
|
+
fs.writeSync(
|
|
1170
|
+
process.stderr.fd,
|
|
1171
|
+
'stdd: no active task; recording branch-scoped legacy evidence — run `stdd task start "<short name>"`\n',
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
const record = JSON.stringify({
|
|
1175
|
+
ts: new Date().toISOString(),
|
|
1176
|
+
...(task && !event.taskId && !preserveTaskScope ? { taskId: task.id } : {}),
|
|
1177
|
+
...event,
|
|
1178
|
+
...(taskState.state === "legacy" && LEGACY_RECORDER_EVENTS.has(event.event)
|
|
1179
|
+
? { legacyFlow: LEGACY_FLOW_MARKER }
|
|
1180
|
+
: {}),
|
|
1181
|
+
branch: recordedBranch,
|
|
1182
|
+
});
|
|
1183
|
+
if (activeLedgerMutation === null || activeLedgerMutation.cwd !== cwd) {
|
|
1184
|
+
throw new Error("ledger append escaped its native mutation session");
|
|
1185
|
+
}
|
|
1186
|
+
if (activeLedgerMutation.committed) {
|
|
1187
|
+
throw new Error("ledger append cannot run after the native mutation committed");
|
|
1188
|
+
}
|
|
1189
|
+
if (
|
|
1190
|
+
activeLedgerMutation.expectedBranch !== null &&
|
|
1191
|
+
activeLedgerMutation.expectedBranch !== recordedBranch
|
|
1192
|
+
) {
|
|
1193
|
+
throw new Error("one ledger mutation cannot publish records for multiple branches");
|
|
1194
|
+
}
|
|
1195
|
+
activeLedgerMutation.expectedBranch = recordedBranch;
|
|
1196
|
+
activeLedgerMutation.records.push(record);
|
|
1197
|
+
};
|
|
1198
|
+
try {
|
|
1199
|
+
if (lockHeld) write();
|
|
1200
|
+
else withLedgerLock(cwd, write);
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
if (lockHeld) throw err;
|
|
1203
|
+
fail(err.message);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function enqueueLedgerTransaction(cwd, events, expectedBranch, subject, checkBranch = true) {
|
|
1208
|
+
if (activeLedgerMutation === null || activeLedgerMutation.cwd !== cwd) {
|
|
1209
|
+
throw new Error(`${subject} escaped its native mutation session`);
|
|
1210
|
+
}
|
|
1211
|
+
if (activeLedgerMutation.committed) {
|
|
1212
|
+
throw new Error(`${subject} cannot append after the ledger mutation committed`);
|
|
1213
|
+
}
|
|
1214
|
+
if (
|
|
1215
|
+
activeLedgerMutation.expectedBranch !== null &&
|
|
1216
|
+
activeLedgerMutation.expectedBranch !== expectedBranch
|
|
1217
|
+
) {
|
|
1218
|
+
throw new Error("one ledger mutation cannot publish records for multiple branches");
|
|
1219
|
+
}
|
|
1220
|
+
activeLedgerMutation.expectedBranch = expectedBranch;
|
|
1221
|
+
activeLedgerMutation.checkBranch = activeLedgerMutation.checkBranch && checkBranch;
|
|
1222
|
+
for (const event of events) {
|
|
1223
|
+
activeLedgerMutation.records.push(
|
|
1224
|
+
JSON.stringify({ ts: new Date().toISOString(), ...event, branch: expectedBranch }),
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function appendLedgerTransaction(cwd, events, expectedBranch) {
|
|
1230
|
+
if (currentBranch(cwd) !== expectedBranch) {
|
|
1231
|
+
throw new Error(
|
|
1232
|
+
`the checkout switched branches before the task reset was recorded — nothing recorded; retry on ${expectedBranch}`,
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
enqueueLedgerTransaction(cwd, events, expectedBranch, "ledger reset");
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Queue one provenance-captured terminal event while the caller holds the ledger lock. */
|
|
1239
|
+
export function appendCapturedLedgerEvent(cwd, event, expectedBranch) {
|
|
1240
|
+
enqueueLedgerTransaction(cwd, [event], expectedBranch, event.event, false);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** Commit the queued records before a later side effect runs under the same lock. */
|
|
1244
|
+
export function commitActiveLedgerMutation(cwd) {
|
|
1245
|
+
if (activeLedgerMutation === null || activeLedgerMutation.cwd !== cwd) {
|
|
1246
|
+
throw new Error("ledger commit escaped its native mutation session");
|
|
1247
|
+
}
|
|
1248
|
+
if (activeLedgerMutation.committed) throw new Error("ledger mutation was already committed");
|
|
1249
|
+
runNativeLedgerMutation(
|
|
1250
|
+
cwd,
|
|
1251
|
+
activeLedgerMutation.records,
|
|
1252
|
+
activeLedgerMutation.expectedBranch,
|
|
1253
|
+
activeLedgerMutation.checkBranch,
|
|
1254
|
+
);
|
|
1255
|
+
activeLedgerMutation.committed = true;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// --- the task lifecycle, the durable plan, and deferred cuts ---
|
|
1259
|
+
|
|
1260
|
+
/** Current task's ledger events. Branch-only history remains a legacy scope. */
|
|
1261
|
+
export function loadLedger(cwd, branch, config = loadConfig(cwd)) {
|
|
1262
|
+
return scopeLedgerForCheckout(cwd, branch, rawLedger(cwd, branch), config).events;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function planHash(cwd) {
|
|
1266
|
+
const planPath = statePath(cwd, PLAN_REL, "plan path");
|
|
1267
|
+
return fs.existsSync(planPath) ? sha256(fs.readFileSync(planPath)) : null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
export function taskPlanContent(cwd, taskState, planPath = statePath(cwd, PLAN_REL, "plan path")) {
|
|
1271
|
+
if (!fs.existsSync(planPath) || taskState.state === "idle" || taskState.state === "invalid") {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
const content = fs.readFileSync(planPath, "utf8");
|
|
1275
|
+
if (
|
|
1276
|
+
taskState.state === "active" &&
|
|
1277
|
+
taskState.task.planBaseline !== null &&
|
|
1278
|
+
taskState.task.planBaseline === sha256(content)
|
|
1279
|
+
) {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
return content;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Return the plan that belongs to the current task. A plan left unchanged
|
|
1287
|
+
* across `task start` is historical context, not the new task's contract.
|
|
1288
|
+
* Branch-only ledgers retain their legacy behavior.
|
|
1289
|
+
*/
|
|
1290
|
+
export function currentTaskPlan(cwd) {
|
|
1291
|
+
const planPath = statePath(cwd, PLAN_REL, "plan path");
|
|
1292
|
+
if (!fs.existsSync(planPath)) return null;
|
|
1293
|
+
const branch = currentBranch(cwd);
|
|
1294
|
+
if (!branch) return fs.readFileSync(planPath, "utf8");
|
|
1295
|
+
const taskState = scopeLedgerForCheckout(cwd, branch).state;
|
|
1296
|
+
return taskPlanContent(cwd, taskState, planPath);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function normalizeTaskName(name) {
|
|
1300
|
+
try {
|
|
1301
|
+
return assertPrintableSingleLine(name, "task name").trim();
|
|
1302
|
+
} catch {
|
|
1303
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
1304
|
+
fail('task start needs a short name, e.g. `stdd task start "add invoices"`');
|
|
1305
|
+
}
|
|
1306
|
+
fail("task name must be a non-empty single printable line without control characters");
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function taskTransition(cwd, action) {
|
|
1311
|
+
try {
|
|
1312
|
+
return withLedgerLock(cwd, () => {
|
|
1313
|
+
const branch = currentBranch(cwd);
|
|
1314
|
+
if (!branch) {
|
|
1315
|
+
throw new Error("the ledger needs a git repository with at least one commit");
|
|
1316
|
+
}
|
|
1317
|
+
return action(branch);
|
|
1318
|
+
});
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
// This synchronous API may have just waited on the native subprocess;
|
|
1321
|
+
// write the terminal diagnostic synchronously before exiting as well.
|
|
1322
|
+
fs.writeSync(process.stderr.fd, `stdd: ${err.message}\n`);
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export function startTask(cwd, name) {
|
|
1328
|
+
const taskName = normalizeTaskName(name);
|
|
1329
|
+
const started = taskTransition(cwd, (branch) => {
|
|
1330
|
+
const taskState = taskLifecycleState(rawLedger(cwd, branch));
|
|
1331
|
+
if (taskState.state === "active") {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`task ${taskState.task.id} (${taskState.task.name}) is already active — ` +
|
|
1334
|
+
"finish it, reset it, or continue it",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
const id = `task-${randomBytes(6).toString("hex")}`;
|
|
1338
|
+
appendLedger(
|
|
1339
|
+
cwd,
|
|
1340
|
+
{
|
|
1341
|
+
event: "task-start",
|
|
1342
|
+
id,
|
|
1343
|
+
name: taskName,
|
|
1344
|
+
planBaseline: planHash(cwd),
|
|
1345
|
+
},
|
|
1346
|
+
{ lockHeld: true, expectedBranch: branch },
|
|
1347
|
+
);
|
|
1348
|
+
return { branch, id };
|
|
1349
|
+
});
|
|
1350
|
+
fs.writeSync(
|
|
1351
|
+
process.stdout.fd,
|
|
1352
|
+
`stdd task: started ${started.id} (${taskName}) on ${started.branch}\n`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export function finishTask(cwd) {
|
|
1357
|
+
const active = taskTransition(cwd, (branch) => {
|
|
1358
|
+
const taskState = taskLifecycleState(rawLedger(cwd, branch));
|
|
1359
|
+
if (taskState.state !== "active") {
|
|
1360
|
+
throw new Error('no active task — run `stdd task start "<name>"`');
|
|
1361
|
+
}
|
|
1362
|
+
const task = taskState.task;
|
|
1363
|
+
appendLedger(
|
|
1364
|
+
cwd,
|
|
1365
|
+
{ event: "task-finish", taskId: task.id },
|
|
1366
|
+
{ lockHeld: true, expectedBranch: branch },
|
|
1367
|
+
);
|
|
1368
|
+
return task;
|
|
1369
|
+
});
|
|
1370
|
+
fs.writeSync(
|
|
1371
|
+
process.stdout.fd,
|
|
1372
|
+
`stdd task: finished ${active.id} (${active.name}); evidence remains in the ledger\n`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
export function resetTask(cwd, name = null) {
|
|
1377
|
+
const requestedName = name === null ? null : normalizeTaskName(name);
|
|
1378
|
+
const reset = taskTransition(cwd, (branch) => {
|
|
1379
|
+
const taskState = taskLifecycleState(rawLedger(cwd, branch));
|
|
1380
|
+
if (taskState.state !== "active") {
|
|
1381
|
+
throw new Error('no active task — run `stdd task start "<name>"`');
|
|
1382
|
+
}
|
|
1383
|
+
const active = taskState.task;
|
|
1384
|
+
const nextName = requestedName ?? active.name;
|
|
1385
|
+
const id = `task-${randomBytes(6).toString("hex")}`;
|
|
1386
|
+
const planBaseline = planHash(cwd);
|
|
1387
|
+
appendLedgerTransaction(
|
|
1388
|
+
cwd,
|
|
1389
|
+
[
|
|
1390
|
+
{ event: "task-reset", taskId: active.id },
|
|
1391
|
+
{
|
|
1392
|
+
event: "task-start",
|
|
1393
|
+
id,
|
|
1394
|
+
name: nextName,
|
|
1395
|
+
planBaseline,
|
|
1396
|
+
},
|
|
1397
|
+
],
|
|
1398
|
+
branch,
|
|
1399
|
+
);
|
|
1400
|
+
return { branch, id, nextName };
|
|
1401
|
+
});
|
|
1402
|
+
fs.writeSync(
|
|
1403
|
+
process.stdout.fd,
|
|
1404
|
+
`stdd task: reset to ${reset.id} (${reset.nextName}) on ${reset.branch}\n`,
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* `stdd defer <text>` — record a scope cut under the plan's `## Deferred`
|
|
1410
|
+
* section (see method: "The durable plan and stdd defer"). Creates the
|
|
1411
|
+
* plan file and the section as needed; never mutates git or appends to the ledger.
|
|
1412
|
+
*/
|
|
1413
|
+
export function defer(cwd, text) {
|
|
1414
|
+
try {
|
|
1415
|
+
assertPrintableSingleLine(text, "deferred cut");
|
|
1416
|
+
} catch {
|
|
1417
|
+
fail("deferred cut must be a non-empty single printable line without control characters");
|
|
1418
|
+
}
|
|
1419
|
+
// Resolve the mutation target before consulting lifecycle state. This is
|
|
1420
|
+
// read-only, but ensures an unsafe plan symlink is diagnosed as the target
|
|
1421
|
+
// violation even when the ledger is independently unsafe.
|
|
1422
|
+
const planPath = statePath(cwd, PLAN_REL, "plan path");
|
|
1423
|
+
const deferContext = ledgerAppendContext(cwd, { event: "defer" });
|
|
1424
|
+
if (!deferContext.task) {
|
|
1425
|
+
fail('no active task — run `stdd task start "<short name>"` before recording a scope cut');
|
|
1426
|
+
}
|
|
1427
|
+
const existed = fs.existsSync(planPath);
|
|
1428
|
+
let content = "";
|
|
1429
|
+
if (existed) {
|
|
1430
|
+
const fd = fs.openSync(planPath, "r");
|
|
1431
|
+
try {
|
|
1432
|
+
content = fs.readFileSync(fd, "utf8");
|
|
1433
|
+
} finally {
|
|
1434
|
+
fs.closeSync(fd);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const nextContent = appendDeferred(content, text);
|
|
1438
|
+
try {
|
|
1439
|
+
withCapturedLedgerIdentity(
|
|
1440
|
+
cwd,
|
|
1441
|
+
{
|
|
1442
|
+
expectedBranch: deferContext.branch,
|
|
1443
|
+
expectedTaskState: deferContext.taskState,
|
|
1444
|
+
subject: "deferred plan cut",
|
|
1445
|
+
retry: "stdd defer",
|
|
1446
|
+
},
|
|
1447
|
+
() => {
|
|
1448
|
+
const stillExists = fs.existsSync(planPath);
|
|
1449
|
+
const current = stillExists ? fs.readFileSync(planPath, "utf8") : "";
|
|
1450
|
+
if (stillExists !== existed || current !== content) {
|
|
1451
|
+
throw new Error(
|
|
1452
|
+
"the plan changed before the deferred cut was recorded — nothing recorded; " +
|
|
1453
|
+
"rerun `stdd defer` for the current task",
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
// Recovery/helper preflight must settle before the non-ledger plan
|
|
1457
|
+
// side effect; this lock action intentionally queues no records.
|
|
1458
|
+
commitActiveLedgerMutation(cwd);
|
|
1459
|
+
fs.mkdirSync(path.dirname(planPath), { recursive: true });
|
|
1460
|
+
fs.writeFileSync(planPath, nextContent);
|
|
1461
|
+
},
|
|
1462
|
+
);
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
fail(err.message);
|
|
1465
|
+
}
|
|
1466
|
+
fs.writeSync(
|
|
1467
|
+
process.stdout.fd,
|
|
1468
|
+
`stdd defer: recorded under ${PLAN_REL} — carry it into the PR description's out-of-scope\n`,
|
|
1469
|
+
);
|
|
1470
|
+
}
|