@rashidee/co2 1.3.17 → 1.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/index.js +75 -26
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/marketplace.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/skills/util-gencicdscript/SKILL.md +18 -23
  6. package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +176 -141
  7. package/plugin/skills/util-plancicd/SKILL.md +319 -312
  8. package/static/assets/{abnfDiagram-VRR7QNED-kjS3baPt.js → abnfDiagram-VRR7QNED-cQAiKjYG.js} +1 -1
  9. package/static/assets/{arc-CXd0xnDj.js → arc-C4ZlEQ3S.js} +1 -1
  10. package/static/assets/{architectureDiagram-ZJ3FMSHR-5QfnAz_Q.js → architectureDiagram-ZJ3FMSHR-C_31NGnA.js} +1 -1
  11. package/static/assets/{blockDiagram-677ZJIJ3-ByICFNDb.js → blockDiagram-677ZJIJ3-DDQQ2PAJ.js} +1 -1
  12. package/static/assets/{c4Diagram-LMCZKHZV-C5a0Ubby.js → c4Diagram-LMCZKHZV-BPbCZxQJ.js} +1 -1
  13. package/static/assets/channel-CufY_jLp.js +1 -0
  14. package/static/assets/{chunk-2Q5K7J3B-UucADWxr.js → chunk-2Q5K7J3B-B3LoONkc.js} +1 -1
  15. package/static/assets/{chunk-32BRIVSS-WAe17d3N.js → chunk-32BRIVSS-Buim75a9.js} +1 -1
  16. package/static/assets/{chunk-5VM5RSS4-BQBMOb0f.js → chunk-5VM5RSS4-BjHqxxbw.js} +1 -1
  17. package/static/assets/{chunk-EX3LRPZG-BzaET0n4.js → chunk-EX3LRPZG-Dn0_qUTW.js} +1 -1
  18. package/static/assets/{chunk-JWPE2WC7-D_tBvbZg.js → chunk-JWPE2WC7-DpYEF0BV.js} +1 -1
  19. package/static/assets/{chunk-MOJQB5TN-CsYb9JBc.js → chunk-MOJQB5TN-CbNDWijQ.js} +1 -1
  20. package/static/assets/{chunk-RYQCIY6F-D5xRlnc7.js → chunk-RYQCIY6F-DOhXYkGG.js} +1 -1
  21. package/static/assets/{chunk-V7JOEXUC-y0cEgq6c.js → chunk-V7JOEXUC-CfASUuSJ.js} +1 -1
  22. package/static/assets/{chunk-VR4S4FIN-CSXJHPXT.js → chunk-VR4S4FIN-vgVNnFtp.js} +1 -1
  23. package/static/assets/{chunk-XXDRQBXY-QFNFGLYZ.js → chunk-XXDRQBXY-CVjoYUTB.js} +1 -1
  24. package/static/assets/classDiagram-OUVF2IWQ-CVQD_QZa.js +1 -0
  25. package/static/assets/classDiagram-v2-EOCWNBFH-CVQD_QZa.js +1 -0
  26. package/static/assets/{cose-bilkent-JH36ORCC-ValIntpZ.js → cose-bilkent-JH36ORCC-EqjuuBpY.js} +1 -1
  27. package/static/assets/{cynefin-VYW2F7L2-BgHVKyAR.js → cynefin-VYW2F7L2-DioyO_Cm.js} +1 -1
  28. package/static/assets/{cynefinDiagram-TSTJHNR4-LIZzpkLM.js → cynefinDiagram-TSTJHNR4-CuPa5A8w.js} +1 -1
  29. package/static/assets/{dagre-VKFMJZFB-DY9b36A5.js → dagre-VKFMJZFB-MIf1Dtbk.js} +1 -1
  30. package/static/assets/{diagram-FQU43EPY-aeuYUUxe.js → diagram-FQU43EPY-5OBJqg6R.js} +1 -1
  31. package/static/assets/{diagram-G47NLZAW-BWwcYYCF.js → diagram-G47NLZAW-B2ZGPKp3.js} +1 -1
  32. package/static/assets/{diagram-NH7WQ7WH-BObFlRPR.js → diagram-NH7WQ7WH-B5YEN-B-.js} +1 -1
  33. package/static/assets/{diagram-OA4YK3LP-BUnc8m1-.js → diagram-OA4YK3LP-BXg_CQ2m.js} +1 -1
  34. package/static/assets/{diagram-WEI45ONY-DsNxBIuJ.js → diagram-WEI45ONY-DVbqehF6.js} +1 -1
  35. package/static/assets/{ebnfDiagram-CCIWWBDH-DDL2Id1r.js → ebnfDiagram-CCIWWBDH-hmnXWJuZ.js} +1 -1
  36. package/static/assets/{erDiagram-Q63AITRT-DZZcG2QU.js → erDiagram-Q63AITRT-CLYATrvi.js} +1 -1
  37. package/static/assets/{flowDiagram-23GEKE2U-C6GvkBFb.js → flowDiagram-23GEKE2U-cs1YvuVH.js} +1 -1
  38. package/static/assets/{ganttDiagram-NO4QXBWP-C7ydCTnF.js → ganttDiagram-NO4QXBWP-CH5rfioY.js} +1 -1
  39. package/static/assets/{gitGraphDiagram-IHSO6WYX-C60P6bwf.js → gitGraphDiagram-IHSO6WYX-DjAOB5L7.js} +1 -1
  40. package/static/assets/{index-CfGMfAGO.js → index-B4Y8oBUt.js} +4 -4
  41. package/static/assets/{infoDiagram-FWYZ7A6U-CnH7Q2nX.js → infoDiagram-FWYZ7A6U-De2-PVxo.js} +1 -1
  42. package/static/assets/{ishikawaDiagram-FXEZZL3T-BNEvOQYX.js → ishikawaDiagram-FXEZZL3T-CDikxFR6.js} +1 -1
  43. package/static/assets/{journeyDiagram-5HDEW3XC-BOwGZTfX.js → journeyDiagram-5HDEW3XC-Bo6isp1w.js} +1 -1
  44. package/static/assets/{kanban-definition-HUTT4EX6-BEfHVl-l.js → kanban-definition-HUTT4EX6-jM1azdC7.js} +1 -1
  45. package/static/assets/{linear-BOHPNxBi.js → linear-CUk0_yzf.js} +1 -1
  46. package/static/assets/{mindmap-definition-LN4V7U3C-B84wfT_w.js → mindmap-definition-LN4V7U3C-l-cgg6PS.js} +1 -1
  47. package/static/assets/{pegDiagram-2B236MQR-BgEa7yRL.js → pegDiagram-2B236MQR-B-8nGU3G.js} +1 -1
  48. package/static/assets/{pieDiagram-ENE6RG2P-DgA9LQlc.js → pieDiagram-ENE6RG2P-Di1V86Ag.js} +1 -1
  49. package/static/assets/{quadrantDiagram-ABIIQ3AL-C5K_r0Mx.js → quadrantDiagram-ABIIQ3AL-BknVxapP.js} +1 -1
  50. package/static/assets/{railroadDiagram-RFXS5EU6-Ckh_mLXW.js → railroadDiagram-RFXS5EU6-D7Vohrlg.js} +1 -1
  51. package/static/assets/{requirementDiagram-TGXJPOKE-CtII7Obm.js → requirementDiagram-TGXJPOKE-BlFXj6Li.js} +1 -1
  52. package/static/assets/{sankeyDiagram-HTMAVEWB-DdkySwbL.js → sankeyDiagram-HTMAVEWB-Ck_A1wxA.js} +1 -1
  53. package/static/assets/{sequenceDiagram-DBY2YBRQ-DEIBg3kY.js → sequenceDiagram-DBY2YBRQ-D4mu07Ol.js} +1 -1
  54. package/static/assets/{sizeCapture-X5ZJPWSS-D2MM9f6M.js → sizeCapture-X5ZJPWSS-BwempBoZ.js} +1 -1
  55. package/static/assets/{stateDiagram-2N3HPSRC-DHmJsAte.js → stateDiagram-2N3HPSRC-DSh1YMwZ.js} +1 -1
  56. package/static/assets/stateDiagram-v2-6OUMAXLB-DrMkFhr2.js +1 -0
  57. package/static/assets/{swimlanes-5IMT3BWC-BBPvUQvZ.js → swimlanes-5IMT3BWC-4EE1KLA5.js} +2 -2
  58. package/static/assets/swimlanesDiagram-G3AALYLV-DoZQ3XaY.js +8 -0
  59. package/static/assets/{timeline-definition-FHXFAJF6-CG_Q0YTD.js → timeline-definition-FHXFAJF6-DVZepywq.js} +1 -1
  60. package/static/assets/{vennDiagram-L72KCM5P-DlFSe4is.js → vennDiagram-L72KCM5P-YqnYvNJT.js} +1 -1
  61. package/static/assets/{wardleyDiagram-EHGQE667-BTlvtp2f.js → wardleyDiagram-EHGQE667-Dc6PvPZE.js} +1 -1
  62. package/static/assets/{xychartDiagram-FW5EYKEG-Bm14zLxj.js → xychartDiagram-FW5EYKEG-DLbY5Kg1.js} +1 -1
  63. package/static/index.html +1 -1
  64. package/static/assets/channel-CrYjYK1f.js +0 -1
  65. package/static/assets/classDiagram-OUVF2IWQ-DoLCCqXD.js +0 -1
  66. package/static/assets/classDiagram-v2-EOCWNBFH-DoLCCqXD.js +0 -1
  67. package/static/assets/stateDiagram-v2-6OUMAXLB-B6sumoG6.js +0 -1
  68. package/static/assets/swimlanesDiagram-G3AALYLV-DH2ts1oT.js +0 -8
