dw-sdlc 0.12.0 → 0.13.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/dist/pr/read.js CHANGED
@@ -64,6 +64,10 @@ function parseOrCannotRun(parse) {
64
64
  throw error;
65
65
  }
66
66
  }
67
+ /** The remedy for a pull request `command`, as `pr ship`, cannot find by itself: name it in full. */
68
+ export function nameItRemedy(command) {
69
+ return `name it: dw-sdlc ${command} <n> -R owner/name`;
70
+ }
67
71
  /** The pull request named in full, or the one `gh pr view` resolves from what is given. */
68
72
  function targetOf({ number, repo }, command) {
69
73
  if (number !== undefined && repo !== undefined) {
@@ -72,8 +76,7 @@ function targetOf({ number, repo }, command) {
72
76
  const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
73
77
  const view = gh(["pr", "view", ...named, "--json", "url"]);
74
78
  if (view.status !== 0) {
75
- const remedy = `name it: dw-sdlc ${command} <n> -R owner/name`;
76
- throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, remedy);
79
+ throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, nameItRemedy(command));
77
80
  }
78
81
  return parseOrCannotRun(() => parseView(view.stdout));
79
82
  }
@@ -93,18 +96,33 @@ export function read(named, command, sized) {
93
96
  requireAuth();
94
97
  return query(targetOf(named, command), sized);
95
98
  }
96
- /** The state of the current branch's pull request, open or not, or null when gh finds none. */
97
- export function readBranch(sized) {
98
- requireAuth();
99
- const view = gh(["pr", "view", "--json", "url"]);
99
+ /** The pull request `gh pr view` resolves for the head branch `branch`, else the current one; null when none. */
100
+ function viewed(branch) {
101
+ const view = gh(["pr", "view", ...(branch === undefined ? [] : [branch]), "--json", "url"]);
100
102
  if (view.status === 0) {
101
- return query(parseOrCannotRun(() => parseView(view.stdout)), sized);
103
+ return parseOrCannotRun(() => parseView(view.stdout));
102
104
  }
103
105
  if (said(view.stderr).startsWith("no pull requests found")) {
104
106
  return null;
105
107
  }
106
108
  throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
107
109
  }
110
+ /**
111
+ * The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
112
+ * misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
113
+ */
114
+ export function readBranch(sized, upstreamName) {
115
+ requireAuth();
116
+ const own = viewed(undefined);
117
+ if (own !== null) {
118
+ return { pr: query(own, sized), byUpstream: false };
119
+ }
120
+ if (upstreamName === undefined) {
121
+ return null;
122
+ }
123
+ const found = viewed(upstreamName);
124
+ return found === null ? null : { pr: query(found, sized), byUpstream: true };
125
+ }
108
126
  /** The default branch of the repository gh resolves from the current directory. */
109
127
  export function defaultBranch() {
110
128
  const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
@@ -170,25 +188,26 @@ function ownChanges(pr, commit) {
170
188
  return parseOrCannotRun(() => changesOf(reply));
171
189
  }
172
190
  /**
173
- * `pr` and whether its head carries the last reviewed commit's status: only merges since it, and the
174
- * same own changes at both. The compares run only when a Review awaits the head.
191
+ * `pr` and whether its head carries the success of the last commit a Review read: a clean read, only
192
+ * merges since it, and the same own changes at both. A read mark never carries. The compares run only
193
+ * when a Review awaits the head.
175
194
  */
176
195
  function withCarried(pr) {
177
- const last = pr.lastReviewed;
178
- if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
196
+ const last = pr.lastRead;
197
+ if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
179
198
  return { ...pr, carried: false };
180
199
  }
181
200
  return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
182
201
  }
183
202
  /**
184
203
  * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
185
- * the compare from the last reviewed commit, else from the base, to the head.
204
+ * the compare from the last commit a Review read, else from the base, to the head.
186
205
  */
187
206
  function withChanged(pr, sized) {
188
207
  if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
189
208
  return { ...pr, changed: null };
190
209
  }
191
- const from = pr.lastReviewed?.commit ?? pr.baseOid;
210
+ const from = pr.lastRead?.commit ?? pr.baseOid;
192
211
  const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
193
212
  return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
194
213
  }
@@ -2,6 +2,14 @@
2
2
  export declare const REVIEW = "dw-sdlc/review";
