dw-sdlc 0.15.0 → 0.15.2

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.
@@ -1,6 +1,6 @@
1
1
  import { type CommitConvention, type CommitOnProtected, type ForcePush } from "../agentic-config.ts";
2
- /** `by hand` is an edit the setup names and leaves to the user. */
3
- export type Outcome = "wrote" | "unchanged" | "refused" | "by hand";
2
+ /** `by hand` is an edit the setup names and leaves to the user; `skipped` is a file it has no reason to write. */
3
+ export type Outcome = "wrote" | "unchanged" | "skipped" | "refused" | "by hand";
4
4
  export type Step = {
5
5
  file: string;
6
6
  outcome: Outcome;
@@ -28,7 +28,8 @@ is kept, and --force-push and --commit-on-protected pin those two keys in it, re
28
28
  loosen what the committed config sets; --force overwrites a local config that differs in anything
29
29
  else; --dry-run reports without writing. Where cswap is on PATH, the local config also gets
30
30
  worker.command, the command a Worker starts with: cswap run -- --dangerously-skip-permissions. A
31
- worker block the file holds is kept as it is, and without cswap nothing is written.
31
+ worker block the file holds is kept as it is, and without cswap nothing is written. A local config
32
+ holding only $schema is not written, and an existing one stays: the run prints "skipped".
32
33
 
33
34
  Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
34
35
  there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
@@ -251,8 +252,8 @@ export function setup(options) {
251
252
  const root = repositoryRoot(options.root);
252
253
  const { checkout, gitDir } = mainCheckout(root);
253
254
  const claudeDir = options.claudeDir ?? join(options.home, ".claude");
254
- // 1. The local config, from inference, the convention (the flag's, else the one the file pins) and
255
- // the guardrails block the file holds, with the keys the flags pin.
255
+ // 1. The local config: inference, the convention (the flag's, else the file's pin), the guardrails block
256
+ // the file holds with the flags' pins. A layer of `$schema` alone tells the guards nothing: not written.
256
257
  const localPath = join(checkout, LOCAL_FILE);
257
258
  const localCurrent = existsSync(localPath) ? readFileSync(localPath, "utf8") : undefined;
258
259
  const existing = localCurrent === undefined ? undefined : parseObject(localCurrent);
@@ -264,7 +265,10 @@ export function setup(options) {
264
265
  else {
265
266
  const { worker, detail } = pinnedWorker(keptWorker(existing), options.path ?? process.env.PATH);
266
267
  const local = localConfig(checkout, claudeDir, convention, pinned.guardrails, worker);
267
- if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
268
+ if (Object.keys(local).length === 1 && (localCurrent === undefined || sameContent(existing, local))) {
269
+ writer.steps.push({ file: LOCAL_FILE, outcome: "skipped", detail: "the layer would hold only $schema" });
270
+ }
271
+ else if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
268
272
  writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
269
273
  }
270
274
  else {
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
7
7
  import { mainCheckout, repositoryRoot, resolveConfig, WORKER_EFFORTS } from "../agentic-config.js";
8
8
  import { isObject } from "../json-text.js";
9
9
  import { describe, hcObject, isLive, lead, refuse, Refusal, wholeNumber, workersOf } from "../worker/herdr.js";
10
+ import { freshenOrigin } from "../worker/origin.js";
10
11
  import { nameTab } from "../worker/tabs.js";
11
12
  export const summary = "start a Worker on a Brief in a herdr terminal";
12
13
  /** The most seconds the command looks for the Worker's tab in the window, beside the read-back. */
@@ -27,12 +28,17 @@ turns working, blocked or done. It fails when herdr reports the first message as
27
28
  ended, the Worker reports an error, or the Worker is still idle or silent when the time is up. It never sends
28
29
  the Brief again: read the terminal with hc read first.
29
30
 
31
+ Before it starts the Worker it fetches origin's default branch into the clone (the branch origin/HEAD names,
32
+ else baseBranch of the config, else every branch of origin), so the Worker's worktree, which Claude Code
33
+ branches from origin/<default>, begins from origin's tip. A clone with no origin skips the fetch. When the
34
+ fetch fails the command starts no Worker and exits 2.
35
+
30
36
  It refuses outside herdr ($HC_TERMINAL_ID unset) and when the Lead already has --max-workers live
31
37
  Workers (default 3). The name is 1-32 of a-z, 0-9, - and _; it names the worktree too.
32
38
 
33
39
  One line, or one JSON document with --json.
34
40
 
35
- Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run.`;
41
+ Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run, a failed fetch included.`;
36
42
  const DEFAULT_MAX_WORKERS = 3;
37
43
  const DEFAULT_WAIT = 30;
38
44
  /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
@@ -156,7 +162,7 @@ async function start(cli) {
156
162
  const checkout = repositoryRoot(process.cwd());
157
163
  // The Worker's --worktree makes a worktree of the repository itself, never of a linked one.
158
164
  const root = mainCheckout(checkout) ?? checkout;
159
- const worker = resolveConfig(checkout).config.worker;
165
+ const { baseBranch, worker } = resolveConfig(checkout).config;
160
166
  const command = worker.command.split(/\s+/).filter(Boolean);
161
167
  if (command.length === 0) {
162
168
  throw new Refusal("worker.command holds no command", "set it in .ai/agentic.config.local.json, or remove the key");
@@ -167,6 +173,8 @@ async function start(cli) {
167
173
  if (live.length >= cli.max) {
168
174
  throw new Refusal(`${leadId} already has ${live.length} live Workers (the limit is ${cli.max})`, `carry one through first, or raise the limit with --max-workers ${live.length + 1}`);
169
175
  }
176
+ // Before hc run, so a failed fetch starts no terminal.
177
+ freshenOrigin(root, baseBranch);
170
178
  const space = spaceOf(leadId);
171
179
  const { address } = hcObject([
172
180
  "run",
package/dist/exec.js CHANGED
@@ -1,6 +1,6 @@
1
- // Every short-lived child process the package runs: stopped at a time limit, so a tool that hangs cannot
2
- // hang the command, and with git's prompts off, since nobody would see one. A Step of the Gate runs
3
- // through `run-command.ts` instead, and takes as long as it takes.
1
+ // Every short-lived child process the package runs: in a session of its own, so a time limit stops it with
2
+ // every child it started; no terminal, so no prompt, ssh's included; Ctrl-C stops the package, not the
3
+ // tool. A Step of the Gate runs through `run-command.ts`, as long as it takes.
4
4
  import { spawnSync } from "node:child_process";
5
5
  const LIMIT_MS = 120_000;
6
6
  /** For what the network or a repository's size can stretch: a clone, a fetch, a scan of its history. */
@@ -19,20 +19,39 @@ function limitOf(asked) {
19
19
  function shown(tool, args) {
20
20
  return [tool, ...args].join(" ").replaceAll(/\s+/g, " ").trim().slice(0, SHOWN);
21
21
  }
22
+ /** Every process left in the group the tool led; the tool itself is stopped and reaped by now. */
23
+ function stopGroup(pid) {
24
+ // A pid of 0 would make the kill reach this process's own group.
25
+ if (pid <= 0) {
26
+ return;
27
+ }
28
+ try {
29
+ process.kill(-pid, "SIGKILL");
30
+ }
31
+ catch (error) {
32
+ // ESRCH: no process is left in the group.
33
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
34
+ throw error;
35
+ }
36
+ }
37
+ }
22
38
  /** `tool args` as a child process; a tool's own exit, or its absence, is the caller's. Throws `TimedOut` at the limit. */
23
39
  export function exec(tool, args, options = {}) {
24
40
  const { cwd, env = process.env, input } = options;
25
41
  const limitMs = limitOf(options.limitMs ?? LIMIT_MS);
26
- const result = spawnSync(tool, args, {
42
+ const spawnOptions = {
27
43
  ...(cwd === undefined ? {} : { cwd }),
28
44
  ...(input === undefined ? {} : { input }),
45
+ detached: true,
29
46
  encoding: "utf8",
30
47
  env: { ...env, GIT_TERMINAL_PROMPT: "0" },
31
48
  killSignal: "SIGKILL",
32
49
  maxBuffer: MAX_BUFFER,
33
50
  timeout: limitMs
34
- });
51
+ };
52
+ const result = spawnSync(tool, args, spawnOptions);
35
53
  if (result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT") {
54
+ stopGroup(result.pid);
36
55
  throw new TimedOut(`${shown(tool, args)} did not answer within ${limitMs / 1000} s`);
37
56
  }
38
57
  return result;
package/dist/pr/decide.js CHANGED
@@ -157,7 +157,7 @@ const ROWS = [
157
157
  action: "done",
158
158
  actor: "dev",
159
159
  reason: pr.mergeCommit === null ? "merged" : `merged as ${pr.mergeCommit.slice(0, 7)}`,
160
- command: shellLine(["git", "push", "origin", "--delete", pr.branch]),
160
+ ...(pr.branchStands ? { command: shellLine(["git", "push", "origin", "--delete", pr.branch]) } : {}),
161
161
  ...(pr.mergeCommit === null ? {} : { details: { mergeCommit: pr.mergeCommit } })
162
162
  }),
163
163
  alone: true
@@ -249,14 +249,18 @@ const ROWS = [
249
249
  }
250
250
  },
251
251
  {
252
- when: (pr) => ciOf(pr).length === 0,
253
- answer: (pr, now, policy) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
252
+ // A head whose every check was skipped has run nothing: a draft's are, until `ready` starts the run.
253
+ when: (pr) => ciOf(pr).length === 0 || (!pr.draft && ciOf(pr).every((check) => check.bucket === "skipping")),
254
+ answer: (pr, now, policy) => now - Math.max(Date.parse(pr.headCommittedAt), Date.parse(pr.readyAt ?? pr.headCommittedAt)) <=
255
+ GRACE_SECONDS * 1000
254
256
  ? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
255
257
  : {
256
258
  status: "park",
257
259
  action: "unverified",
258
260
  actor: "dev",
259
- reason: `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`,
261
+ reason: ciOf(pr).length === 0
262
+ ? `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`
263
+ : `every check on ${pr.head.slice(0, 7)} was skipped, ${GRACE_SECONDS} s after its commit or its ready`,
260
264
  command: devCommand(pr, "merge", policy)
261
265
  }
262
266
  },
package/dist/pr/read.d.ts CHANGED
@@ -39,6 +39,8 @@ export declare function defaultBranch(): string;
39
39
  export declare function now(): number;
40
40
  /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
41
41
  export declare function readAgain(pr: PrState, sized: boolean): PrState;
42
+ /** For a test that runs the command in its own process: each case starts with no compare read. */
43
+ export declare function forgetCompares(): void;
42
44
  /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
43
45
  export declare function carriesBase(before: PrState, after: PrState): boolean;
44
46
  /**
package/dist/pr/read.js CHANGED
@@ -164,6 +164,10 @@ export function readAgain(pr, sized) {
164
164
  * once: a compare of two commits never changes.
165
165
  */
166
166
  const COMPARES = new Map();
167
+ /** For a test that runs the command in its own process: each case starts with no compare read. */
168
+ export function forgetCompares() {
169
+ COMPARES.clear();
170
+ }
167
171
  /** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
168
172
  function compared(pr, from, commit, what) {
169
173
  const key = `${pr.repo} ${from}...${commit}`;
@@ -10,7 +10,7 @@ export declare const MARK_STATE = "failure";
10
10
  export declare const READ_MARK = "read, findings open";
11
11
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
12
  export declare const ACCEPTED = "accepted by the dev, findings open";
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 latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } 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) { pageInfo { hasNextPage } 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}";
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 headRef { name } baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } 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) { pageInfo { hasNextPage } 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}";
14
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
15
  export type CiCheck = {
16
16
  name: string;
@@ -51,7 +51,11 @@ export type PrState = {
51
51
  viewerDidAuthor: boolean;
52
52
  head: string;
53
53
  headCommittedAt: string;
54
+ /** When the pull request last left draft; null when it never was one. */
55
+ readyAt: string | null;
54
56
  branch: string;
57
+ /** Whether the head branch still stands on the remote: GitHub reports no `headRef` once it is gone. */
58
+ branchStands: boolean;
55
59
  base: string;
56
60
  /**
57
61
  * The commit the base branch points at, read from the branch itself: GitHub's `baseRefOid` lags a
package/dist/pr/state.js CHANGED
@@ -18,8 +18,9 @@ const THREADS_READ = 100;
18
18
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
19
19
  repository(owner: $owner, name: $name) {
20
20
  pullRequest(number: $number) {
21
- url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
22
- baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
21
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } baseRefName
22
+ baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
23
+ ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }
23
24
  latestReviews(first: 100) { nodes { state author { login } commit { oid } } }
24
25
  reviewThreads(first: ${THREADS_READ}) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line
25
26
  comments(first: 1) { nodes { author { login } body } }
@@ -302,6 +303,7 @@ export function parsePr(reply, repo) {
302
303
  const head = string(pr, "headRefOid");
303
304
  const baseRef = childOrNull(pr, "baseRef");
304
305
  const history = historyOf(pr);
306
+ const [ready] = nodes(pr, "ready");
305
307
  return {
306
308
  url: string(pr, "url"),
307
309
  repo,
@@ -314,7 +316,9 @@ export function parsePr(reply, repo) {
314
316
  viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
315
317
  head,
316
318
  headCommittedAt: time(commit, "committedDate"),
319
+ readyAt: ready === undefined ? null : time(ready, "createdAt"),
317
320
  branch: string(pr, "headRefName"),
321
+ branchStands: childOrNull(pr, "headRef") !== null,
318
322
  base: string(pr, "baseRefName"),
319
323
  baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
320
324
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Fetches origin's default branch, or every branch of origin when no default is known, into the remote-tracking
3
+ * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws a `Refusal` when the
4
+ * fetch fails, since a Worker started on a stale base is the fault this prevents.
5
+ */
6
+ export declare function freshenOrigin(root: string, baseBranch: string): void;
@@ -0,0 +1,42 @@
1
+ // Brings the clone's view of origin's default branch up to date before a Worker starts: Claude Code's
2
+ // `--worktree` branches from the cached `origin/<default>` and fetches it only when the clone has gone a
3
+ // day without a fetch, so a Worker could otherwise begin from a ref that is hours or days behind.
4
+ import { exec, SLOW_LIMIT_MS, TimedOut } from "../exec.js";
5
+ import { repositoryFreeEnv } from "../git-env.js";
6
+ import { asked } from "../working-tree.js";
7
+ import { Refusal } from "./herdr.js";
8
+ const REMOTE_HEAD = "refs/remotes/origin/HEAD";
9
+ const PREFIX = "refs/remotes/origin/";
10
+ /** The branch to fetch: the one origin/HEAD names, else `baseBranch` of the config; undefined when neither names one. */
11
+ function defaultBranch(root, baseBranch) {
12
+ const ref = asked(root, ["symbolic-ref", "-q", REMOTE_HEAD]);
13
+ if (ref !== undefined && ref.startsWith(PREFIX) && ref.length > PREFIX.length) {
14
+ return ref.slice(PREFIX.length);
15
+ }
16
+ return baseBranch === "" || baseBranch === "auto" ? undefined : baseBranch;
17
+ }
18
+ /**
19
+ * Fetches origin's default branch, or every branch of origin when no default is known, into the remote-tracking
20
+ * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws a `Refusal` when the
21
+ * fetch fails, since a Worker started on a stale base is the fault this prevents.
22
+ */
23
+ export function freshenOrigin(root, baseBranch) {
24
+ if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
25
+ return;
26
+ }
27
+ const branch = defaultBranch(root, baseBranch);
28
+ const args = ["-C", root, "fetch", "--quiet", "origin", ...(branch === undefined ? [] : [branch])];
29
+ const fetched = `git fetch origin${branch === undefined ? "" : ` ${branch}`}`;
30
+ const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin, then start the Worker again");
31
+ let result;
32
+ try {
33
+ result = exec("git", args, { env: repositoryFreeEnv(), limitMs: SLOW_LIMIT_MS });
34
+ }
35
+ catch (error) {
36
+ throw error instanceof TimedOut ? stale(error.message) : error;
37
+ }
38
+ if (result.status !== 0) {
39
+ const said = result.stderr.split("\n").find((line) => line.trim() !== "");
40
+ throw stale(said?.trim() ?? `exited ${result.status ?? "on a signal"}`);
41
+ }
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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": {