@@ -41,6 +41,11 @@ const PORT = Number(process.env.PORT || loadConfig().port || 5000)
41
41
  const STUDIO = process.env.CO2_STUDIO_URL || 'http://127.0.0.1:3001'
42
42
  const PROJECT_ID = process.env.CO2_PROJECT_ID || ''
43
43
  const TOKEN = process.env.CO2_GUARD_TOKEN || ''
44
+ // Defect 1 — the build id stamped into cicd.config.json at generation. Pinned at boot; when a
45
+ // regeneration rewrites the config with a newer value while this process keeps running, /api/state
46
+ // reports the drift and the page shows a "running older code — restart" banner. Missing (older
47
+ // configs) → null → never a false positive.
48
+ const BOOT_GENERATED_AT = loadConfig().generatedAt || null
44
49
 
45
50
  mkdirSync(LOG_DIR, { recursive: true })
46
51
  const running = new Map() // app name -> ChildProcess (only processes THIS app spawned)
@@ -63,30 +68,60 @@ function currentBranch() {
63
68
 
64
69
  // ---- Develop gate (Compound Context Studio) --------------------------------------------
65
70
  // An application that never completed a conductor-develop run has nothing to build or run —
66
- // its tab controls are disabled. Refreshed every 30 s; the last known answer is kept when the
67
- // studio is briefly unreachable, and `null` (never fetched) disables controls with a hint.
68
- let guardApps = null // Map lower(name|folder) -> developedOnce
69
- let guardVersions = [] // project versions (semver strings, studio order — latest first)
71
+ // its tab controls are disabled. The studio build guard is authoritative; when it is
72
+ // unreachable we fall back to the on-disk develop tracking file, and ONLY a definitive
73
+ // "never developed" (false) disables controls — unknown fails OPEN (Defect 2c/4).
74
+ //
75
+ // Identity differs across the two sides: the studio keys by the CLAUDE.md application name
76
+ // (e.g. `skolafund-web`), while the plan/config carry a display name (`SkolaFund Web`) and a
77
+ // folder (`skolafund_web`). Match on a separator-insensitive key so all three collapse to the
78
+ // same value (Defect 2b) — exact-lowercase matching silently missed every app.
79
+ function norm(s) { return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '') }
80
+ let guardApps = null // Map norm(name) -> developedOnce (last good answer, kept across blips)
81
+ // ok | unauthorized | unreachable | http-<code> | not-configured | init — surfaced in the notice
82
+ // so a disabled/unknown control says WHAT actually happened instead of a hard-coded guess.
83
+ let guardStatus = PROJECT_ID ? 'init' : 'not-configured'
70
84
  async function refreshGuard() {
71
- if (!PROJECT_ID) return
85
+ if (!PROJECT_ID) { guardStatus = 'not-configured'; return }
72
86
  try {
73
87
  const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
74
- if (!r.ok) return
88
+ if (r.status === 401) { guardStatus = 'unauthorized'; return } // keep last good guardApps
89
+ if (!r.ok) { guardStatus = 'http-' + r.status; return }
75
90
  const g = await r.json()
76
91
  if (Array.isArray(g.applications)) {
77
- guardApps = new Map()
78
- for (const a of g.applications) guardApps.set(String(a.name).toLowerCase(), !!a.developedOnce)
92
+ const next = new Map()
93
+ for (const a of g.applications) next.set(norm(a.name), !!a.developedOnce)
94
+ guardApps = next
95
+ guardStatus = 'ok'
79
96
  }
80
- // The topbar version selector's choices the {VERSION} tag substituted into commands.
81
- if (Array.isArray(g.versions)) guardVersions = g.versions.map((v) => String(v.semver)).filter(Boolean)
82
- } catch {}
97
+ } catch { guardStatus = 'unreachable' } // keep last good guardApps
98
+ }
99
+ // On-disk fallback (Defect 2 fix 3) — conductor-feature-develop writes this app's
100
+ // context/develop/IMPLEMENTATION_MASTER.md with `**Status**: COMPLETED` when development
101
+ // finishes. Read it (mtime-cached) so the gate is correct even with the studio down, restarted
102
+ // or renamed. Mirrors the studio's TRACKING_FILE_MAP.develop.
103
+ const developMarkerCache = new Map() // abs path -> { mtimeMs, done }
104
+ function localDeveloped(app) {
105
+ const p = join(PROJECT_DIR, app.folder || '', 'context', 'develop', 'IMPLEMENTATION_MASTER.md')
106
+ try {
107
+ const mtimeMs = statSync(p).mtimeMs
108
+ const hit = developMarkerCache.get(p)
109
+ if (hit && hit.mtimeMs === mtimeMs) return hit.done
110
+ const done = /^\s*\**\s*Status\s*\**\s*:\s*COMPLETED\b/im.test(readFileSync(p, 'utf8'))
111
+ developMarkerCache.set(p, { mtimeMs, done })
112
+ return done
113
+ } catch { return false }
83
114
  }