3
3
  /** How the description of a success the script posts under off begins: no one read that head. */
4
4
  export declare const OFF_PREFIX = "off:";
5
+ /**
6
+ * The read mark: the review status in this state and with this description says a Review read that
7
+ * head and left a Finding open.
8
+ */
9
+ export declare const MARK_STATE = "failure";
10
+ export declare const READ_MARK = "read, findings open";
11
+ /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
+ export declare const ACCEPTED = "accepted by the dev, findings open";
5
13
  export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
6
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
7
15
  export type CiCheck = {
@@ -51,12 +59,17 @@ export type PrState = {
51
59
  unresolvedThreads: Thread[];
52
60
  /** The oids of the pull request's last 100 commits, oldest first, the head last. */
53
61
  commits: string[];
54
- /** The last commit before the head with a review success not posted under off, of the last 100. */
55
- lastReviewed: LastReviewed | null;
56
- /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
62
+ /**
63
+ * The last commit before the head a Review read, of the last 100: a review success not posted under
64
+ * off, or a read mark.
65
+ */
66
+ lastRead: LastRead | null;
67
+ /** The read marks since the last review success of any kind, of the last 100 commits. */
68
+ marks: ReadMarks;
69
+ /** Whether the head carries the success of `lastRead`: the read decides it from two compares. */
57
70
  carried: boolean;
58
71
  /**
59
- * The files a Review would read, from `lastReviewed` or the base to the head, read only for `pr.review:
72
+ * The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
60
73
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
61
74
  */
62
75
  changed: ChangedFile[] | "unsized" | null;
@@ -75,11 +88,24 @@ export type ChangedFile = {
75
88
  previous: string | null;
76
89
  lines: number | null;
77
90
  };
78
- /** A reviewed commit, and whether every commit after it, the head included, is a merge. */
79
- export type LastReviewed = {
91
+ /**
92
+ * A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
93
+ * every commit after it, the head included, is a merge.
94
+ */
95
+ export type LastRead = {
80
96
  commit: string;
97
+ clean: boolean;
81
98
  mergesOnly: boolean;
82
99
  };
100
+ /**
101
+ * The Reviews that left a Finding open since the last review success, a success under off and the dev's
102
+ * acceptance included: `count` their read marks, the head's own among them, and `since` that success's
103
+ * commit, null when none of the last 100 holds one.
104
+ */
105
+ export type ReadMarks = {
106
+ count: number;
107
+ since: string | null;
108
+ };
83
109
  /** A reply that is not the shape the query asks for; the message names the field. */
84
110
  export declare class ReplyError extends Error {
85
111
  }
package/dist/pr/state.js CHANGED
@@ -4,6 +4,14 @@
4
4
  export const REVIEW = "dw-sdlc/review";
5
5
  /** How the description of a success the script posts under off begins: no one read that head. */
6
6
  export const OFF_PREFIX = "off:";
7
+ /**
8
+ * The read mark: the review status in this state and with this description says a Review read that
9
+ * head and left a Finding open.
10
+ */
11
+ export const MARK_STATE = "failure";
12
+ export const READ_MARK = "read, findings open";
13
+ /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
14
+ export const ACCEPTED = "accepted by the dev, findings open";
7
15
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
8
16
  repository(owner: $owner, name: $name) {
9
17
  pullRequest(number: $number) {
@@ -191,26 +199,57 @@ function threadOf(thread) {
191
199
  export function awaitsReview(pr) {
192
200
  return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
193
201
  }
194
- /** A commit whose review success says someone read it: a status posted under off never does. */
195
- function isReviewed(commit) {
202
+ /**
203
+ * What a commit's review status says: `clean` a success, `findings` the read mark, `off` a success
204
+ * posted under off, which says no one read the commit, null no status of the Review.
205
+ */
206
+ function postedOn(commit) {
196
207
  const status = childOrNull(commit, "status");
197
208
  const context = status === null ? null : childOrNull(status, "context");
198
- if (context === null || string(context, "state") !== "SUCCESS") {
199
- return false;
209
+ if (context === null) {
210
+ return null;
211
+ }
212
+ const state = string(context, "state");
213
+ const description = stringOrNull(context, "description") ?? "";
214
+ if (state === "SUCCESS") {
215
+ return description.startsWith(OFF_PREFIX) ? "off" : "clean";
200
216
  }
201
- return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
217
+ return state === MARK_STATE.toUpperCase() && description === READ_MARK ? "findings" : null;
218
+ }
219
+ /** The pull request's last 100 commits, oldest first, each with the review status it carries. */
220
+ function historyOf(pr) {
221
+ return nodes(pr, "history").map((node) => {
222
+ const commit = child(node, "commit");
223
+ return {
224
+ oid: string(commit, "oid"),
225
+ posted: postedOn(commit),
226
+ merge: integer(child(commit, "parents"), "totalCount") > 1
227
+ };
228
+ });
202
229
  }
203
- /** The last commit before `head` that `isReviewed` takes, the history oldest first. */
204
- function lastReviewedOf(pr, head) {
205
- const commits = nodes(pr, "history").map((node) => child(node, "commit"));
206
- const index = commits.findLastIndex((commit) => string(commit, "oid") !== head && isReviewed(commit));
207
- const reviewed = commits[index];
208
- if (reviewed === undefined) {
230
+ /**
231
+ * The last commit before `head` a Review read, the history oldest first. A read mark with only merges
232
+ * after it is passed over: no fix answers its Findings yet, and a Review from it would read only the
233
+ * base's changes, so the Review reads from the read before it and finds them again.
234
+ */
235
+ function lastReadOf(history, head) {
236
+ const commits = history.map(({ oid, posted, merge }) => ({
237
+ oid,
238
+ read: oid === head || posted === "off" ? null : posted,
239
+ merge
240
+ }));
241
+ const mergesOnlyAfter = (index) => commits.slice(index + 1).every(({ merge }) => merge);
242
+ const index = commits.findLastIndex(({ read }, at) => read === "clean" || (read === "findings" && !mergesOnlyAfter(at)));
243
+ const last = commits[index];
244
+ if (last === undefined) {
209
245
  return null;
210
246
  }
211
- const after = commits.slice(index + 1);
212
- const mergesOnly = after.every((commit) => integer(child(commit, "parents"), "totalCount") > 1);
213
- return { commit: string(reviewed, "oid"), mergesOnly };
247
+ return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
248
+ }
249
+ function readMarksOf(history) {
250
+ const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
251
+ const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
252
+ return { count, since: history[success]?.oid ?? null };
214
253
  }
215
254
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
216
255
  export function parseView(reply) {
@@ -233,6 +272,7 @@ export function parsePr(reply, repo) {
233
272
  const mergeCommit = childOrNull(pr, "mergeCommit");
234
273
  const head = string(pr, "headRefOid");
235
274
  const baseRef = childOrNull(pr, "baseRef");
275
+ const history = historyOf(pr);
236
276
  return {
237
277
  url: string(pr, "url"),
238
278
  repo,
@@ -253,7 +293,8 @@ export function parsePr(reply, repo) {
253
293
  unresolvedThreads: nodes(pr, "reviewThreads")
254
294
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
255
295
  .map(threadOf),
256
- commits: nodes(pr, "history").map((node) => string(child(node, "commit"), "oid")),
257
- lastReviewed: lastReviewedOf(pr, head)
296
+ commits: history.map(({ oid }) => oid),
297
+ lastRead: lastReadOf(history, head),
298
+ marks: readMarksOf(history)
258
299
  };
259
300
  }
@@ -0,0 +1,16 @@
1
+ import type { PrState } from "./state.ts";
2
+ /**
3
+ * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
4
+ * after the remote deleted the branch.
5
+ */
6
+ export declare function upstreamName(root: string, branch: string): string | undefined;
7
+ /** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
8
+ export declare function holds(pr: PrState, head: string | null): boolean;
9
+ /** The checked-out branch at `root`, its head commit, and the other name its upstream gives. */
10
+ export type RenamedUpstream = {
11
+ branch: string;
12
+ head: string;
13
+ upstream: string;
14
+ };
15
+ /** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
16
+ export declare function renamedUpstream(root: string): RenamedUpstream | undefined;
@@ -0,0 +1,31 @@
1
+ // A branch's upstream, read from git, for a branch pushed under another name: gh resolves
2
+ // its pull request neither through `@{push}` nor by the local name, so `pr ship` and recap ask by the name
3
+ // the upstream gives, and take that pull request only when its commits hold the local head.
4
+ import { asked } from "../working-tree.js";
5
+ /**
6
+ * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
7
+ * after the remote deleted the branch.
8
+ */
9
+ export function upstreamName(root, branch) {
10
+ const remote = asked(root, ["config", "--get", `branch.${branch}.remote`]);
11
+ const merge = asked(root, ["config", "--get", `branch.${branch}.merge`]);
12
+ if (remote === undefined || remote === "." || merge === undefined || !merge.startsWith("refs/heads/")) {
13
+ return undefined;
14
+ }
15
+ const name = merge.slice("refs/heads/".length);
16
+ return name === branch ? undefined : name;
17
+ }
18
+ /** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
19
+ export function holds(pr, head) {
20
+ return head !== null && pr.commits.includes(head);
21
+ }
22
+ /** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
23
+ export function renamedUpstream(root) {
24
+ const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
25
+ const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]);
26
+ if (branch === undefined || head === undefined) {
27
+ return undefined;
28
+ }
29
+ const upstream = upstreamName(root, branch);
30
+ return upstream === undefined ? undefined : { branch, head, upstream };
31
+ }
@@ -19,6 +19,6 @@ export type Ran = {
19
19
  export declare const TAIL_LINES = 50;
20
20
  /** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
21
21
  export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
22
- /** The signal that stopped the run, once one has; no command starts after it. */
22
+ /** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
23
23
  export declare function stoppedBy(): NodeJS.Signals | undefined;
24
24
  export declare function runCommand(command: string, root: string, output: Output): Promise<Ran>;
@@ -13,6 +13,11 @@ const STOPPING = ["SIGINT", "SIGTERM"];
13
13
  let running;
14
14
  /** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
15
15
  let received;
16
+ /**
17
+ * The SIGINT or SIGTERM that ended a command; no command starts after it either. Ctrl-C reaches the
18
+ * command and the Gate together, and the command's exit can be seen before the Gate's own signal.
19
+ */
20
+ let commandEndedBy;
16
21
  function release() {
17
22
  for (const signal of STOPPING) {
18
23
  process.off(signal, stop);
@@ -32,6 +37,8 @@ function stop(signal) {
32
37
  export async function holdingSignals(body) {
33
38
  running = undefined;
34
39
  received = undefined;
40
+ commandEndedBy = undefined;
41
+ release();
35
42
  for (const signal of STOPPING) {
36
43
  process.on(signal, stop);
37
44
  }
@@ -41,12 +48,16 @@ export async function holdingSignals(body) {
41
48
  finally {
42
49
  // One turn of the event loop hands a signal that landed after the last command to its handler.
43
50
  await new Promise((resolve) => setImmediate(resolve));
44
- release();
51
+ // The Gate's own signal may still be on its way after a command it ended: the handlers stay, so
52
+ // that signal does not end the Gate before its report.
53
+ if (commandEndedBy === undefined || received !== undefined) {
54
+ release();
55
+ }
45
56
  }
46
57
  }
47
- /** The signal that stopped the run, once one has; no command starts after it. */
58
+ /** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
48
59
  export function stoppedBy() {
49
- return received;
60
+ return received ?? commandEndedBy;
50
61
  }
51
62
  /** Everything written to `fd`, read from its start: the command's writes moved the offset it shares to the end. */
52
63
  function written(fd) {
@@ -99,7 +110,12 @@ function run(command, root, stdio) {
99
110
  done({ status: "error", exitCode: null, signal: null, detail: `could not start: ${error.message}` });
100
111
  }
101
112
  });
102
- child.once("exit", (exitCode, signal) => done(answer(exitCode, signal)));
113
+ child.once("exit", (exitCode, signal) => {
114
+ if (signal !== null && STOPPING.includes(signal)) {
115
+ commandEndedBy ??= signal;
116
+ }
117
+ done(answer(exitCode, signal));
118
+ });
103
119
  });
104
120
  }
105
121
  export async function runCommand(command, root, output) {
@@ -4,6 +4,8 @@ export declare function git(root: string, args: string[], index?: string): {
4
4
  } | {
5
5
  error: string;
6
6
  };
7
+ /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
8
+ export declare function asked(root: string, args: string[]): string | undefined;
7
9
  /** The tree of the checkout at `root` and its git dir, or why it has none. */
8
10
  export declare function workingTree(root: string): {
9
11
  tree: string;
@@ -20,6 +20,11 @@ export function git(root, args, index) {
20
20
  const reason = result.error?.message ?? result.stderr.split("\n").find((line) => line.trim() !== "");
21
21
  return { error: `git ${args[0] ?? ""}: ${reason?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
22
22
  }
23
+ /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
24
+ export function asked(root, args) {
25
+ const result = git(root, args);
26
+ return "out" in result ? result.out : undefined;
27
+ }
23
28
  /**
24
29
  * The tree `git write-tree` hashes from a copy of the index after `git add -A` on it: the copy keeps the
25
30
  * stat cache, so only what changed is read again; identical content survives a commit, an amend or a
@@ -0,0 +1,15 @@
1
+ /** The process is gone: `held` is the reason to put back should the removal fail, `why` what the Sweep tells. */
2
+ export type StaleLock = {
3
+ state: "stale";
4
+ held: string;
5
+ why: string;
6
+ };
7
+ export type LockVerdict = {
8
+ state: "none";
9
+ } | StaleLock | {
10
+ state: "stays";
11
+ reason: string;
12
+ command: string | null;
13
+ };
14
+ /** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
15
+ export declare function lockVerdict(path: string, reason: string | undefined): LockVerdict;
@@ -0,0 +1,63 @@
1
+ // What a worktree's lock means to a Sweep. Claude Code locks the worktree of a session with the reason
2
+ // `claude session <name> (pid <N> start <time>)`, <time> as `ps -o lstart` writes it in UTC. Such a lock is
3
+ // stale once no process N runs or N started at another time. Any other reason is the dev's to lift.
4
+ import { spawnSync } from "node:child_process";
5
+ import { shellLine } from "./pr/decide.js";
6
+ const CLAUDE_LOCK = /^claude session .* \(pid (?<pid>\d{1,10}) start (?<start>.+)\)$/;
7
+ const MAX_PID = 2 ** 31 - 1;
8
+ const squeezed = (text) => text.trim().replace(/\s+/g, " ");
9
+ /**
10
+ * Whether process `pid` runs and, if so, its start time as `ps` writes it in UTC and in local time, so a
11
+ * live lock is never taken for stale over a time zone.
12
+ */
13
+ function runningOf(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
19
+ return { at: "gone" };
20
+ }
21
+ }
22
+ const starts = [{ TZ: "UTC" }, {}]
23
+ .map((zone) => {
24
+ const env = { ...process.env, LC_ALL: "C", ...zone };
25
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
26
+ return result.status === 0 ? squeezed(result.stdout) : "";
27
+ })
28
+ .filter((start) => start !== "");
29
+ return starts.length === 0 ? { at: "unknown" } : { at: "running", starts };
30
+ }
31
+ /** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
32
+ export function lockVerdict(path, reason) {
33
+ if (reason === undefined) {
34
+ return { state: "none" };
35
+ }
36
+ const unlock = shellLine(["git", "worktree", "unlock", path]);
37
+ const { pid: pidText = "", start = "" } = CLAUDE_LOCK.exec(reason)?.groups ?? {};
38
+ const pid = Number(pidText);
39
+ if (pidText === "" || pid < 1 || pid > MAX_PID) {
40
+ const said = reason === "" ? "locked, with no reason given" : `locked: ${reason}`;
41
+ return { state: "stays", reason: said, command: unlock };
42
+ }
43
+ const now = runningOf(pid);
44
+ if (now.at === "gone") {
45
+ return { state: "stale", held: reason, why: `pid ${pid} is gone, no process runs under it` };
46
+ }
47
+ if (now.at === "unknown") {
48
+ return { state: "stays", reason: `locked: ${reason}; ps could not say whether pid ${pid} runs`, command: unlock };
49
+ }
50
+ if (!now.starts.includes(squeezed(start))) {
51
+ const started = now.starts[0] ?? "";
52
+ return {
53
+ state: "stale",
54
+ held: reason,
55
+ why: `pid ${pid} is gone, it now belongs to another process (started ${started} UTC)`
56
+ };
57
+ }
58
+ return {
59
+ state: "stays",
60
+ reason: `locked by a live Claude Code session: pid ${pid} is running (${reason})`,
61
+ command: null
62
+ };
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {