@rashidee/co2 1.3.18 → 1.3.20

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 +78 -29
  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 +5 -2
  6. package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +156 -54
  7. package/plugin/skills/util-plancicd/SKILL.md +319 -305
  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
package/dist/index.js CHANGED
@@ -6809,7 +6809,7 @@ var {
6809
6809
  // package.json
6810
6810
  var package_default = {
6811
6811
  name: "@rashidee/co2",
6812
- version: "1.3.18",
6812
+ version: "1.3.20",
6813
6813
  description: "Compound Context Studio \u2014 self-hosted team context authoring for CO2 projects",
6814
6814
  type: "module",
6815
6815
  license: "MIT",
@@ -21875,6 +21875,7 @@ import { spawn as spawn2 } from "child_process";
21875
21875
  import { randomBytes as randomBytes2 } from "crypto";
21876
21876
  import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
21877
21877
  import { request as httpRequest2 } from "http";
21878
+ import { createServer as createNetServer } from "net";
21878
21879
  import { join as join9 } from "path";
21879
21880
  function svcError6(code, status) {
21880
21881
  return Object.assign(new Error(code), { code, status });
@@ -21896,8 +21897,22 @@ process.once("SIGINT", () => {
21896
21897
  killSpawnedApps();
21897
21898
  process.exit(130);
21898
21899
  });
21899
- function cicdGuardTokenValid(projectId, token) {
21900
- return !!token && guardTokens.get(projectId) === token;
21900
+ function persistGuardToken(db, projectId, token) {
21901
+ try {
21902
+ db.update(cicdSetups).set({ guardToken: token, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(cicdSetups.projectId, projectId)).run();
21903
+ } catch {
21904
+ }
21905
+ }
21906
+ function cicdGuardTokenValid(db, projectId, token) {
21907
+ if (!token) return false;
21908
+ const mem = guardTokens.get(projectId);
21909
+ if (mem !== void 0) return mem === token;
21910
+ const row = db.select({ t: cicdSetups.guardToken }).from(cicdSetups).where(eq(cicdSetups.projectId, projectId)).get();
21911
+ if (row?.t) {
21912
+ guardTokens.set(projectId, row.t);
21913
+ return row.t === token;
21914
+ }
21915
+ return false;
21901
21916
  }
21902
21917
  function resolveCicdApp(db, workspacePath, projectId) {
21903
21918
  const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
@@ -21930,6 +21945,49 @@ function probeApp(port, timeoutMs = 1e3) {
21930
21945
  req.end();
21931
21946
  });
21932
21947
  }
21948
+ function portFree(port) {
21949
+ return new Promise((resolve5) => {
21950
+ const srv = createNetServer();
21951
+ srv.once("error", () => resolve5(false));
21952
+ srv.once("listening", () => srv.close(() => resolve5(true)));
21953
+ srv.listen(port, "127.0.0.1");
21954
+ });
21955
+ }
21956
+ function ephemeralPort() {
21957
+ return new Promise((resolve5) => {
21958
+ const srv = createNetServer();
21959
+ srv.once("error", () => resolve5(0));
21960
+ srv.listen(0, "127.0.0.1", () => {
21961
+ const addr = srv.address();
21962
+ const p = addr && typeof addr === "object" ? addr.port : 0;
21963
+ srv.close(() => resolve5(p));
21964
+ });
21965
+ });
21966
+ }
21967
+ async function pickFreePort(preferred, avoid) {
21968
+ if (preferred && preferred !== avoid && await portFree(preferred)) return preferred;
21969
+ for (let i = 0; i < 20; i++) {
21970
+ const p = await ephemeralPort();
21971
+ if (p && p !== avoid) return p;
21972
+ }
21973
+ return preferred || DEFAULT_CICD_APP_PORT;
21974
+ }
21975
+ function persistConfigPort(location, newPort) {
21976
+ if (newPort === location.port) return;
21977
+ const configPath = join9(location.appDir, "cicd.config.json");
21978
+ let config = {};
21979
+ try {
21980
+ config = JSON.parse(readFileSync6(configPath, "utf8"));
21981
+ } catch {
21982
+ }
21983
+ config.port = newPort;
21984
+ try {
21985
+ writeFileSync5(configPath, `${JSON.stringify(config, null, 2)}
21986
+ `);
21987
+ } catch {
21988
+ }
21989
+ location.port = newPort;
21990
+ }
21933
21991
  function toStatus2(location, running, projectId) {
21934
21992
  return {
21935
21993
  present: location.present,
@@ -21948,26 +22006,13 @@ async function getCicdAppStatus(db, workspacePath, projectId) {
21948
22006
  async function startCicdApp(db, workspacePath, projectId, studioPort, port) {
21949
22007
  const location = resolveCicdApp(db, workspacePath, projectId);
21950
22008
  if (!location.present) throw svcError6("CICD_APP_MISSING", 404);
21951
- const targetPort = port ?? location.port;
21952
- if (targetPort !== location.port) {
21953
- const configPath = join9(location.appDir, "cicd.config.json");
21954
- let config = {};
21955
- try {
21956
- config = JSON.parse(readFileSync6(configPath, "utf8"));
21957
- } catch {
21958
- }
21959
- config.port = targetPort;
21960
- try {
21961
- writeFileSync5(configPath, `${JSON.stringify(config, null, 2)}
21962
- `);
21963
- } catch {
21964
- }
21965
- location.port = targetPort;
21966
- }
21967
- if (await probeApp(targetPort)) {
21968
- location.port = targetPort;
22009
+ const savedPort = port ?? location.port;
22010
+ if (await probeApp(savedPort)) {
22011
+ persistConfigPort(location, savedPort);
21969
22012
  return toStatus2(location, true, projectId);
21970
22013
  }
22014
+ const targetPort = port !== void 0 ? port : await pickFreePort(savedPort, studioPort);
22015
+ persistConfigPort(location, targetPort);
21971
22016
  const existing = spawnedApps.get(projectId);
21972
22017
  if (existing && existing.exitCode === null) {
21973
22018
  try {
@@ -21993,20 +22038,22 @@ async function startCicdApp(db, workspacePath, projectId, studioPort, port) {
21993
22038
  child.on("error", () => {
21994
22039
  spawnedApps.delete(projectId);
21995
22040
  guardTokens.delete(projectId);
22041
+ persistGuardToken(db, projectId, null);
21996
22042
  });
21997
22043
  child.on("exit", () => {
21998
22044
  spawnedApps.delete(projectId);
21999
22045
  guardTokens.delete(projectId);
22046
+ persistGuardToken(db, projectId, null);
22000
22047
  });
22001
22048
  spawnedApps.set(projectId, child);
22002
- db.update(cicdSetups).set({ appPort: location.port, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(cicdSetups.projectId, projectId)).run();
22049
+ db.update(cicdSetups).set({ appPort: location.port, guardToken: token, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(cicdSetups.projectId, projectId)).run();
22003
22050
  for (let attempt = 0; attempt < 20; attempt++) {
22004
22051
  if (await probeApp(location.port, 500)) return toStatus2(location, true, projectId);
22005
22052
  await new Promise((r) => setTimeout(r, 250));
22006
22053
  }
22007
22054
  return toStatus2(location, false, projectId);
22008
22055
  }
22009
- function stopCicdApp(projectId) {
22056
+ function stopCicdApp(db, projectId) {
22010
22057
  const child = spawnedApps.get(projectId);
22011
22058
  if (!child) return false;
22012
22059
  try {
@@ -22015,6 +22062,7 @@ function stopCicdApp(projectId) {
22015
22062
  }
22016
22063
  spawnedApps.delete(projectId);
22017
22064
  guardTokens.delete(projectId);
22065
+ persistGuardToken(db, projectId, null);
22018
22066
  return true;
22019
22067
  }
22020
22068
 
@@ -22701,6 +22749,7 @@ function pollCicdTracking(db, workspacePath, setupId, kind, notifyUserId) {
22701
22749
  relatedEntityType: "cicd_setup",
22702
22750
  relatedEntityId: setupId
22703
22751
  });
22752
+ stopCicdApp(db, setup.projectId);
22704
22753
  try {
22705
22754
  runProjectGit(
22706
22755
  projectDir2,
@@ -22854,7 +22903,7 @@ function cicdRoutes(db, workspacePath, studioPort) {
22854
22903
  const user = c.get("user");
22855
22904
  try {
22856
22905
  const result = requestPlan(db, workspacePath, projectId, user.id);
22857
- stopCicdApp(projectId);
22906
+ stopCicdApp(db, projectId);
22858
22907
  return c.json(result, 201);
22859
22908
  } catch (e) {
22860
22909
  return errorResponse3(c, e);
@@ -22910,7 +22959,7 @@ function cicdRoutes(db, workspacePath, studioPort) {
22910
22959
  const projectId = parseProjectId(c);
22911
22960
  if (projectId === null) return c.json({ code: "INVALID_ID" }, 400);
22912
22961
  try {
22913
- stopCicdApp(projectId);
22962
+ stopCicdApp(db, projectId);
22914
22963
  return c.json(await getCicdAppStatus(db, workspacePath, projectId));
22915
22964
  } catch (e) {
22916
22965
  return errorResponse3(c, e);
@@ -22943,7 +22992,7 @@ function cicdGuardRoutes(db, workspacePath) {
22943
22992
  if (Number.isNaN(projectId)) return c.json({ code: "INVALID_ID" }, 400);
22944
22993
  const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "");
22945
22994
  const token = c.req.query("token") ?? bearer;
22946
- if (!cicdGuardTokenValid(projectId, token) && !c.get("user")) {
22995
+ if (!cicdGuardTokenValid(db, projectId, token) && !c.get("user")) {
22947
22996
  return c.json({ code: "UNAUTHORIZED" }, 401);
22948
22997
  }
22949
22998
  const rawVersion = c.req.query("versionId");
@@ -25518,7 +25567,7 @@ function isPendingForConductor(s, conductorType) {
25518
25567
  if (conductorType === "prepare") return s.prepareStatus !== "completed";
25519
25568
  if (conductorType === "develop")
25520
25569
  return s.prepareStatus === "completed" && s.developStatus !== "completed";
25521
- return s.developStatus === "completed" && s.qualityStatus !== "completed";
25570
+ return s.developStatus === "completed";
25522
25571
  }
25523
25572
  function getVersionAffectedAppIds(db, projectId, prdVersionId) {
25524
25573
  const version2 = db.select({ id: prdVersions.id, semver: prdVersions.semver }).from(prdVersions).where(and(eq(prdVersions.id, prdVersionId), eq(prdVersions.projectId, projectId))).limit(1).get();
@@ -25568,7 +25617,7 @@ function getVersionAffectedAppIds(db, projectId, prdVersionId) {
25568
25617
  }
25569
25618
  function getPendingApps(db, projectId, conductorType, prdVersionId) {
25570
25619
  const apps = db.select({ id: applications.id, name: applications.name }).from(applications).where(and(eq(applications.projectId, projectId), eq(applications.isDeleted, 0))).all();
25571
- const affected = prdVersionId === void 0 ? null : getVersionAffectedAppIds(db, projectId, prdVersionId);
25620
+ const affected = prdVersionId === void 0 || conductorType === "quality" ? null : getVersionAffectedAppIds(db, projectId, prdVersionId);
25572
25621
  const result = [];
25573
25622
  for (const app of apps) {
25574
25623
  if (affected !== null && !affected.has(app.id)) continue;
@@ -25581,7 +25630,7 @@ function getPendingApps(db, projectId, conductorType, prdVersionId) {
25581
25630
  }
25582
25631
  function getPhaseCompletion(db, projectId, conductorType, prdVersionId) {
25583
25632
  const allApps = db.select({ id: applications.id }).from(applications).where(and(eq(applications.projectId, projectId), eq(applications.isDeleted, 0))).all();
25584
- const affected = prdVersionId === void 0 ? null : getVersionAffectedAppIds(db, projectId, prdVersionId);
25633
+ const affected = prdVersionId === void 0 || conductorType === "quality" ? null : getVersionAffectedAppIds(db, projectId, prdVersionId);
25585
25634
  const apps = affected === null ? allApps : allApps.filter((a) => affected.has(a.id));
25586
25635
  let completed = 0;
25587
25636
  for (const app of apps) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rashidee/co2",
3
- "version": "1.3.18",
3
+ "version": "1.3.20",
4
4
  "description": "Compound Context Studio — self-hosted team context authoring for CO2 projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  "name": "co2-skills",
13
13
  "source": "./",
14
14
  "description": "Compound Context Skills — generates data models, HTML mockups, technical specifications, test specifications, and orchestrates full-stack development from Agile user stories.",
15
- "version": "1.7.0",
15
+ "version": "1.8.2",
16
16
  "author": {
17
17
  "name": "Rashidee M.Rashid"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co2-skills",
3
- "version": "1.7.0",
3
+ "version": "1.8.2",
4
4
  "description": "Compound Context Skills — an opinionated context engineering methodology for AI-driven software development. Generates data models, HTML mockups, technical specifications, test specifications, and orchestrates full-stack development from Agile user stories.",
5
5
  "author": {
6
6
  "name": "Rashidee M.Rashid"
@@ -66,10 +66,13 @@ Write `cicd/CICD_GENERATION.md`:
66
66
  Read `references/cicd-app-template.md` for the complete file templates. Produce:
67
67
 
68
68
  1. `cicd/app/cicd.config.json` — from the plan's tables:
69
- `{ "port": <CI/CD app port>, "docker": { "registry" } | null, "apps": [{ "name", "folder",
69
+ `{ "port": <CI/CD app port>, "generatedAt": "<current ISO-8601 UTC>", "docker": { "registry" } | null, "apps": [{ "name", "folder",
70
70
  "method", "port", "build", "start", "stop", "deployMethod", "deploy", "health", "image",
71
71
  "dockerfile", "configs" }], "infra": [{ "id", "name",
72
- "host", "port", "url", "description" }] }` — `docker.registry` comes from the plan's
72
+ "host", "port", "url", "description" }] }` — `generatedAt` MUST be the actual time you generate
73
+ this file (a fresh value on EVERY run — never copy a previous one): the running server pins the
74
+ value it booted with and shows a "restart required" banner when a later regeneration moves it
75
+ forward, so an in-place-replaced-but-still-running app is self-diagnosing. `docker.registry` comes from the plan's
73
76
  `## Docker` section (`null` when the plan says not dockerized); per-app `image` is that
74
77
  section's Image column (`null` for non-dockerized apps) and `dockerfile` is
75
78
  `{ "path": "Dockerfile", "content": "<body>" }` for a `managed` Dockerfile from the plan's
@@ -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,27 +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
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'
69
84
  async function refreshGuard() {
70
- if (!PROJECT_ID) return
85
+ if (!PROJECT_ID) { guardStatus = 'not-configured'; return }
71
86
  try {
72
87
  const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
73
- 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 }
74
90
  const g = await r.json()
75
91
  if (Array.isArray(g.applications)) {
76
- guardApps = new Map()
77
- 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'
78
96
  }
79
- } 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 }
80
114
  }
81
- function developedOnce(app) {
82
- if (!guardApps) return null // unknown controls stay disabled until the studio answers
83
- const byName = guardApps.get(app.name.toLowerCase())
84
- if (byName !== undefined) return byName
85
- const byFolder = guardApps.get(String(app.folder || '').toLowerCase())
86
- 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' }
87
125
  }
88
126
  refreshGuard()
89
127
  setInterval(refreshGuard, 30_000).unref?.()
@@ -140,7 +178,11 @@ function runLogged(app, command, onExit) {
140
178
  const log = createWriteStream(logPath(app.name), { flags: 'a' })
141
179
  log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
142
180
  broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
143
- 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' })
144
186
  const tap = (d) => { log.write(d); broadcast(app.name, d) }
145
187
  child.stdout.on('data', tap)
146
188
  child.stderr.on('data', tap)
@@ -282,20 +324,27 @@ function startApp(app) {
282
324
  saveState()
283
325
  }
284
326
 
285
- // shell:true spawns a shell whose grandchildren survive child.kill() on Windows — kill the
286
- // whole tree there (taskkill /T); plain kill elsewhere.
287
- function killTree(child) {
288
- if (process.platform === 'win32' && child.pid) {
289
- try { execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
290
- } else {
291
- 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
292
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 {} } }
293
344
  }
294
345
 
295
- // Stop an app we DON'T hold a child handle for (started outside this app, or before this app
296
- // last restarted) by killing whatever process tree is listening on its port — so Stop works
297
- // for any running app, not only ones this CI/CD app spawned. Returns true if a PID was killed.
298
- function killByPort(port) {
346
+ // PIDs listening on a TCP port. POSIX: lsof, then fuser both tolerated absent. Windows: netstat.
347
+ function portListeners(port) {
299
348
  try {
300
349
  if (process.platform === 'win32') {
301
350
  const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
@@ -304,36 +353,60 @@ function killByPort(port) {
304
353
  const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
305
354
  if (m && Number(m[1]) === Number(port)) pids.add(m[2])
306
355
  }
307
- for (const pid of pids) {
308
- try { execFileSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'ignore' }) } catch {}
309
- }
310
- return pids.size > 0
356
+ return [...pids]
311
357
  }
312
- const out = execFileSync('sh', ['-c', `lsof -ti tcp:${port} || true`], { encoding: 'utf8' })
313
- const pids = out.split(/\s+/).filter(Boolean)
314
- for (const pid of pids) { try { process.kill(Number(pid)) } catch {} }
315
- return pids.length > 0
316
- } 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
317
391
  }
318
392
 
319
- function stopApp(app) {
393
+ async function stopApp(app) {
320
394
  if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
321
395
  const stopCmd = app.stop
322
396
  try { execFileSync(stopCmd.split(' ')[0], stopCmd.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
323
- } else {
324
- const child = running.get(app.name)
325
- if (child) {
326
- stopping.add(app.name)
327
- killTree(child)
328
- } else {
329
- // No child handle — kill by port so an externally/previously started app still stops.
330
- lastError.delete(app.name)
331
- if (!killByPort(app.port)) {
332
- lastError.set(app.name, `could not stop — nothing found listening on port ${app.port}`)
333
- }
334
- }
397
+ running.delete(app.name)
398
+ saveState()
399
+ return
335
400
  }
401
+ lastError.delete(app.name)
402
+ const child = running.get(app.name)
403
+ if (child) { stopping.add(app.name); await killTree(child) }
336
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`)
337
410
  saveState()
338
411
  }
339
412
 
@@ -360,6 +433,7 @@ button{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;
360
433
  button:hover:enabled{background:#eef1f5}
361
434
  button:disabled{opacity:.4;cursor:default}
362
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}
363
437
  .err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
364
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}
365
439
  .muted{color:#5b6572;font-size:12px}
@@ -378,6 +452,7 @@ button:disabled{opacity:.4;cursor:default}
378
452
  </style></head><body>
379
453
  <div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span>
380
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>
381
456
  <div class="tabs" id="tabs"></div>
382
457
  <div class="bar" id="bar"></div>
383
458
  <div class="notice" id="notice"></div>
@@ -403,11 +478,19 @@ function renderTabs(){
403
478
  <span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
404
479
  }
405
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
+ }
406
487
  function renderBar(){
407
488
  const a = appOf(active)
408
489
  if (!a) return
409
490
  const url = 'http://'+location.hostname+':'+a.port+'/'
410
- 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
411
494
  const busy = a.phase==='building'||a.phase==='deploying'
412
495
  document.getElementById('bar').innerHTML = \`
413
496
  <span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
@@ -424,7 +507,10 @@ function renderBar(){
424
507
  notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
425
508
  notice.style.display = 'block'
426
509
  } else if (a.developedOnce === null) {
427
- 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).'
428
514
  notice.style.display = 'block'
429
515
  } else notice.style.display = 'none'
430
516
  const err = document.getElementById('err')
@@ -494,6 +580,10 @@ async function refresh(){
494
580
  state = s
495
581
  document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
496
582
  document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
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'
497
587
  if (!active && s.apps.length) setActive(s.apps[0].name)
498
588
  renderTabs(); renderBar()
499
589
  }
@@ -571,18 +661,24 @@ const server = http.createServer(async (req, res) => {
571
661
  const apps = await Promise.all(loadConfig().apps.map(async (a) => {
572
662
  const healthy = await probe(a)
573
663
  const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
664
+ const dev = developInfo(a)
574
665
  return {
575
666
  name: a.name, method: a.method, port: a.port, phase,
576
667
  deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
577
668
  image: a.image || null,
578
669
  external: healthy && !running.has(a.name),
579
- developedOnce: developedOnce(a),
670
+ developedOnce: dev.val, developSource: dev.src,
580
671
  error: lastError.get(a.name) || null,
581
672
  }
582
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
583
677
  return json(res, 200, {
584
678
  currentBranch: currentBranch(),
585
679
  hasInfra: (loadConfig().infra || []).length > 0,
680
+ guardStatus,
681
+ regenerated: !!(BOOT_GENERATED_AT && nowGen !== BOOT_GENERATED_AT),
586
682
  apps,
587
683
  })
588
684
  }
@@ -597,13 +693,14 @@ const server = http.createServer(async (req, res) => {
597
693
  if (m && req.method === 'POST') {
598
694
  const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
599
695
  if (!app) return json(res, 404, { error: 'unknown app' })
600
- 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) {
601
698
  return json(res, 409, { error: 'application has not completed a develop workflow run' })
602
699
  }
603
700
  if (m[2] === 'build') buildApp(app)
604
701
  else if (m[2] === 'start') startApp(app)
605
702
  else if (m[2] === 'deploy') deployApp(app)
606
- else stopApp(app)
703
+ else await stopApp(app)
607
704
  return json(res, 200, { ok: true })
608
705
  }
609
706
  json(res, 404, { error: 'not found' })
@@ -642,6 +739,10 @@ server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
642
739
 
643
740
  `cicd/app/cicd.config.json` shape — one `apps` entry per application from the plan's Deployment
644
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()`).
645
746
  `deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is `null` when the method
646
747
  is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
647
748
  `"infra": []` when the plan declares no supporting services.
@@ -669,6 +770,7 @@ verbatim (this is a local dev CI/CD app).
669
770
  ```json
670
771
  {
671
772
  "port": 5000,
773
+ "generatedAt": "2026-07-24T09:15:00.000Z",
672
774
  "docker": { "registry": "registry.example.com/team" },
673
775
  "apps": [
674
776
  {
@@ -676,7 +778,7 @@ verbatim (this is a local dev CI/CD app).
676
778
  "folder": "my-app",
677
779
  "method": "process",
678
780
  "port": 3000,
679
- "build": "pnpm install && pnpm build && docker build -t registry.example.com/team/my-app:latest .",
781
+ "build": "rm -rf dist && pnpm install && pnpm build && docker build --no-cache -t registry.example.com/team/my-app:latest .",
680
782
  "start": "node dist/index.js",
681
783
  "stop": "process-kill",
682
784
  "deployMethod": "kubernetes",