84
- function developedOnce(app) {
85
- if (!guardApps) return null // unknown controls stay disabled until the studio answers
86
- const byName = guardApps.get(app.name.toLowerCase())
87
- if (byName !== undefined) return byName
88
- const byFolder = guardApps.get(String(app.folder || '').toLowerCase())
89
- return byFolder === undefined ? null : byFolder
115
+ // { val: true|false|null, src: 'studio'|'local'|'none' }. Studio wins; local marker fills the gap
116
+ // when the studio has no answer; null only when nobody knows (controls then stay ENABLED).
117
+ function developInfo(app) {
118
+ if (guardApps) {
119
+ let v = guardApps.get(norm(app.name))
120
+ if (v === undefined) v = guardApps.get(norm(app.folder))
121
+ if (v !== undefined) return { val: v, src: 'studio' }
122
+ }
123
+ if (localDeveloped(app)) return { val: true, src: 'local' }
124
+ return { val: null, src: 'none' }
90
125
  }
91
126
  refreshGuard()
92
127
  setInterval(refreshGuard, 30_000).unref?.()
@@ -143,7 +178,11 @@ function runLogged(app, command, onExit) {
143
178
  const log = createWriteStream(logPath(app.name), { flags: 'a' })
144
179
  log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
145
180
  broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
146
- const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env })
181
+ // detached on POSIX so the shell AND everything it forks (a compound `a && b` start command,
182
+ // pnpm → vite, etc.) share ONE process group we can signal as a unit on Stop. Without it, `sh`
183
+ // running `a && b` cannot exec into `b`, so killing the shell alone leaves the real server
184
+ // reparented to init and still holding the port (Defect 3). Windows kills the tree via taskkill.
185
+ const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env, detached: process.platform !== 'win32' })
147
186
  const tap = (d) => { log.write(d); broadcast(app.name, d) }
