github-delivery-os 1.2.0 → 1.2.1

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.
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // Pure verdict-computation logic for authorize-deployment.yml, extracted out
4
+ // of the inline actions/github-script step so it can be unit tested directly
5
+ // (see test/authorize-deployment-verdict.test.js) instead of only verified
6
+ // by hand whenever it changes. Has no dependency on the `github`/`context`
7
+ // globals actions/github-script injects — everything it needs is passed in.
8
+
9
+ // Anchored to comment start + word boundary so keywords must lead the
10
+ // comment (matches the documented convention) and can't match inside a
11
+ // larger word (e.g. "ok" no longer matches "okay", "reject" no longer
12
+ // matches "rejection") or as a substring anywhere in unrelated prose.
13
+ const DECLINE_RE = /^(declined|rejected|reject|not approved)\b/i;
14
+ const APPROVE_RE = /^(approved|approve|ok|go ahead)\b/i;
15
+ const QA_APPROVE_RE = /^(qa approved|approved|qa ok|looks good)\b/i;
16
+
17
+ const normalize = (s) => (s || '').toLowerCase();
18
+
19
+ /**
20
+ * Walks comments in chronological order and keeps the LATEST verdict from
21
+ * each approver, rather than stopping at the first decline seen. This lets a
22
+ * release approver re-approve after an earlier decline (e.g. once fixes
23
+ * land) instead of being permanently stuck as declined.
24
+ *
25
+ * @param {Array<{ user?: { login?: string | null } | null, body?: string | null }>} comments
26
+ * Chronological (oldest first), matching the order github.paginate(listComments) returns.
27
+ * @param {string} releaseApprover
28
+ * @param {string} qaApprover
29
+ * @returns {{ releaseVerdict: 'approved' | 'declined' | null, qaApproved: boolean }}
30
+ */
31
+ function computeVerdict(comments, releaseApprover, qaApprover) {
32
+ let releaseVerdict = null;
33
+ let qaApproved = false;
34
+
35
+ for (const comment of comments) {
36
+ const login = comment.user && comment.user.login;
37
+ const body = (comment.body || '').trim();
38
+
39
+ if (normalize(login) === normalize(releaseApprover)) {
40
+ if (DECLINE_RE.test(body)) {
41
+ releaseVerdict = 'declined';
42
+ } else if (APPROVE_RE.test(body)) {
43
+ releaseVerdict = 'approved';
44
+ }
45
+ }
46
+
47
+ if (normalize(login) === normalize(qaApprover) && QA_APPROVE_RE.test(body)) {
48
+ qaApproved = true;
49
+ }
50
+ }
51
+
52
+ return { releaseVerdict, qaApproved };
53
+ }
54
+
55
+ module.exports = { computeVerdict, DECLINE_RE, APPROVE_RE, QA_APPROVE_RE };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ // Pure burn-down/health computation for auto-close-sprint.yml, extracted so
4
+ // it can be unit tested directly (see test/auto-close-sprint.test.js)
5
+ // instead of only verified by hand.
6
+
7
+ /**
8
+ * @param {string} body - a child issue's body
9
+ * @returns {number | null} the parent sprint issue number, or null if this
10
+ * issue isn't a sprint child (no "Parent Sprint: #N" line).
11
+ */
12
+ function parseParentSprintNumber(body) {
13
+ const match = (body || '').match(/Parent Sprint:\s*#(\d+)/);
14
+ return match ? parseInt(match[1], 10) : null;
15
+ }
16
+
17
+ /**
18
+ * Parses sprint dates out of a sprint issue's body, supporting both the
19
+ * combined "Sprint Dates: X to Y" format and the separate "Sprint Start" /
20
+ * "Sprint End" field format (the sprint_planning.yml template).
21
+ *
22
+ * @param {string} sprintBody - the sprint (parent) issue's body
23
+ * @returns {{ startDate: Date, endDate: Date } | null} null if neither
24
+ * format matched.
25
+ */
26
+ function parseSprintDates(sprintBody) {
27
+ const body = sprintBody || '';
28
+
29
+ const rangeMatch = body.match(/Sprint Dates[\s\S]*?(\d{4}-\d{2}-\d{2})\s*to\s*(\d{4}-\d{2}-\d{2})/i);
30
+ if (rangeMatch) {
31
+ return { startDate: new Date(rangeMatch[1]), endDate: new Date(rangeMatch[2]) };
32
+ }
33
+
34
+ const startMatch = body.match(/Sprint Start[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
35
+ const endMatch = body.match(/Sprint End[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
36
+ if (startMatch && endMatch) {
37
+ return { startDate: new Date(startMatch[1]), endDate: new Date(endMatch[1]) };
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * @param {Date} startDate
45
+ * @param {Date} endDate
46
+ * @param {Date} now
47
+ * @returns {number} percent of the sprint's duration elapsed as of `now`,
48
+ * clamped to [0, 100]. Returns 100 for a zero-or-negative-length sprint
49
+ * (Start >= End — a data-entry mistake) or an unparseable date (e.g. an
50
+ * out-of-range "2026-13-05" typo, which regex-matches the YYYY-MM-DD
51
+ * shape but produces an Invalid Date) instead of dividing by zero or NaN:
52
+ * that NaN would otherwise flow into the posted burn-down as "Time
53
+ * Elapsed: NaN%", and every NaN comparison in computeHealthEmoji is
54
+ * false, so it'd silently default to the green/no-warning branch instead
55
+ * of flagging the broken dates. `!(totalDuration > 0)` (rather than
56
+ * `totalDuration <= 0`) catches NaN too, since every comparison against
57
+ * NaN is false — `NaN <= 0` is false, but so is `NaN > 0`, and negating
58
+ * that is true.
59
+ */
60
+ function computeTimePercent(startDate, endDate, now) {
61
+ const totalDuration = endDate - startDate;
62
+ if (!(totalDuration > 0)) return 100;
63
+ const elapsed = now - startDate;
64
+ return Math.max(0, Math.min(100, Math.round((elapsed / totalDuration) * 100)));
65
+ }
66
+
67
+ /**
68
+ * @param {Array<{ state: string }>} children
69
+ * @returns {{ progressPercent: number, closedCount: number, totalCount: number }}
70
+ */
71
+ function computeProgress(children) {
72
+ const totalCount = children.length;
73
+ const closedCount = children.filter((i) => i.state === 'closed').length;
74
+ const progressPercent = totalCount === 0 ? 0 : Math.round((closedCount / totalCount) * 100);
75
+ return { progressPercent, closedCount, totalCount };
76
+ }
77
+
78
+ /**
79
+ * Sprint health relative to time elapsed: red if progress trails time by
80
+ * more than 10 points, yellow if behind at all, green otherwise.
81
+ *
82
+ * @returns {'🟢' | '🟡' | '🔴'}
83
+ */
84
+ function computeHealthEmoji(progressPercent, timePercent) {
85
+ if (progressPercent < timePercent - 10) return '🔴';
86
+ if (progressPercent < timePercent) return '🟡';
87
+ return '🟢';
88
+ }
89
+
90
+ /**
91
+ * @returns {string} a block-character progress bar, e.g. "████░░░░░░░░░░░░░░░░".
92
+ */
93
+ function renderBurnDown(progressPercent, totalBars = 20) {
94
+ const filledBars = Math.round((progressPercent / 100) * totalBars);
95
+ return '█'.repeat(filledBars) + '░'.repeat(totalBars - filledBars);
96
+ }
97
+
98
+ /**
99
+ * Replaces any previous "## 🚦 Sprint Status" block in the sprint body with
100
+ * a freshly rendered one (appended at the end).
101
+ */
102
+ function updateSprintBody(sprintBody, { progressPercent, timePercent, healthEmoji, burnDown }) {
103
+ let updatedBody = (sprintBody || '').replace(/## 🚦 Sprint Status[\s\S]*?---/g, '');
104
+
105
+ updatedBody +=
106
+ `\n\n---\n\n## 🚦 Sprint Status\n\n` +
107
+ `Progress: **${progressPercent}%**\n` +
108
+ `Time Elapsed: **${timePercent}%**\n\n` +
109
+ `Health: ${healthEmoji}\n\n` +
110
+ `### 📉 Burn-down\n` +
111
+ `\`${burnDown}\` ${progressPercent}%\n\n---`;
112
+
113
+ return updatedBody;
114
+ }
115
+
116
+ module.exports = {
117
+ parseParentSprintNumber,
118
+ parseSprintDates,
119
+ computeTimePercent,
120
+ computeProgress,
121
+ computeHealthEmoji,
122
+ renderBurnDown,
123
+ updateSprintBody,
124
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // Pure parsing logic for sprint-child-creator.yml, extracted so it can be
4
+ // unit tested directly (see test/sprint-child-creator.test.js) instead of
5
+ // only verified by hand.
6
+
7
+ /**
8
+ * Extracts the feature list from a sprint-planning issue body's
9
+ * "### Sprint Features" section (one feature per line, up to the next
10
+ * heading or end of body).
11
+ *
12
+ * @param {string} body - the sprint issue's body
13
+ * @returns {string[]} feature titles, in the order they appear. Empty if
14
+ * there's no Sprint Features section, or it has no non-blank lines.
15
+ */
16
+ function parseFeatures(body) {
17
+ const featuresMatch = (body || '').match(/### Sprint Features[\s\S]*?(?=###|$)/);
18
+ if (!featuresMatch) return [];
19
+
20
+ return featuresMatch[0]
21
+ .split('\n')
22
+ .map((line) => line.trim())
23
+ .filter((line) => line && !line.startsWith('###'));
24
+ }
25
+
26
+ /**
27
+ * @param {number} parentNumber - the sprint (parent) issue number
28
+ * @returns {string} body to use for each generated child issue
29
+ */
30
+ function buildChildBody(parentNumber) {
31
+ return `Parent Sprint: #${parentNumber}\n\n---\n*Created by Delivery OS Sprint Child Creator*`;
32
+ }
33
+
34
+ module.exports = { parseFeatures, buildChildBody };
@@ -58,19 +58,40 @@ jobs:
58
58
 
59
59
  const { releaseVerdict, qaApproved } = computeVerdict(comments, releaseApprover, qaApprover);
60
60
 
61
- const currentLabels = (context.payload.issue.labels || []).map((l) => l.name);
61
+ // Nothing to act on this run (most comments on a production issue
62
+ // are routine discussion, not a verdict-changing approval/decline)
63
+ // — return before the live label fetch below, which only matters
64
+ // once we actually have a verdict to check against current state.
65
+ const hasActionableVerdict =
66
+ releaseVerdict === "declined" || (releaseVerdict === "approved" && qaApproved);
67
+ if (!hasActionableVerdict) return;
68
+
69
+ // Fetch labels live rather than trusting context.payload.issue.labels:
70
+ // that's a snapshot fixed at the triggering event's delivery time.
71
+ // Two comments posted seconds apart queue under the concurrency
72
+ // group above and run sequentially, but each was still delivered
73
+ // (and its label snapshot captured) before either job actually ran
74
+ // — serializing execution order doesn't refresh an already-stale
75
+ // snapshot. A live re-fetch is what actually lets the second
76
+ // (queued) run see what the first one just did.
77
+ const { data: liveIssue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
78
+ const currentLabels = (liveIssue.labels || []).map((l) => (typeof l === "string" ? l : l.name));
62
79
  const alreadyDeclined = currentLabels.includes("declined");
63
80
  const alreadyReady = currentLabels.includes("ready-for-deploy");
64
81
 
65
82
  if (releaseVerdict === "declined") {
66
83
  if (alreadyDeclined) return; // avoid re-posting on every later comment
67
84
 
68
- await github.rest.issues.addLabels({
69
- owner,
70
- repo,
71
- issue_number: issueNumber,
72
- labels: ["declined"]
73
- });
85
+ try {
86
+ await github.rest.issues.addLabels({
87
+ owner,
88
+ repo,
89
+ issue_number: issueNumber,
90
+ labels: ["declined"]
91
+ });
92
+ } catch (e) {
93
+ console.log(`Failed to add 'declined' label: ${e.message || e}`);
94
+ }
74
95
 
75
96
  try {
76
97
  await github.rest.issues.removeLabel({
@@ -103,12 +124,16 @@ jobs:
103
124
  });
104
125
  } catch (e) {}
105
126
 
106
- await github.rest.issues.addLabels({
107
- owner,
108
- repo,
109
- issue_number: issueNumber,
110
- labels: ["ready-for-deploy"]
111
- });
127
+ try {
128
+ await github.rest.issues.addLabels({
129
+ owner,
130
+ repo,
131
+ issue_number: issueNumber,
132
+ labels: ["ready-for-deploy"]
133
+ });
134
+ } catch (e) {
135
+ console.log(`Failed to add 'ready-for-deploy' label: ${e.message || e}`);
136
+ }
112
137
 
113
138
  await github.rest.issues.createComment({
114
139
  owner,
@@ -70,17 +70,23 @@ jobs:
70
70
 
71
71
  // Paginate: a plain listForRepo call caps at one page (100 issues) and
72
72
  // would silently undercount children in repos with more open+closed
73
- // issues than that, skewing the burn-down. Scoped to `sprint-active`
74
- // (the label every child issue gets on creation) instead of every
75
- // issue in the repo, so this stays cheap in repos with a lot of
76
- // unrelated issue history — this workflow now runs serialized per
77
- // repo, so an unscoped full-repo fetch on every child close would
78
- // add up fast.
73
+ // issues than that, skewing the burn-down.
74
+ //
75
+ // Deliberately NOT scoped to labels: "sprint-active" (an earlier
76
+ // version of this workflow did that, for cost — cheaper in repos
77
+ // with a lot of unrelated issue history). That's wrong: a child
78
+ // issue relabeled away from sprint-active during triage (e.g. to
79
+ // "bug") while still open would silently drop out of BOTH the
80
+ // numerator and denominator of the progress calculation instead
81
+ // of just being excluded from the closed count — the sprint could
82
+ // report inflated progress, or even reach a false 100% and
83
+ // auto-close, while that untracked child is still open. Matches
84
+ // docs/governance.md's documented trigger ("body has Parent
85
+ // Sprint"), which is body-content-based, not label-based.
79
86
  const allIssues = await github.paginate(github.rest.issues.listForRepo, {
80
87
  owner: context.repo.owner,
81
88
  repo: context.repo.repo,
82
89
  state: "all",
83
- labels: "sprint-active",
84
90
  per_page: 100
85
91
  });
86
92
 
@@ -103,14 +103,20 @@ jobs:
103
103
  # Case-insensitive actor comparison: GitHub logins are case-insensitive,
104
104
  # but a plain bash == is not, so a RELEASE_APPROVER var with different
105
105
  # casing than the actual login would otherwise never match here.
106
- # Anchored to the start of a line, matching the same keyword set and
107
- # convention as authorize-deployment.yml's DECLINE_RE, so this alert
108
- # can't fire for a comment that workflow wouldn't actually treat as a
109
- # decline (e.g. "not approved" appearing mid-sentence).
110
- if [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(declined|rejected|reject|not approved)"; then
106
+ # Anchored to the start of a line + word boundary (\b), matching the
107
+ # same keyword set and convention as authorize-deployment.yml's real
108
+ # DECLINE_RE/APPROVE_RE, so this alert can't fire for a comment that
109
+ # workflow wouldn't actually treat as a decline/approval (e.g. "not
110
+ # approved" mid-sentence, or "okay, I'll look at this tomorrow"
111
+ # "ok" without \b would otherwise match as a false approval).
112
+ if [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(declined|rejected|reject|not approved)\b"; then
111
113
  MESSAGE="🔴🛑 RELEASE DECLINED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
112
114
 
113
- elif [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iq "^approved"; then
115
+ # Matches authorize-deployment.yml's full APPROVE_RE keyword set
116
+ # (approved|approve|ok|go ahead), not just "approved" — it used to
117
+ # miss "ok"/"go ahead"/"approve", so a release could be approved
118
+ # for real with no Telegram alert sent for it.
119
+ elif [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(approved|approve|ok|go ahead)\b"; then
114
120
  MESSAGE="🟢🛡️ RELEASE APPROVED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
115
121
 
116
122
  elif [[ "${{ contains(github.event.issue.labels.*.name || fromJSON('[]'), 'bug') }}" == "true" ]]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-delivery-os",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A GitHub-native Delivery Governance Framework for structured sprint execution, QA review, and collaborative production release control.",
5
5
  "main": "src/install.js",
6
6
  "bin": {
@@ -45,6 +45,9 @@
45
45
  ".github/workflows/auto-assign-qa.yml",
46
46
  ".github/workflows/telegram-issues.yml",
47
47
  ".github/workflows/setup-labels.yml",
48
+ ".github/scripts/authorize-deployment-verdict.js",
49
+ ".github/scripts/auto-close-sprint.js",
50
+ ".github/scripts/sprint-child-creator.js",
48
51
  ".github/ISSUE_TEMPLATE",
49
52
  ".claude/skills/delivery-ops"
50
53
  ],
package/src/install.js CHANGED
@@ -20,6 +20,22 @@ const WORKFLOWS = [
20
20
  'setup-labels',
21
21
  ];
22
22
 
23
+ // Pure logic some of the workflows above require() at runtime from
24
+ // .github/scripts/<name>.js (see .github/workflows/authorize-deployment.yml
25
+ // etc.) — these are required dependencies of those workflows, not optional,
26
+ // so they're always copied alongside them, the same as WORKFLOWS. Also list
27
+ // them explicitly in package.json's "files".
28
+ const SCRIPTS = ['authorize-deployment-verdict', 'auto-close-sprint', 'sprint-child-creator'];
29
+
30
+ // Which workflow requires which script, so `status` can flag a workflow
31
+ // that's present but whose required script is missing (an install that will
32
+ // fail with MODULE_NOT_FOUND the next time that workflow actually runs).
33
+ const REQUIRED_SCRIPT_BY_WORKFLOW = {
34
+ 'authorize-deployment': 'authorize-deployment-verdict',
35
+ 'auto-close-sprint': 'auto-close-sprint',
36
+ 'sprint-child-creator': 'sprint-child-creator',
37
+ };
38
+
23
39
  const LABELS = [
24
40
  ['intake', '0E8A16'],
25
41
  ['bug', 'D93F0B'],
@@ -101,6 +117,44 @@ function fetchLatestVersion(timeoutMs = 3000) {
101
117
  });
102
118
  }
103
119
 
120
+ // Copies each `${name}${ext}` from srcDir to destDir for a fixed list of
121
+ // expected filenames — the shared logic behind copying WORKFLOWS and SCRIPTS
122
+ // (both: a required, always-on set of individually-named files, as opposed
123
+ // to templates, which copies whatever's found in a directory, or the skill,
124
+ // a single optional file). `label` is what's printed for each entry, e.g.
125
+ // `.github/scripts/auto-close-sprint.js` — pass names already including
126
+ // their directory prefix so log lines are self-explanatory on their own.
127
+ function copyManagedFiles(names, ext, srcDir, destDir, { overwrite, dryRun, relDir }) {
128
+ let copied = 0;
129
+ let skipped = 0;
130
+
131
+ for (const name of names) {
132
+ const src = path.join(srcDir, `${name}${ext}`);
133
+ const dest = path.join(destDir, `${name}${ext}`);
134
+ const label = `${relDir}/${name}${ext}`;
135
+
136
+ if (!fs.existsSync(src)) {
137
+ console.log(` Warning: source not found: ${label}`);
138
+ skipped++; // missing source must block a "clean install" claim, not just warn
139
+ continue;
140
+ }
141
+
142
+ if (fs.existsSync(dest) && !overwrite) {
143
+ console.log(` Skipped (exists): ${label}`);
144
+ skipped++;
145
+ } else if (dryRun) {
146
+ console.log(` [dry-run] Would create: ${label}`);
147
+ copied++;
148
+ } else {
149
+ fs.copyFileSync(src, dest);
150
+ console.log(` Created: ${label}`);
151
+ copied++;
152
+ }
153
+ }
154
+
155
+ return { copied, skipped };
156
+ }
157
+
104
158
  function getPackageRoot() {
105
159
  // When installed via npm, __dirname is node_modules/github-delivery-os/src
106
160
  const possibleRoots = [
@@ -129,6 +183,7 @@ function runInstall(options) {
129
183
  const pkgRoot = getPackageRoot();
130
184
  const workflowsSrc = path.join(pkgRoot, '.github', 'workflows');
131
185
  const templatesSrc = path.join(pkgRoot, '.github', 'ISSUE_TEMPLATE');
186
+ const scriptsSrc = path.join(pkgRoot, '.github', 'scripts');
132
187
  const skillSrc = path.join(pkgRoot, SKILL_REL_PATH);
133
188
  const targetAbs = path.resolve(process.cwd(), targetDir);
134
189
 
@@ -151,41 +206,42 @@ function runInstall(options) {
151
206
  // Ensure target structure
152
207
  const workflowsDest = path.join(targetAbs, '.github', 'workflows');
153
208
  const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
209
+ const scriptsDest = path.join(targetAbs, '.github', 'scripts');
154
210
 
155
211
  if (!dryRun) {
156
212
  fs.mkdirSync(workflowsDest, { recursive: true });
157
213
  fs.mkdirSync(templatesDest, { recursive: true });
214
+ fs.mkdirSync(scriptsDest, { recursive: true });
158
215
  }
159
216
 
160
217
  let workflowsCopied = 0;
161
218
  let templatesCopied = 0;
219
+ let scriptsCopied = 0;
162
220
  let skillCopied = 0;
163
- let workflowsSkipped = 0;
164
- let templatesSkipped = 0;
165
- let skillSkipped = 0;
221
+ let workflowsSkipped;
222
+ let scriptsSkipped;
166
223
 
167
- // Copy workflows
168
- for (const wf of WORKFLOWS) {
169
- const src = path.join(workflowsSrc, `${wf}.yml`);
170
- const dest = path.join(workflowsDest, `${wf}.yml`);
171
-
172
- if (!fs.existsSync(src)) {
173
- console.log(` Warning: source not found: ${wf}.yml`);
174
- continue;
175
- }
224
+ // Copy workflows (always on — not optional)
225
+ ({ copied: workflowsCopied, skipped: workflowsSkipped } = copyManagedFiles(
226
+ WORKFLOWS,
227
+ '.yml',
228
+ workflowsSrc,
229
+ workflowsDest,
230
+ { overwrite, dryRun, relDir: '.github/workflows' }
231
+ ));
232
+
233
+ // Copy the scripts the workflows above require() at runtime — required,
234
+ // not optional, so (unlike templates/skill) this always runs too.
235
+ ({ copied: scriptsCopied, skipped: scriptsSkipped } = copyManagedFiles(
236
+ SCRIPTS,
237
+ '.js',
238
+ scriptsSrc,
239
+ scriptsDest,
240
+ { overwrite, dryRun, relDir: '.github/scripts' }
241
+ ));
176
242
 
177
- if (fs.existsSync(dest) && !overwrite) {
178
- console.log(` Skipped (exists): ${wf}.yml`);
179
- workflowsSkipped++;
180
- } else if (dryRun) {
181
- console.log(` [dry-run] Would create: ${wf}.yml`);
182
- workflowsCopied++;
183
- } else {
184
- fs.copyFileSync(src, dest);
185
- console.log(` Created: ${wf}.yml`);
186
- workflowsCopied++;
187
- }
188
- }
243
+ let templatesSkipped = 0;
244
+ let skillSkipped = 0;
189
245
 
190
246
  // Copy templates
191
247
  if (withTemplates && fs.existsSync(templatesSrc)) {
@@ -291,12 +347,28 @@ function runInstall(options) {
291
347
  }
292
348
 
293
349
  // Record what got installed so `status` can report a version and detect
294
- // drift. Only claim a version when the on-disk files actually match it: an
295
- // overwrite, or a fresh install where nothing had to be skipped. A partial
296
- // skip-mode install would leave older file content on disk, so don't
297
- // overwrite a previously recorded (possibly accurate, possibly newer)
298
- // version with a number that isn't actually true on disk yet.
299
- const cleanInstall = overwrite || (workflowsSkipped === 0 && templatesSkipped === 0 && skillSkipped === 0);
350
+ // drift. Only claim a version when the on-disk files actually match it.
351
+ //
352
+ // Two ways this can go wrong, both of which must block the claim:
353
+ // 1. A skip-mode install left older content on disk for something that
354
+ // WAS requested this run (tracked by the *Skipped counters below).
355
+ // 2. An --overwrite run touches only what was explicitly requested
356
+ // (workflows + scripts always; templates/skill only if their flags
357
+ // were passed) — templates or skill already on disk from an earlier
358
+ // install, but not requested this run, are left untouched and stale,
359
+ // even though --overwrite makes every *Skipped counter read 0. Naively
360
+ // trusting `overwrite` alone would then claim the whole install is
361
+ // current when part of it demonstrably wasn't touched.
362
+ const templatesPresentButNotTouched =
363
+ !withTemplates && TEMPLATES.some((t) => fs.existsSync(path.join(templatesDest, t)));
364
+ const skillPresentButNotTouched = !withSkill && fs.existsSync(skillPath(targetAbs));
365
+ const cleanInstall =
366
+ workflowsSkipped === 0 &&
367
+ templatesSkipped === 0 &&
368
+ scriptsSkipped === 0 &&
369
+ skillSkipped === 0 &&
370
+ !templatesPresentButNotTouched &&
371
+ !skillPresentButNotTouched;
300
372
  if (!dryRun && cleanInstall) {
301
373
  const pkgVersion = require(path.join(pkgRoot, 'package.json')).version;
302
374
  writeManifest(targetAbs, pkgVersion);
@@ -305,19 +377,28 @@ function runInstall(options) {
305
377
  // Summary
306
378
  console.log('');
307
379
  if (!dryRun && !cleanInstall) {
308
- console.log(' Note: some files already existed and were skipped, so the recorded');
309
- console.log(' Delivery OS version was not updated. Re-run with --overwrite to sync');
310
- console.log(' all files (and the recorded version) to the latest release.');
380
+ if (templatesPresentButNotTouched || skillPresentButNotTouched) {
381
+ console.log(' Note: previously-installed templates and/or the Claude Code skill exist');
382
+ console.log(' on disk but were not requested this run, so the recorded Delivery OS');
383
+ console.log(' version was not updated. Re-run with --overwrite plus --with-templates');
384
+ console.log(' and/or --with-skill to bring everything (and the recorded version) in sync.');
385
+ } else {
386
+ console.log(' Note: some files already existed and were skipped, so the recorded');
387
+ console.log(' Delivery OS version was not updated. Re-run with --overwrite to sync');
388
+ console.log(' all files (and the recorded version) to the latest release.');
389
+ }
311
390
  console.log('');
312
391
  }
313
- if (workflowsCopied > 0 || templatesCopied > 0 || skillCopied > 0 || labelsCreated > 0) {
392
+ if (workflowsCopied > 0 || templatesCopied > 0 || scriptsCopied > 0 || skillCopied > 0 || labelsCreated > 0) {
314
393
  if (dryRun) {
315
394
  if (workflowsCopied > 0) console.log(`Would install ${workflowsCopied} workflow(s).`);
316
395
  if (templatesCopied > 0) console.log(`Would copy ${templatesCopied} issue template(s).`);
396
+ if (scriptsCopied > 0) console.log(`Would install ${scriptsCopied} supporting script(s).`);
317
397
  if (skillCopied > 0) console.log('Would add the Claude Code delivery-ops skill.');
318
398
  } else {
319
399
  if (workflowsCopied > 0) console.log(`Installed ${workflowsCopied} workflow(s).`);
320
400
  if (templatesCopied > 0) console.log(`Copied ${templatesCopied} issue template(s).`);
401
+ if (scriptsCopied > 0) console.log(`Installed ${scriptsCopied} supporting script(s).`);
321
402
  if (skillCopied > 0) console.log('Added the Claude Code delivery-ops skill.');
322
403
  if (labelsCreated > 0) console.log(`Created ${labelsCreated} label(s).`);
323
404
  }
@@ -380,7 +461,18 @@ async function runStatus(options) {
380
461
  );
381
462
  const skillInstalled = fs.existsSync(skillPath(targetAbs));
382
463
 
383
- if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
464
+ // A workflow can be present while the script it require()s at runtime is
465
+ // not — e.g. an install from before this check existed, or a manual
466
+ // partial copy. That workflow will fail (MODULE_NOT_FOUND) the next time
467
+ // it actually runs, silently, since nothing here executes the workflow
468
+ // itself to notice.
469
+ const brokenWorkflows = installedWorkflows.filter((wf) => {
470
+ const requiredScript = REQUIRED_SCRIPT_BY_WORKFLOW[wf];
471
+ if (!requiredScript) return false;
472
+ return !fs.existsSync(path.join(targetAbs, '.github', 'scripts', `${requiredScript}.js`));
473
+ });
474
+
475
+ if (installedWorkflows.length > 0 || installedTemplates.length > 0 || skillInstalled) {
384
476
  const manifest = readManifest(targetAbs);
385
477
  if (manifest && manifest.version) {
386
478
  const installedOn = manifest.installedAt ? ` (installed ${manifest.installedAt.slice(0, 10)})` : '';
@@ -411,6 +503,17 @@ async function runStatus(options) {
411
503
  installedWorkflows.forEach((wf) => console.log(` ✓ ${wf}.yml`));
412
504
  console.log('');
413
505
  }
506
+
507
+ if (brokenWorkflows.length > 0) {
508
+ console.log('⚠️ Broken install detected:');
509
+ brokenWorkflows.forEach((wf) => {
510
+ console.log(` ${wf}.yml requires .github/scripts/${REQUIRED_SCRIPT_BY_WORKFLOW[wf]}.js, which is missing.`);
511
+ });
512
+ console.log(' That workflow will fail with MODULE_NOT_FOUND the next time it runs.');
513
+ console.log(' Fix: npx github-delivery-os@latest install --overwrite .');
514
+ console.log('');
515
+ }
516
+
414
517
  if (installedTemplates.length > 0) {
415
518
  console.log('Templates:');
416
519
  installedTemplates.forEach((t) => console.log(` ✓ ${t}`));
@@ -424,7 +527,7 @@ async function runStatus(options) {
424
527
  console.log('');
425
528
  }
426
529
 
427
- if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
530
+ if (installedWorkflows.length > 0 || installedTemplates.length > 0 || skillInstalled) {
428
531
  console.log('Claude Code skill:');
429
532
  console.log(
430
533
  skillInstalled
@@ -434,7 +537,7 @@ async function runStatus(options) {
434
537
  console.log('');
435
538
  }
436
539
 
437
- if (installedWorkflows.length === 0 && installedTemplates.length === 0) {
540
+ if (installedWorkflows.length === 0 && installedTemplates.length === 0 && !skillInstalled) {
438
541
  console.log('Delivery OS is not installed in this repository.');
439
542
  console.log('Run: npx github-delivery-os install --with-templates .');
440
543
  } else {
@@ -450,6 +553,7 @@ function runUninstall(options) {
450
553
  const targetAbs = path.resolve(process.cwd(), targetDir);
451
554
  const workflowsDest = path.join(targetAbs, '.github', 'workflows');
452
555
  const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
556
+ const scriptsDest = path.join(targetAbs, '.github', 'scripts');
453
557
 
454
558
  console.log('=== GitHub Delivery Operating System — Uninstall ===');
455
559
  console.log(`Target: ${targetAbs}`);
@@ -458,6 +562,7 @@ function runUninstall(options) {
458
562
 
459
563
  let workflowsRemoved = 0;
460
564
  let templatesRemoved = 0;
565
+ let scriptsRemoved = 0;
461
566
 
462
567
  for (const wf of WORKFLOWS) {
463
568
  const dest = path.join(workflowsDest, `${wf}.yml`);
@@ -472,6 +577,21 @@ function runUninstall(options) {
472
577
  }
473
578
  }
474
579
 
580
+ // Scripts are a required dependency of the workflows above, not optional,
581
+ // so (like workflows) they're always removed, not gated behind a flag.
582
+ for (const name of SCRIPTS) {
583
+ const dest = path.join(scriptsDest, `${name}.js`);
584
+ if (fs.existsSync(dest)) {
585
+ if (dryRun) {
586
+ console.log(` [dry-run] Would remove: .github/scripts/${name}.js`);
587
+ } else {
588
+ fs.unlinkSync(dest);
589
+ console.log(` Removed: .github/scripts/${name}.js`);
590
+ }
591
+ scriptsRemoved++;
592
+ }
593
+ }
594
+
475
595
  if (withTemplates) {
476
596
  for (const t of TEMPLATES) {
477
597
  const dest = path.join(templatesDest, t);
@@ -501,11 +621,25 @@ function runUninstall(options) {
501
621
  }
502
622
  }
503
623
 
504
- // Remove the version manifest too — it has no meaning once Delivery OS is
505
- // gone, and leaving it behind would make a later install/status think a
506
- // stale version is still installed.
624
+ // Remove the version manifest too — but only once nothing Delivery-OS-
625
+ // related actually remains. Templates and the skill are kept by default
626
+ // (only removed with their own flags), and if they're still on disk, the
627
+ // manifest's version is still meaningful for them — deleting it would make
628
+ // a later `status` report "unknown version" for files that are, in fact,
629
+ // still fully present and version-tracked.
630
+ const anyWorkflowsRemain = WORKFLOWS.some((wf) => fs.existsSync(path.join(workflowsDest, `${wf}.yml`)));
631
+ const anyTemplatesRemain = TEMPLATES.some((t) => fs.existsSync(path.join(templatesDest, t)));
632
+ // Scripts are removed unconditionally just above, so this is normally
633
+ // always false by the time we get here — included anyway for the same
634
+ // reason the other three are checked explicitly rather than assumed:
635
+ // defensive completeness against a future change (e.g. a failed unlink,
636
+ // or script removal ever becoming flag-gated like templates/skill).
637
+ const anyScriptsRemain = SCRIPTS.some((name) => fs.existsSync(path.join(scriptsDest, `${name}.js`)));
638
+ const skillRemains = fs.existsSync(skillPath(targetAbs));
639
+ const nothingLeft = !anyWorkflowsRemain && !anyTemplatesRemain && !anyScriptsRemain && !skillRemains;
640
+
507
641
  const manifestDest = manifestPath(targetAbs);
508
- if (fs.existsSync(manifestDest)) {
642
+ if (nothingLeft && fs.existsSync(manifestDest)) {
509
643
  if (dryRun) {
510
644
  console.log(' [dry-run] Would remove: delivery-os.json');
511
645
  } else {
@@ -528,6 +662,9 @@ function runUninstall(options) {
528
662
  if (!withSkill) {
529
663
  console.log('Claude Code skill (if installed) was kept. Re-run with --with-skill to remove it.');
530
664
  }
665
+ if (!nothingLeft) {
666
+ console.log('delivery-os.json was kept — something Delivery-OS-related is still on disk.');
667
+ }
531
668
  }
532
669
  } else {
533
670
  console.log('No Delivery OS files found to remove.');
@@ -550,5 +687,7 @@ module.exports = {
550
687
  SKILL_REL_PATH,
551
688
  WORKFLOWS,
552
689
  TEMPLATES,
690
+ SCRIPTS,
691
+ REQUIRED_SCRIPT_BY_WORKFLOW,
553
692
  },
554
693
  };