148
187
  child.stdout.on('data', tap)
149
188
  child.stderr.on('data', tap)
@@ -229,13 +268,6 @@ function tcpProbe(host, port, timeout = 1000) {
229
268
  })
230
269
  }
231
270
 
232
- // {VERSION} token substitution — the version comes from the page's topbar selector (the
233
- // studio guard's versions, latest preselected). Tag-only: never checks out code. The `v`
234
- // prefix is stripped so docker tags read `1.2.0`, not `v1.2.0`.
235
- function subst(cmd, version) {
236
- return String(cmd || '').replaceAll('{VERSION}', String(version || 'latest').replace(/^v/, ''))
237
- }
238
-
239
271
  // Managed Dockerfile from the plan's ## Dockerfiles — written before Build only when the
240
272
  // application folder has none (an existing Dockerfile is never overwritten).
241
273
  function ensureDockerfile(app) {
@@ -249,38 +281,37 @@ function ensureDockerfile(app) {
249
281
  } catch (e) { logLine(app, `dockerfile: FAILED to write — ${e.message}`) }
250
282
  }
251
283
 
252
- function buildApp(app, version, onDone) {
253
- if (activity.has(app.name)) { onDone && onDone(); return }
284
+ function buildApp(app) {
285
+ if (activity.has(app.name)) return
254
286
  activity.set(app.name, 'building'); lastError.delete(app.name)
255
287
  ensureConfigs(app)
256
288
  ensureDockerfile(app)
257
- runLogged(app, subst(app.build, version), (code) => {
289
+ runLogged(app, app.build, (code) => {
258
290
  activity.delete(app.name)
259
291
  if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
260
292
  else logLine(app, 'build OK')
261
- onDone && onDone()
262
293
  })
263
294
  }
264
295
 
265
- function deployApp(app, version) {
296
+ function deployApp(app) {
266
297
  if (activity.has(app.name) || !app.deploy) return
267
298
  activity.set(app.name, 'deploying'); lastError.delete(app.name)
268
- runLogged(app, subst(app.deploy, version), (code) => {
299
+ runLogged(app, app.deploy, (code) => {
269
300
  activity.delete(app.name)
270
301
  if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
271
302
  else logLine(app, `deploy OK (${app.deployMethod})`)
272
303
  })
273
304
  }
274
305
 
275
- function startApp(app, version) {
306
+ function startApp(app) {
276
307
  if (running.get(app.name) || activity.has(app.name)) return
277
308
  failed.delete(app.name); lastError.delete(app.name)
278
309
  ensureConfigs(app)
279
310
  if (app.method === 'container') {
280
- runLogged(app, subst(app.start, version), () => {})
311
+ runLogged(app, app.start, () => {})
281
312
  saveState(); return
282
313
  }
283
- const child = runLogged(app, subst(app.start, version), (code) => {
314
+ const child = runLogged(app, app.start, (code) => {
284
315
  running.delete(app.name)
285
316
  // A user-requested Stop exits nonzero (killed) — that is not a failure.
286
317
  if (code !== 0 && !stopping.delete(app.name)) {
@@ -293,20 +324,27 @@ function startApp(app, version) {
293
324
  saveState()
294
325
  }
295
326
 
296
- // shell:true spawns a shell whose grandchildren survive child.kill() on Windows — kill the
297
- // whole tree there (taskkill /T); plain kill elsewhere.
298
- function killTree(child) {
299
- if (process.platform === 'win32' && child.pid) {
300
- try { execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
301
- } else {
302
- try { child.kill() } catch {}
327
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms))
328
+ // A signal-0 probe: alive if it succeeds OR fails with EPERM (exists, not ours to signal).
329
+ function pidAlive(pid) { try { process.kill(Number(pid), 0); return true } catch (e) { return e.code === 'EPERM' } }
330
+
331
+ // shell:true (and a compound start command) spawns a tree whose grandchildren survive a bare
332
+ // child.kill(). Signal the whole PROCESS GROUP (detached → pgid === the shell's pid), escalating
333
+ // SIGTERM → SIGKILL. Windows: taskkill /T /F kills the tree. Async — the caller awaits it.
334
+ async function killTree(child) {
335
+ const pid = child && child.pid
336
+ if (!pid) return
337
+ if (process.platform === 'win32') {
338
+ try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
339
+ return
303
340
  }
341
+ try { process.kill(-pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch {} }
342
+ for (let i = 0; i < 40 && pidAlive(pid); i++) await delay(100) // up to ~4s for a graceful exit
343
+ if (pidAlive(pid)) { try { process.kill(-pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch {} } }
304
344
  }
305
345
 
306
- // Stop an app we DON'T hold a child handle for (started outside this app, or before this app
307
- // last restarted) by killing whatever process tree is listening on its port — so Stop works
308
- // for any running app, not only ones this CI/CD app spawned. Returns true if a PID was killed.
309
- function killByPort(port) {
346
+ // PIDs listening on a TCP port. POSIX: lsof, then fuser both tolerated absent. Windows: netstat.
347
+ function portListeners(port) {
310
348
  try {
311
349
  if (process.platform === 'win32') {
312
350
  const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
@@ -315,36 +353,60 @@ function killByPort(port) {
315
353
  const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
316
354
  if (m && Number(m[1]) === Number(port)) pids.add(m[2])
317
355
  }
318
- for (const pid of pids) {
319
- try { execFileSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'ignore' }) } catch {}
320
- }
321
- return pids.size > 0
356
+ return [...pids]
322
357
  }
323
- const out = execFileSync('sh', ['-c', `lsof -ti tcp:${port} || true`], { encoding: 'utf8' })
324
- const pids = out.split(/\s+/).filter(Boolean)
325
- for (const pid of pids) { try { process.kill(Number(pid)) } catch {} }
326
- return pids.length > 0
327
- } catch { return false }
358
+ for (const cmd of [`lsof -ti tcp:${port}`, `fuser -n tcp ${port} 2>/dev/null`]) {
359
+ const out = execFileSync('sh', ['-c', `${cmd} || true`], { encoding: 'utf8' })
360
+ const pids = [...new Set(out.split(/\s+/).map((s) => s.trim()).filter((s) => /^\d+$/.test(s)))]
361
+ if (pids.length) return pids
362
+ }
363
+ } catch {}
364
+ return []
365
+ }
366
+ // Signal a pid's whole process group (falls back to the bare pid if the group is already gone).
367
+ function killGroup(pid, sig) {
368
+ let pgid = 0
369
+ if (process.platform !== 'win32') {
370
+ try { pgid = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim()) } catch {}
371
+ }
372
+ try { process.kill(pgid ? -pgid : Number(pid), sig) } catch { try { process.kill(Number(pid), sig) } catch {} }
373
+ }
374
+
375
+ // Stop whatever is listening on a port even when we hold no child handle for it (started outside
376
+ // this app, or before this app last restarted) — group-kill each listener, escalating
377
+ // SIGTERM → SIGKILL. Returns true when the port ended up FREE.
378
+ async function killByPort(port) {
379
+ let pids = portListeners(port)
380
+ if (!pids.length) return true // nothing listening — already free
381
+ if (process.platform === 'win32') {
382
+ for (const pid of pids) { try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) } catch {} }
383
+ for (let i = 0; i < 20 && portListeners(port).length; i++) await delay(100)
384
+ return portListeners(port).length === 0
385
+ }
386
+ for (const pid of pids) killGroup(pid, 'SIGTERM')
387
+ for (let i = 0; i < 30 && portListeners(port).length; i++) await delay(100) // ~3s grace
388
+ for (const pid of portListeners(port)) killGroup(pid, 'SIGKILL')
389
+ await delay(200)
390
+ return portListeners(port).length === 0
328
391
  }
329
392
 
330
- function stopApp(app, version) {
393
+ async function stopApp(app) {
331
394
  if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
332
- const stopCmd = subst(app.stop, version)
395
+ const stopCmd = app.stop
333
396
  try { execFileSync(stopCmd.split(' ')[0], stopCmd.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
334
- } else {
335
- const child = running.get(app.name)
336
- if (child) {
337
- stopping.add(app.name)
338
- killTree(child)
339
- } else {
340
- // No child handle — kill by port so an externally/previously started app still stops.
341
- lastError.delete(app.name)
342
- if (!killByPort(app.port)) {
343
- lastError.set(app.name, `could not stop — nothing found listening on port ${app.port}`)
344
- }
345
- }
397
+ running.delete(app.name)
398
+ saveState()
399
+ return
346
400
  }
401
+ lastError.delete(app.name)
402
+ const child = running.get(app.name)
403
+ if (child) { stopping.add(app.name); await killTree(child) }
347
404
  running.delete(app.name)
405
+ // ALWAYS sweep the port after killing our handle — the child dying is NOT proof the server did
406
+ // (its reparented grandchildren, or an app started outside this app, can still hold the port).
407
+ // Report an honest failure if it never frees, instead of the old silent "success" (Defect 3).
408
+ const free = await killByPort(app.port)
409
+ if (!free) lastError.set(app.name, `stop failed — port ${app.port} is still in use`)
348
410
  saveState()
349
411
  }
350
412
 
@@ -368,10 +430,10 @@ body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;
368
430
  .badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
369
431
  .url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
370
432
  button{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer}
371
- select{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:3px 6px;font-size:12px}
372
433
  button:hover:enabled{background:#eef1f5}
373
434
  button:disabled{opacity:.4;cursor:default}
374
435
  .notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
436
+ .stale{padding:6px 12px;font-size:12px;color:#fff;background:#c23b3b;font-weight:600;display:none}
375
437
  .err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
376
438
  #log{flex:1;margin:0;padding:10px 12px;background:#0f1216;color:#d7dde4;font-size:11px;line-height:1.5;overflow:auto;white-space:pre-wrap}
377
439
  .muted{color:#5b6572;font-size:12px}
@@ -389,11 +451,8 @@ button:disabled{opacity:.4;cursor:default}
389
451
  .cfg pre{background:#0f1216;color:#d7dde4;font-size:11px;padding:8px 10px;border-radius:6px;overflow:auto;max-height:320px;white-space:pre-wrap;margin:4px 0 0}
390
452
  </style></head><body>
391
453
  <div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span>
392
- <span class="meta">version</span><select id="ver" title="Tag substituted into {VERSION} in commands (docker image tag)"><option value="">latest</option></select>
393
- <button onclick="actAll('build')" title="Build every eligible application, one at a time">Build All</button>
394
- <button onclick="actAll('start')" title="Start every eligible stopped application">Start All</button>
395
- <button onclick="actAll('stop')" title="Stop every running application">Stop All</button>
396
454
  <span style="flex:1"></span><a id="infralink" class="url" href="infra" target="_blank" rel="noreferrer" style="display:none">Infrastructure ↗</a></div>
455
+ <div class="stale" id="stale"></div>
397
456
  <div class="tabs" id="tabs"></div>
398
457
  <div class="bar" id="bar"></div>
399
458
  <div class="notice" id="notice"></div>
@@ -419,11 +478,19 @@ function renderTabs(){
419
478
  <span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
420
479
  }
421
480
 
481
+ // A guard answer we could not get, said plainly — the notice names the cause it observed instead
482
+ // of always blaming an unreachable studio (Defect 2c/4).
483
+ function guardReason(s){
484
+ return ({unauthorized:'studio auth failed',unreachable:'studio unreachable','not-configured':'studio link not set',init:'checking…',ok:'not listed by the studio'})[s]
485
+ || ('studio error '+String(s||'').replace(/^http-/,''))
486
+ }
422
487
  function renderBar(){
423
488
  const a = appOf(active)
424
489
  if (!a) return
425
490
  const url = 'http://'+location.hostname+':'+a.port+'/'
426
- const gate = a.developedOnce === true
491
+ // Fail OPEN — only a definitive "never developed" (false) disables controls; unknown (null)
492
+ // leaves them enabled so an unverifiable studio never locks the user out (Defect 2c/4).
493
+ const gate = a.developedOnce !== false
427
494
  const busy = a.phase==='building'||a.phase==='deploying'
428
495
  document.getElementById('bar').innerHTML = \`
429
496
  <span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
@@ -431,7 +498,7 @@ function renderBar(){
431
498
  <a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
432
499
  <span style="flex:1"></span>
433
500
  <button onclick="openConfig()" title="Show this application's runtime configuration files">Config</button>
434
- <button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''} title="\${a.image?'Builds the app AND the docker image '+a.image+':<version>':'Runs the plan build command'}">Build</button>
501
+ <button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''} title="\${a.image?'Builds the app AND the docker image '+a.image+':latest':'Runs the plan build command'}">Build</button>
435
502
  <button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
436
503
  <button onclick="act('stop')" \${a.phase!=='running'?'disabled':''}>Stop</button>
437
504
  \${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
@@ -440,7 +507,10 @@ function renderBar(){
440
507
  notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
441
508
  notice.style.display = 'block'
442
509
  } else if (a.developedOnce === null) {
443
- notice.textContent = 'Develop status not verified yet (studio unreachable) — controls stay disabled.'
510
+ notice.textContent = 'Develop status unverified (' + guardReason(state.guardStatus) + ') — controls left enabled.'
511
+ notice.style.display = 'block'
512
+ } else if (a.developSource === 'local') {
513
+ notice.textContent = 'Develop status read from this app’s local tracking file (studio guard unavailable).'
444
514
  notice.style.display = 'block'
445
515
  } else notice.style.display = 'none'
446
516
  const err = document.getElementById('err')
@@ -461,7 +531,10 @@ function setActive(name){
461
531
  function openWs(name){
462
532
  // Path-relative + scheme-aware so the stream works direct (:5000/) AND behind the studio's
463
533
  // /api/cicd/<id>/app-proxy/ prefix (which tunnels the upgrade), also under HTTPS.
464
- ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+location.pathname.replace(/\/$/,'')+'/ws?app='+encodeURIComponent(name))
534
+ // NO backslash/regex here on purpose: this string is inside server.js's PAGE template literal,
535
+ // where a client-side regex like /\/$/ cooks to //$/ (a line comment) and kills the script.
536
+ var base = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname
537
+ ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+base+'/ws?app='+encodeURIComponent(name))
465
538
  ws.onmessage = e => appendLog(e.data)
466
539
  ws.onclose = () => { ws = null; if (active === name) wsTimer = setTimeout(() => openWs(name), 2000) }
467
540
  }
@@ -501,35 +574,21 @@ async function openConfig(){
501
574
  function closeConfig(){ document.getElementById('cfgModal').classList.remove('open') }
502
575
  document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConfig() })
503
576
 
504
- // Topbar version selector — the studio guard's project versions (latest first). Repopulated
505
- // only when the list changes so the user's selection survives the 3 s poll.
506
- let verChoices = ''
507
- function renderVersions(versions){
508
- const sel = document.getElementById('ver')
509
- const list = (versions && versions.length ? versions : []).join('|')
510
- if (list === verChoices) return
511
- verChoices = list
512
- sel.innerHTML = (versions && versions.length ? versions : ['latest'])
513
- .map((v, i) => '<option value="'+esc(v)+'"'+(i===0?' selected':'')+'>'+esc(v)+'</option>').join('')
514
- }
515
- function selectedVersion(){ return document.getElementById('ver').value || '' }
516
-
517
577
  async function refresh(){
518
578
  const s = await fetch('api/state').then(r => r.json()).catch(() => null)
519
579
  if (!s) return
520
580
  state = s
521
581
  document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
522
582
  document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
523
- renderVersions(s.versions)
583
+ // Defect 1 — the app was regenerated in place while this process kept serving the old code.
584
+ const stale = document.getElementById('stale')
585
+ if (s.regenerated) { stale.textContent = 'This CI/CD app is running older code than was generated — restart it (Stop, then Start).'; stale.style.display = 'block' }
586
+ else stale.style.display = 'none'
524
587
  if (!active && s.apps.length) setActive(s.apps[0].name)
525
588
  renderTabs(); renderBar()
526
589
  }
527
590
  async function act(op){
528
- await fetch('api/apps/'+encodeURIComponent(active)+'/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
529
- refresh()
530
- }
531
- async function actAll(op){
532
- await fetch('api/all/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
591
+ await fetch('api/apps/'+encodeURIComponent(active)+'/'+op, { method: 'POST' })
533
592
  refresh()
534
593
  }
535
594
  // Rescan immediately when the user returns to this page (tab focus / regained visibility), so
@@ -602,19 +661,24 @@ const server = http.createServer(async (req, res) => {
602
661
  const apps = await Promise.all(loadConfig().apps.map(async (a) => {
603
662
  const healthy = await probe(a)
604
663
  const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
664
+ const dev = developInfo(a)
605
665
  return {
606
666
  name: a.name, method: a.method, port: a.port, phase,
607
667
  deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
608
668
  image: a.image || null,
609
669
  external: healthy && !running.has(a.name),
610
- developedOnce: developedOnce(a),
670
+ developedOnce: dev.val, developSource: dev.src,
611
671
  error: lastError.get(a.name) || null,
612
672
  }
613
673
  }))
674
+ // Defect 1 — regenerated in place while still running the old code (config's generatedAt
675
+ // moved past the value pinned at boot).
676
+ const nowGen = loadConfig().generatedAt || null
614
677
  return json(res, 200, {
615
678
  currentBranch: currentBranch(),
616
679
  hasInfra: (loadConfig().infra || []).length > 0,
617
- versions: guardVersions,
680
+ guardStatus,
681
+ regenerated: !!(BOOT_GENERATED_AT && nowGen !== BOOT_GENERATED_AT),
618
682
  apps,
619
683
  })
620
684
  }
@@ -624,54 +688,21 @@ const server = http.createServer(async (req, res) => {
624
688
  if (!app) return json(res, 404, { error: 'unknown app' })
625
689
  return json(res, 200, { configs: (app.configs || []).map((c) => readConfig(app, c)) })
626
690
  }
627
- // ?version=<semver> on every action — the topbar selection, substituted into {VERSION}.
628
- const reqVersion = (() => {
629
- try { return new URL(req.url, 'http://x').searchParams.get('version') || '' } catch { return '' }
630
- })()
631
691
  const path = (req.url || '').split('?')[0]
632
692
  const m = path.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
633
693
  if (m && req.method === 'POST') {
634
694
  const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
635
695
  if (!app) return json(res, 404, { error: 'unknown app' })
636
- if (m[2] !== 'stop' && developedOnce(app) === false) {
696
+ // Only a DEFINITIVE "never developed" blocks — unknown (null) fails open (Defect 2c/4).
697
+ if (m[2] !== 'stop' && developInfo(app).val === false) {
637
698
  return json(res, 409, { error: 'application has not completed a develop workflow run' })
638
699
  }
639
- if (m[2] === 'build') buildApp(app, reqVersion)
640
- else if (m[2] === 'start') startApp(app, reqVersion)
641
- else if (m[2] === 'deploy') deployApp(app, reqVersion)
642
- else stopApp(app, reqVersion)
700
+ if (m[2] === 'build') buildApp(app)
701
+ else if (m[2] === 'start') startApp(app)
702
+ else if (m[2] === 'deploy') deployApp(app)
703
+ else await stopApp(app)
643
704
  return json(res, 200, { ok: true })
644
705
  }
645
- // All-apps controls. Eligible = develop gate not failed and not mid-build/deploy; build and
646
- // start additionally skip apps already running. Builds run SEQUENTIALLY (honest logs, no
647
- // CPU storm); start/stop fire together. Ineligible apps are skipped silently.
648
- const all = path.match(/^\/api\/all\/(build|start|stop)$/)
649
- if (all && req.method === 'POST') {
650
- const op = all[1]
651
- const apps = loadConfig().apps
652
- if (op === 'stop') {
653
- for (const a of apps) {
654
- if (running.has(a.name)) stopApp(a, reqVersion)
655
- else probe(a).then((up) => { if (up) stopApp(a, reqVersion) })
656
- }
657
- return json(res, 200, { ok: true })
658
- }
659
- const eligible = []
660
- for (const a of apps) {
661
- if (developedOnce(a) === false || activity.has(a.name)) continue
662
- if (await probe(a)) continue // already running — skip for build AND start
663
- eligible.push(a)
664
- }
665
- if (op === 'start') {
666
- for (const a of eligible) startApp(a, reqVersion)
667
- return json(res, 200, { ok: true, count: eligible.length })
668
- }
669
- // op === 'build' — sequential chain: each build starts when the previous one exits.
670
- const queue = [...eligible]
671
- const next = () => { const a = queue.shift(); if (a) buildApp(a, reqVersion, next) }
672
- next()
673
- return json(res, 200, { ok: true, count: eligible.length })
674
- }
675
706
  json(res, 404, { error: 'not found' })
676
707
  })
677
708
 
@@ -708,6 +739,10 @@ server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
708
739
 
709
740
  `cicd/app/cicd.config.json` shape — one `apps` entry per application from the plan's Deployment
710
741
  Decisions table, one `infra` entry per row of the plan's `## Infrastructure` table.
742
+ Top-level `"generatedAt"` is an ISO-8601 UTC timestamp **stamped fresh on every generation**
743
+ (Defect 1): the running server pins the value it booted with and flags a "restart required" banner
744
+ once a regeneration moves it forward, so a stale in-place-replaced process is self-diagnosing. It
745
+ MUST change on each run — use the actual generation time (e.g. `new Date().toISOString()`).
711
746
  `deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is `null` when the method
712
747
  is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
713
748
  `"infra": []` when the plan declares no supporting services.
@@ -718,9 +753,8 @@ Docker fields (from the plan's `## Docker` / `## Dockerfiles` sections): top-lev
718
753
  `"dockerfile"` is `{ "path": "Dockerfile", "content": "<managed body>" }` for a `managed`
719
754
  Dockerfile (materialized before Build when missing — never overwriting), or
720
755
  `{ "path": "Dockerfile", "content": null }` for an `existing` one (`null` entirely when the
721
- app has no image). Commands may carry the literal `{VERSION}` token — copied verbatim; the
722
- server substitutes the topbar-selected version (semver sans `v`, fallback `latest`) at
723
- execution time.
756
+ app has no image). Commands are copied verbatim from the plan and run as-is — no `{VERSION}`
757
+ or any other token substitution.
724
758
 
725
759
  Each app also carries a `configs` array — the runtime config files the application reads at start,
726
760
  from the plan's `## Application Configs` section (one entry per file). Fields:
@@ -736,6 +770,7 @@ verbatim (this is a local dev CI/CD app).
736
770
  ```json
737
771
  {
738
772
  "port": 5000,
773
+ "generatedAt": "2026-07-24T09:15:00.000Z",
739
774
  "docker": { "registry": "registry.example.com/team" },
740
775
  "apps": [
741
776
  {
@@ -743,7 +778,7 @@ verbatim (this is a local dev CI/CD app).
743
778
  "folder": "my-app",
744
779
  "method": "process",
745
780
  "port": 3000,
746
- "build": "pnpm install && pnpm build && docker build -t registry.example.com/team/my-app:{VERSION} .",
781
+ "build": "rm -rf dist && pnpm install && pnpm build && docker build --no-cache -t registry.example.com/team/my-app:latest .",
747
782
  "start": "node dist/index.js",
748
783
  "stop": "process-kill",
749
784
  "deployMethod": "kubernetes",