gcf-aif-ext 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/commands/gcf.js +173 -0
  4. package/extension.json +24 -0
  5. package/hooks/pre-commit.js +91 -0
  6. package/injections/commit-status.md +6 -0
  7. package/injections/plan-context.md +8 -0
  8. package/injections/review-pillars.md +7 -0
  9. package/lib/anchors.js +109 -0
  10. package/lib/audit-core.js +102 -0
  11. package/lib/context.js +77 -0
  12. package/lib/frontmatter.js +26 -0
  13. package/lib/gcf-paths.js +60 -0
  14. package/lib/git.js +19 -0
  15. package/lib/id.js +42 -0
  16. package/lib/index-gen.js +51 -0
  17. package/lib/linter.js +61 -0
  18. package/lib/playtest-list.js +13 -0
  19. package/lib/safe-path.js +71 -0
  20. package/lib/transitions.js +30 -0
  21. package/lib/validate-card.js +48 -0
  22. package/lib/verdict-fs.js +126 -0
  23. package/node_modules/cnlp-kit/CHANGELOG.md +625 -0
  24. package/node_modules/cnlp-kit/LICENSE +21 -0
  25. package/node_modules/cnlp-kit/README.md +242 -0
  26. package/node_modules/cnlp-kit/bin/cnlp.js +334 -0
  27. package/node_modules/cnlp-kit/cnlp-format.md +470 -0
  28. package/node_modules/cnlp-kit/package.json +40 -0
  29. package/node_modules/cnlp-kit/profiles/adr.md +129 -0
  30. package/node_modules/cnlp-kit/profiles/api.md +87 -0
  31. package/node_modules/cnlp-kit/profiles/concept.md +70 -0
  32. package/node_modules/cnlp-kit/profiles/docset.md +103 -0
  33. package/node_modules/cnlp-kit/profiles/guide.md +51 -0
  34. package/node_modules/cnlp-kit/profiles/profile.md +69 -0
  35. package/node_modules/cnlp-kit/profiles/reference.md +61 -0
  36. package/node_modules/cnlp-kit/profiles/skill.md +75 -0
  37. package/node_modules/cnlp-kit/profiles/task.md +65 -0
  38. package/node_modules/cnlp-kit/quality-rules.md +69 -0
  39. package/node_modules/cnlp-kit/skills/cnlp-docset/SKILL.md +73 -0
  40. package/node_modules/cnlp-kit/skills/cnlp-migrate/SKILL.md +64 -0
  41. package/node_modules/cnlp-kit/src/cnlp.js +534 -0
  42. package/node_modules/cnlp-kit/src/config.js +352 -0
  43. package/node_modules/yaml/LICENSE +13 -0
  44. package/node_modules/yaml/README.md +172 -0
  45. package/node_modules/yaml/bin.mjs +11 -0
  46. package/node_modules/yaml/browser/dist/compose/compose-collection.js +88 -0
  47. package/node_modules/yaml/browser/dist/compose/compose-doc.js +43 -0
  48. package/node_modules/yaml/browser/dist/compose/compose-node.js +109 -0
  49. package/node_modules/yaml/browser/dist/compose/compose-scalar.js +86 -0
  50. package/node_modules/yaml/browser/dist/compose/composer.js +219 -0
  51. package/node_modules/yaml/browser/dist/compose/resolve-block-map.js +115 -0
  52. package/node_modules/yaml/browser/dist/compose/resolve-block-scalar.js +198 -0
  53. package/node_modules/yaml/browser/dist/compose/resolve-block-seq.js +49 -0
  54. package/node_modules/yaml/browser/dist/compose/resolve-end.js +37 -0
  55. package/node_modules/yaml/browser/dist/compose/resolve-flow-collection.js +207 -0
  56. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +226 -0
  57. package/node_modules/yaml/browser/dist/compose/resolve-props.js +146 -0
  58. package/node_modules/yaml/browser/dist/compose/util-contains-newline.js +34 -0
  59. package/node_modules/yaml/browser/dist/compose/util-empty-scalar-position.js +26 -0
  60. package/node_modules/yaml/browser/dist/compose/util-flow-indent-check.js +15 -0
  61. package/node_modules/yaml/browser/dist/compose/util-map-includes.js +13 -0
  62. package/node_modules/yaml/browser/dist/doc/Document.js +335 -0
  63. package/node_modules/yaml/browser/dist/doc/anchors.js +71 -0
  64. package/node_modules/yaml/browser/dist/doc/applyReviver.js +55 -0
  65. package/node_modules/yaml/browser/dist/doc/createNode.js +88 -0
  66. package/node_modules/yaml/browser/dist/doc/directives.js +176 -0
  67. package/node_modules/yaml/browser/dist/errors.js +57 -0
  68. package/node_modules/yaml/browser/dist/index.js +17 -0
  69. package/node_modules/yaml/browser/dist/log.js +11 -0
  70. package/node_modules/yaml/browser/dist/nodes/Alias.js +118 -0
  71. package/node_modules/yaml/browser/dist/nodes/Collection.js +147 -0
  72. package/node_modules/yaml/browser/dist/nodes/Node.js +38 -0
  73. package/node_modules/yaml/browser/dist/nodes/Pair.js +36 -0
  74. package/node_modules/yaml/browser/dist/nodes/Scalar.js +24 -0
  75. package/node_modules/yaml/browser/dist/nodes/YAMLMap.js +144 -0
  76. package/node_modules/yaml/browser/dist/nodes/YAMLSeq.js +113 -0
  77. package/node_modules/yaml/browser/dist/nodes/addPairToJSMap.js +63 -0
  78. package/node_modules/yaml/browser/dist/nodes/identity.js +36 -0
  79. package/node_modules/yaml/browser/dist/nodes/toJS.js +37 -0
  80. package/node_modules/yaml/browser/dist/parse/cst-scalar.js +214 -0
  81. package/node_modules/yaml/browser/dist/parse/cst-stringify.js +61 -0
  82. package/node_modules/yaml/browser/dist/parse/cst-visit.js +97 -0
  83. package/node_modules/yaml/browser/dist/parse/cst.js +98 -0
  84. package/node_modules/yaml/browser/dist/parse/lexer.js +721 -0
  85. package/node_modules/yaml/browser/dist/parse/line-counter.js +39 -0
  86. package/node_modules/yaml/browser/dist/parse/parser.js +975 -0
  87. package/node_modules/yaml/browser/dist/public-api.js +102 -0
  88. package/node_modules/yaml/browser/dist/schema/Schema.js +37 -0
  89. package/node_modules/yaml/browser/dist/schema/common/map.js +17 -0
  90. package/node_modules/yaml/browser/dist/schema/common/null.js +15 -0
  91. package/node_modules/yaml/browser/dist/schema/common/seq.js +17 -0
  92. package/node_modules/yaml/browser/dist/schema/common/string.js +14 -0
  93. package/node_modules/yaml/browser/dist/schema/core/bool.js +19 -0
  94. package/node_modules/yaml/browser/dist/schema/core/float.js +43 -0
  95. package/node_modules/yaml/browser/dist/schema/core/int.js +38 -0
  96. package/node_modules/yaml/browser/dist/schema/core/schema.js +23 -0
  97. package/node_modules/yaml/browser/dist/schema/json/schema.js +62 -0
  98. package/node_modules/yaml/browser/dist/schema/tags.js +96 -0
  99. package/node_modules/yaml/browser/dist/schema/yaml-1.1/binary.js +58 -0
  100. package/node_modules/yaml/browser/dist/schema/yaml-1.1/bool.js +26 -0
  101. package/node_modules/yaml/browser/dist/schema/yaml-1.1/float.js +46 -0
  102. package/node_modules/yaml/browser/dist/schema/yaml-1.1/int.js +71 -0
  103. package/node_modules/yaml/browser/dist/schema/yaml-1.1/merge.js +67 -0
  104. package/node_modules/yaml/browser/dist/schema/yaml-1.1/omap.js +74 -0
  105. package/node_modules/yaml/browser/dist/schema/yaml-1.1/pairs.js +78 -0
  106. package/node_modules/yaml/browser/dist/schema/yaml-1.1/schema.js +39 -0
  107. package/node_modules/yaml/browser/dist/schema/yaml-1.1/set.js +93 -0
  108. package/node_modules/yaml/browser/dist/schema/yaml-1.1/timestamp.js +101 -0
  109. package/node_modules/yaml/browser/dist/stringify/foldFlowLines.js +146 -0
  110. package/node_modules/yaml/browser/dist/stringify/stringify.js +129 -0
  111. package/node_modules/yaml/browser/dist/stringify/stringifyCollection.js +153 -0
  112. package/node_modules/yaml/browser/dist/stringify/stringifyComment.js +20 -0
  113. package/node_modules/yaml/browser/dist/stringify/stringifyDocument.js +85 -0
  114. package/node_modules/yaml/browser/dist/stringify/stringifyNumber.js +25 -0
  115. package/node_modules/yaml/browser/dist/stringify/stringifyPair.js +150 -0
  116. package/node_modules/yaml/browser/dist/stringify/stringifyString.js +336 -0
  117. package/node_modules/yaml/browser/dist/util.js +11 -0
  118. package/node_modules/yaml/browser/dist/visit.js +233 -0
  119. package/node_modules/yaml/browser/index.js +5 -0
  120. package/node_modules/yaml/browser/package.json +3 -0
  121. package/node_modules/yaml/dist/cli.d.ts +8 -0
  122. package/node_modules/yaml/dist/cli.mjs +201 -0
  123. package/node_modules/yaml/dist/compose/compose-collection.d.ts +11 -0
  124. package/node_modules/yaml/dist/compose/compose-collection.js +90 -0
  125. package/node_modules/yaml/dist/compose/compose-doc.d.ts +7 -0
  126. package/node_modules/yaml/dist/compose/compose-doc.js +45 -0
  127. package/node_modules/yaml/dist/compose/compose-node.d.ts +29 -0
  128. package/node_modules/yaml/dist/compose/compose-node.js +112 -0
  129. package/node_modules/yaml/dist/compose/compose-scalar.d.ts +5 -0
  130. package/node_modules/yaml/dist/compose/compose-scalar.js +88 -0
  131. package/node_modules/yaml/dist/compose/composer.d.ts +63 -0
  132. package/node_modules/yaml/dist/compose/composer.js +224 -0
  133. package/node_modules/yaml/dist/compose/resolve-block-map.d.ts +6 -0
  134. package/node_modules/yaml/dist/compose/resolve-block-map.js +117 -0
  135. package/node_modules/yaml/dist/compose/resolve-block-scalar.d.ts +11 -0
  136. package/node_modules/yaml/dist/compose/resolve-block-scalar.js +200 -0
  137. package/node_modules/yaml/dist/compose/resolve-block-seq.d.ts +6 -0
  138. package/node_modules/yaml/dist/compose/resolve-block-seq.js +51 -0
  139. package/node_modules/yaml/dist/compose/resolve-end.d.ts +6 -0
  140. package/node_modules/yaml/dist/compose/resolve-end.js +39 -0
  141. package/node_modules/yaml/dist/compose/resolve-flow-collection.d.ts +7 -0
  142. package/node_modules/yaml/dist/compose/resolve-flow-collection.js +209 -0
  143. package/node_modules/yaml/dist/compose/resolve-flow-scalar.d.ts +10 -0
  144. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +228 -0
  145. package/node_modules/yaml/dist/compose/resolve-props.d.ts +23 -0
  146. package/node_modules/yaml/dist/compose/resolve-props.js +148 -0
  147. package/node_modules/yaml/dist/compose/util-contains-newline.d.ts +2 -0
  148. package/node_modules/yaml/dist/compose/util-contains-newline.js +36 -0
  149. package/node_modules/yaml/dist/compose/util-empty-scalar-position.d.ts +2 -0
  150. package/node_modules/yaml/dist/compose/util-empty-scalar-position.js +28 -0
  151. package/node_modules/yaml/dist/compose/util-flow-indent-check.d.ts +3 -0
  152. package/node_modules/yaml/dist/compose/util-flow-indent-check.js +17 -0
  153. package/node_modules/yaml/dist/compose/util-map-includes.d.ts +4 -0
  154. package/node_modules/yaml/dist/compose/util-map-includes.js +15 -0
  155. package/node_modules/yaml/dist/doc/Document.d.ts +141 -0
  156. package/node_modules/yaml/dist/doc/Document.js +337 -0
  157. package/node_modules/yaml/dist/doc/anchors.d.ts +24 -0
  158. package/node_modules/yaml/dist/doc/anchors.js +76 -0
  159. package/node_modules/yaml/dist/doc/applyReviver.d.ts +9 -0
  160. package/node_modules/yaml/dist/doc/applyReviver.js +57 -0
  161. package/node_modules/yaml/dist/doc/createNode.d.ts +17 -0
  162. package/node_modules/yaml/dist/doc/createNode.js +90 -0
  163. package/node_modules/yaml/dist/doc/directives.d.ts +49 -0
  164. package/node_modules/yaml/dist/doc/directives.js +178 -0
  165. package/node_modules/yaml/dist/errors.d.ts +21 -0
  166. package/node_modules/yaml/dist/errors.js +62 -0
  167. package/node_modules/yaml/dist/index.d.ts +25 -0
  168. package/node_modules/yaml/dist/index.js +50 -0
  169. package/node_modules/yaml/dist/log.d.ts +3 -0
  170. package/node_modules/yaml/dist/log.js +19 -0
  171. package/node_modules/yaml/dist/nodes/Alias.d.ts +29 -0
  172. package/node_modules/yaml/dist/nodes/Alias.js +120 -0
  173. package/node_modules/yaml/dist/nodes/Collection.d.ts +73 -0
  174. package/node_modules/yaml/dist/nodes/Collection.js +151 -0
  175. package/node_modules/yaml/dist/nodes/Node.d.ts +53 -0
  176. package/node_modules/yaml/dist/nodes/Node.js +40 -0
  177. package/node_modules/yaml/dist/nodes/Pair.d.ts +22 -0
  178. package/node_modules/yaml/dist/nodes/Pair.js +39 -0
  179. package/node_modules/yaml/dist/nodes/Scalar.d.ts +47 -0
  180. package/node_modules/yaml/dist/nodes/Scalar.js +27 -0
  181. package/node_modules/yaml/dist/nodes/YAMLMap.d.ts +53 -0
  182. package/node_modules/yaml/dist/nodes/YAMLMap.js +147 -0
  183. package/node_modules/yaml/dist/nodes/YAMLSeq.d.ts +60 -0
  184. package/node_modules/yaml/dist/nodes/YAMLSeq.js +115 -0
  185. package/node_modules/yaml/dist/nodes/addPairToJSMap.d.ts +4 -0
  186. package/node_modules/yaml/dist/nodes/addPairToJSMap.js +65 -0
  187. package/node_modules/yaml/dist/nodes/identity.d.ts +23 -0
  188. package/node_modules/yaml/dist/nodes/identity.js +53 -0
  189. package/node_modules/yaml/dist/nodes/toJS.d.ts +29 -0
  190. package/node_modules/yaml/dist/nodes/toJS.js +39 -0
  191. package/node_modules/yaml/dist/options.d.ts +350 -0
  192. package/node_modules/yaml/dist/parse/cst-scalar.d.ts +64 -0
  193. package/node_modules/yaml/dist/parse/cst-scalar.js +218 -0
  194. package/node_modules/yaml/dist/parse/cst-stringify.d.ts +8 -0
  195. package/node_modules/yaml/dist/parse/cst-stringify.js +63 -0
  196. package/node_modules/yaml/dist/parse/cst-visit.d.ts +39 -0
  197. package/node_modules/yaml/dist/parse/cst-visit.js +99 -0
  198. package/node_modules/yaml/dist/parse/cst.d.ts +109 -0
  199. package/node_modules/yaml/dist/parse/cst.js +112 -0
  200. package/node_modules/yaml/dist/parse/lexer.d.ts +87 -0
  201. package/node_modules/yaml/dist/parse/lexer.js +723 -0
  202. package/node_modules/yaml/dist/parse/line-counter.d.ts +22 -0
  203. package/node_modules/yaml/dist/parse/line-counter.js +41 -0
  204. package/node_modules/yaml/dist/parse/parser.d.ts +84 -0
  205. package/node_modules/yaml/dist/parse/parser.js +980 -0
  206. package/node_modules/yaml/dist/public-api.d.ts +44 -0
  207. package/node_modules/yaml/dist/public-api.js +107 -0
  208. package/node_modules/yaml/dist/schema/Schema.d.ts +17 -0
  209. package/node_modules/yaml/dist/schema/Schema.js +39 -0
  210. package/node_modules/yaml/dist/schema/common/map.d.ts +2 -0
  211. package/node_modules/yaml/dist/schema/common/map.js +19 -0
  212. package/node_modules/yaml/dist/schema/common/null.d.ts +4 -0
  213. package/node_modules/yaml/dist/schema/common/null.js +17 -0
  214. package/node_modules/yaml/dist/schema/common/seq.d.ts +2 -0
  215. package/node_modules/yaml/dist/schema/common/seq.js +19 -0
  216. package/node_modules/yaml/dist/schema/common/string.d.ts +2 -0
  217. package/node_modules/yaml/dist/schema/common/string.js +16 -0
  218. package/node_modules/yaml/dist/schema/core/bool.d.ts +4 -0
  219. package/node_modules/yaml/dist/schema/core/bool.js +21 -0
  220. package/node_modules/yaml/dist/schema/core/float.d.ts +4 -0
  221. package/node_modules/yaml/dist/schema/core/float.js +47 -0
  222. package/node_modules/yaml/dist/schema/core/int.d.ts +4 -0
  223. package/node_modules/yaml/dist/schema/core/int.js +42 -0
  224. package/node_modules/yaml/dist/schema/core/schema.d.ts +1 -0
  225. package/node_modules/yaml/dist/schema/core/schema.js +25 -0
  226. package/node_modules/yaml/dist/schema/json/schema.d.ts +2 -0
  227. package/node_modules/yaml/dist/schema/json/schema.js +64 -0
  228. package/node_modules/yaml/dist/schema/json-schema.d.ts +69 -0
  229. package/node_modules/yaml/dist/schema/tags.d.ts +48 -0
  230. package/node_modules/yaml/dist/schema/tags.js +99 -0
  231. package/node_modules/yaml/dist/schema/types.d.ts +92 -0
  232. package/node_modules/yaml/dist/schema/yaml-1.1/binary.d.ts +2 -0
  233. package/node_modules/yaml/dist/schema/yaml-1.1/binary.js +70 -0
  234. package/node_modules/yaml/dist/schema/yaml-1.1/bool.d.ts +7 -0
  235. package/node_modules/yaml/dist/schema/yaml-1.1/bool.js +29 -0
  236. package/node_modules/yaml/dist/schema/yaml-1.1/float.d.ts +4 -0
  237. package/node_modules/yaml/dist/schema/yaml-1.1/float.js +50 -0
  238. package/node_modules/yaml/dist/schema/yaml-1.1/int.d.ts +5 -0
  239. package/node_modules/yaml/dist/schema/yaml-1.1/int.js +76 -0
  240. package/node_modules/yaml/dist/schema/yaml-1.1/merge.d.ts +9 -0
  241. package/node_modules/yaml/dist/schema/yaml-1.1/merge.js +71 -0
  242. package/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts +22 -0
  243. package/node_modules/yaml/dist/schema/yaml-1.1/omap.js +77 -0
  244. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.d.ts +10 -0
  245. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.js +82 -0
  246. package/node_modules/yaml/dist/schema/yaml-1.1/schema.d.ts +1 -0
  247. package/node_modules/yaml/dist/schema/yaml-1.1/schema.js +41 -0
  248. package/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts +28 -0
  249. package/node_modules/yaml/dist/schema/yaml-1.1/set.js +96 -0
  250. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.d.ts +6 -0
  251. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js +105 -0
  252. package/node_modules/yaml/dist/stringify/foldFlowLines.d.ts +34 -0
  253. package/node_modules/yaml/dist/stringify/foldFlowLines.js +151 -0
  254. package/node_modules/yaml/dist/stringify/stringify.d.ts +21 -0
  255. package/node_modules/yaml/dist/stringify/stringify.js +132 -0
  256. package/node_modules/yaml/dist/stringify/stringifyCollection.d.ts +17 -0
  257. package/node_modules/yaml/dist/stringify/stringifyCollection.js +155 -0
  258. package/node_modules/yaml/dist/stringify/stringifyComment.d.ts +10 -0
  259. package/node_modules/yaml/dist/stringify/stringifyComment.js +24 -0
  260. package/node_modules/yaml/dist/stringify/stringifyDocument.d.ts +4 -0
  261. package/node_modules/yaml/dist/stringify/stringifyDocument.js +87 -0
  262. package/node_modules/yaml/dist/stringify/stringifyNumber.d.ts +2 -0
  263. package/node_modules/yaml/dist/stringify/stringifyNumber.js +27 -0
  264. package/node_modules/yaml/dist/stringify/stringifyPair.d.ts +3 -0
  265. package/node_modules/yaml/dist/stringify/stringifyPair.js +152 -0
  266. package/node_modules/yaml/dist/stringify/stringifyString.d.ts +9 -0
  267. package/node_modules/yaml/dist/stringify/stringifyString.js +338 -0
  268. package/node_modules/yaml/dist/test-events.d.ts +4 -0
  269. package/node_modules/yaml/dist/test-events.js +134 -0
  270. package/node_modules/yaml/dist/util.d.ts +16 -0
  271. package/node_modules/yaml/dist/util.js +28 -0
  272. package/node_modules/yaml/dist/visit.d.ts +102 -0
  273. package/node_modules/yaml/dist/visit.js +236 -0
  274. package/node_modules/yaml/package.json +97 -0
  275. package/node_modules/yaml/util.js +2 -0
  276. package/package.json +54 -0
  277. package/profiles/card.md +65 -0
  278. package/profiles/pillars.md +31 -0
  279. package/profiles/system.md +56 -0
  280. package/schemas/card.schema.json +30 -0
  281. package/skills/gcf-adopt/SKILL.md +41 -0
  282. package/skills/gcf-audit/SKILL.md +27 -0
  283. package/skills/gcf-card/SKILL.md +35 -0
  284. package/skills/gcf-debate/SKILL.md +33 -0
  285. package/skills/gcf-ingest/SKILL.md +38 -0
  286. package/skills/gcf-init/SKILL.md +32 -0
  287. package/skills/gcf-verdict/SKILL.md +32 -0
  288. package/skills/gcf-vision/SKILL.md +33 -0
  289. package/templates/PILLARS.md +8 -0
  290. package/templates/backlog.md +4 -0
  291. package/templates/card.md +34 -0
  292. package/templates/cnlp.config.json +8 -0
  293. package/templates/config.yml +8 -0
  294. package/templates/system.md +22 -0
package/lib/context.js ADDED
@@ -0,0 +1,77 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
5
+
6
+ /**
7
+ * Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
8
+ * PILLARS + touched systems + the card + its depends + graveyard tag hits +
9
+ * D2 assumptions whose sys overlaps. Never over_budget-throws: the caller gates.
10
+ */
11
+ export async function buildContext(projectDir, cardId) {
12
+ const root = path.join(path.resolve(projectDir), 'gcf');
13
+ const config = await readConfig(root);
14
+ const cards = await listCards(root);
15
+ const card = cards.find((c) => c.data.id === cardId);
16
+ if (!card) throw new Error(`Card not found: ${cardId}`);
17
+
18
+ const files = [];
19
+ const push = async (rel) => {
20
+ const abs = path.join(root, rel);
21
+ if (!existsSync(abs)) return null;
22
+ const text = await readFile(abs, 'utf8');
23
+ const entry = { path: `gcf/${rel.replaceAll('\\', '/')}`, tokens_est: tokensEst(text) };
24
+ files.push(entry);
25
+ return text;
26
+ };
27
+
28
+ await push('PILLARS.md');
29
+ const systems = card.data.system ?? [];
30
+ for (const s of (await listSystems(root)).filter((d) => systems.includes(d.data.system))) {
31
+ await push(`systems/${s.name}`);
32
+ }
33
+ await push(`cards/${card.name}`);
34
+ for (const depId of card.data.depends ?? []) {
35
+ const dep = cards.find((c) => c.data.id === depId);
36
+ if (dep) await push(`cards/${dep.name}`);
37
+ }
38
+
39
+ // Graveyard: headline tags only; a hit is a tag appearing in the card's own text.
40
+ const graveyard_hits = [];
41
+ const gyFile = path.join(root, 'graveyard.md');
42
+ if (existsSync(gyFile)) {
43
+ const cardText = `${card.data.slug} ${systems.join(' ')} ${card.body}`.toLowerCase();
44
+ for (const line of (await readFile(gyFile, 'utf8')).split('\n')) {
45
+ const m = line.match(/^- (card-\d{3}) \| .* \| tags: (.+)$/);
46
+ if (!m) continue;
47
+ const tags = m[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
48
+ if (tags.some((t) => cardText.includes(t))) graveyard_hits.push(line.trim());
49
+ }
50
+ }
51
+
52
+ // D2 assumptions whose sys: overlaps the card's systems (GCF-PRD §9.1).
53
+ const d2 = [];
54
+ const oqFile = path.join(root, 'open-questions.md');
55
+ if (existsSync(oqFile)) {
56
+ const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
57
+ for (const rec of records) {
58
+ if (!/^\s*-?\s*id:/m.test(rec) || !/class:\s*D2/.test(rec)) continue;
59
+ const sys = rec.match(/sys:\s*(.+)/)?.[1] ?? '';
60
+ if (systems.some((s) => sys.includes(s))) {
61
+ d2.push(rec.trim());
62
+ }
63
+ }
64
+ }
65
+
66
+ const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
67
+ const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
68
+ return {
69
+ card: cardId,
70
+ files,
71
+ graveyard_hits,
72
+ d2,
73
+ total_tokens_est,
74
+ budget: config.context_budget,
75
+ over_budget: total_tokens_est > config.context_budget,
76
+ };
77
+ }
@@ -0,0 +1,26 @@
1
+ import YAML from 'yaml';
2
+
3
+ // Leading `---` YAML block, then the rest of the document (the body).
4
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
5
+
6
+ /**
7
+ * Split a Markdown document into `{ data, body, hasFrontmatter }`.
8
+ * `body` is preserved byte-for-byte; only frontmatter is re-serialized on write.
9
+ * Throws on a frontmatter block whose YAML is malformed (fail-safe: never guess).
10
+ */
11
+ export function parse(content) {
12
+ const m = content.match(FM_RE);
13
+ if (!m) return { data: {}, body: content, hasFrontmatter: false };
14
+ let data;
15
+ try {
16
+ data = YAML.parse(m[1]) ?? {};
17
+ } catch (err) {
18
+ throw new Error(`Frontmatter YAML could not be parsed: ${err.message}`);
19
+ }
20
+ return { data, body: m[2], hasFrontmatter: true };
21
+ }
22
+
23
+ /** Recompose a document from frontmatter `data` + untouched `body`. */
24
+ export function serialize(data, body) {
25
+ return `---\n${YAML.stringify(data)}---\n${body}`;
26
+ }
@@ -0,0 +1,60 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import YAML from 'yaml';
5
+ import { parse } from './frontmatter.js';
6
+
7
+ const DEFAULT_CONFIG = {
8
+ schema: 1,
9
+ built_stale_after_commits: 5,
10
+ tune_limit: 3,
11
+ question_stale_after_commits: 20,
12
+ wip_built_limit: 5,
13
+ context_budget: 30000,
14
+ pillars_budget: 1500,
15
+ envelope_default_spread: 0.3,
16
+ };
17
+
18
+ /** `gcf/` lives at the repository root (GCF-PRD §5.1). Throws when absent. */
19
+ export function gcfRoot(projectDir = process.cwd()) {
20
+ const root = path.join(path.resolve(projectDir), 'gcf');
21
+ if (!existsSync(root)) throw new Error(`No gcf/ directory in ${projectDir}. Run gcf init first.`);
22
+ return root;
23
+ }
24
+
25
+ export async function readConfig(root) {
26
+ const file = path.join(root, 'config.yml');
27
+ if (!existsSync(file)) return { ...DEFAULT_CONFIG };
28
+ return { ...DEFAULT_CONFIG, ...(YAML.parse(await readFile(file, 'utf8')) ?? {}) };
29
+ }
30
+
31
+ /** Token estimate: ceil(chars/4). Stability over accuracy (GCF-PRD §9.1). */
32
+ export function tokensEst(text) {
33
+ return Math.ceil(String(text).length / 4);
34
+ }
35
+
36
+ async function listDocs(dir) {
37
+ if (!existsSync(dir)) return [];
38
+ const out = [];
39
+ for (const name of (await readdir(dir)).filter((n) => n.endsWith('.md')).sort()) {
40
+ const file = path.join(dir, name);
41
+ const { data, body } = parse(await readFile(file, 'utf8'));
42
+ out.push({ file, name, data, body });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export const listCards = (root) => listDocs(path.join(root, 'cards'));
48
+ export const listSystems = (root) => listDocs(path.join(root, 'systems'));
49
+
50
+ /** First bullet of a `key:` block in a CNL-P body, for one-line descriptions. */
51
+ export function firstBullet(body, key) {
52
+ const m = String(body).match(new RegExp(`^${key}:\\s*\\n- (.+)$`, 'm'));
53
+ return m ? m[1].trim() : '';
54
+ }
55
+
56
+ /** Scalar value of a `key:` line. */
57
+ export function scalarOf(body, key) {
58
+ const m = String(body).match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
59
+ return m ? m[1].trim() : '';
60
+ }
package/lib/git.js ADDED
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Stage a lifecycle relocation so git reports it as a rename rather than a delete plus an
6
+ * untracked file: git derives the rename from content once both sides sit in the index.
7
+ * Silent no-op outside a work tree or when git is missing — the file operation has
8
+ * already succeeded and must not be undone by a staging failure.
9
+ */
10
+ export function stageMove(...files) {
11
+ const paths = files.filter(Boolean);
12
+ let staged = false;
13
+ // One `git add` per path: a single unmatched pathspec aborts the whole batch otherwise.
14
+ for (const file of paths) {
15
+ const res = spawnSync('git', ['add', '--', file], { cwd: path.dirname(file), stdio: 'ignore' });
16
+ if (res.status === 0) staged = true;
17
+ }
18
+ return staged;
19
+ }
package/lib/id.js ADDED
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+
3
+ // GCF-PRD §5.2/§5.5: candidates are `cand-NNN`, cards are `card-NNN`; a card keeps the
4
+ // candidate's number on materialization. The slug lives next to the id, not inside it.
5
+ const ID_RE = /^(card|cand)-\d{3}$/;
6
+
7
+ /** Derive a stable slug from a free-text name. Created once; never re-derived. */
8
+ export function slugify(name) {
9
+ const slug = String(name)
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '');
13
+ if (!slug) throw new Error(`Cannot derive a slug from: ${JSON.stringify(name)}`);
14
+ return slug;
15
+ }
16
+
17
+ export function isValidId(id) {
18
+ return ID_RE.test(String(id ?? ''));
19
+ }
20
+
21
+ /** Next free number across BOTH kinds: a card and a candidate never share a number. */
22
+ export function nextId(kind, existingIds) {
23
+ if (kind !== 'card' && kind !== 'cand') throw new Error(`Unknown id kind: ${kind}`);
24
+ let max = 0;
25
+ for (const id of existingIds) {
26
+ const m = String(id).match(/^(?:card|cand)-(\d{3})$/);
27
+ if (m) max = Math.max(max, Number(m[1]));
28
+ }
29
+ return `${kind}-${String(max + 1).padStart(3, '0')}`;
30
+ }
31
+
32
+ /** `cand-012` → `card-012`: materialization keeps the number (GCF-PRD §5.2). */
33
+ export function candToCard(candId) {
34
+ const m = String(candId).match(/^cand-(\d{3})$/);
35
+ if (!m) throw new Error(`Not a candidate id: ${candId}`);
36
+ return `card-${m[1]}`;
37
+ }
38
+
39
+ /** Card filename invariant: `cards/<id>-<slug>.md`. */
40
+ export function stemMatches(file, id, slug) {
41
+ return path.basename(file, '.md') === `${id}-${slug}`;
42
+ }
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { listCards, listSystems, firstBullet } from './gcf-paths.js';
5
+ import { atomicWrite } from './safe-path.js';
6
+
7
+ /**
8
+ * INDEX.md is a deterministic function of gcf/ contents (GCF-PRD §5.1): generated,
9
+ * never hand-written. The pre-commit hook regenerates it and stages the result.
10
+ */
11
+ export async function generate(root) {
12
+ const lines = ['# INDEX — generated, do not edit', ''];
13
+ const row = (type, file, status, system, descr) =>
14
+ lines.push(`${type} | gcf/${file} | ${status} | ${system} | ${descr}`);
15
+
16
+ if (existsSync(path.join(root, 'PILLARS.md'))) row('pillars', 'PILLARS.md', '-', '-', 'pillars and negative invariants');
17
+ if (existsSync(path.join(root, 'backlog.md'))) {
18
+ const count = ((await readFile(path.join(root, 'backlog.md'), 'utf8')).match(/^- cand-/gm) ?? []).length;
19
+ row('backlog', 'backlog.md', '-', '-', `${count} candidates`);
20
+ }
21
+ for (const s of await listSystems(root)) {
22
+ row('system', `systems/${s.name}`, '-', s.data.system ?? '-', firstBullet(s.body, 'purpose'));
23
+ }
24
+ for (const c of await listCards(root)) {
25
+ row('card', `cards/${c.name}`, c.data.status ?? '?', (c.data.system ?? []).join(','), firstBullet(c.body, 'behavior'));
26
+ }
27
+ if (existsSync(path.join(root, 'graveyard.md'))) {
28
+ const count = ((await readFile(path.join(root, 'graveyard.md'), 'utf8')).match(/^- card-/gm) ?? []).length;
29
+ row('graveyard', 'graveyard.md', '-', '-', `${count} killed ideas`);
30
+ }
31
+ if (existsSync(path.join(root, 'open-questions.md'))) {
32
+ const txt = await readFile(path.join(root, 'open-questions.md'), 'utf8');
33
+ const d1 = (txt.match(/^\s*class:\s*D1/gm) ?? []).length;
34
+ const d2 = (txt.match(/^\s*class:\s*D2/gm) ?? []).length;
35
+ row('questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`);
36
+ }
37
+ return `${lines.join('\n')}\n`;
38
+ }
39
+
40
+ export async function writeIndex(root) {
41
+ const content = await generate(root);
42
+ await atomicWrite(path.join(root, 'INDEX.md'), content);
43
+ return content;
44
+ }
45
+
46
+ /** True when INDEX.md on disk differs from the regenerated one (audit check). */
47
+ export async function drifted(root) {
48
+ const file = path.join(root, 'INDEX.md');
49
+ if (!existsSync(file)) return true;
50
+ return (await readFile(file, 'utf8')) !== (await generate(root));
51
+ }
package/lib/linter.js ADDED
@@ -0,0 +1,61 @@
1
+ // П2 linter (GCF-PRD §13.2): explicit patterns over systems docs, no ML.
2
+ // `<!-- gcf:allow <reason> -->` on the preceding line suppresses the next line's finding;
3
+ // a pragma without a reason is itself a finding. cnlp check runs separately (form/lexicon).
4
+
5
+ const UNIT_NUM = /\d+(?:[.,]\d+)?\s?(?:ms|мс|s|с|dmg|hp|px|сек)\b/;
6
+ const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
7
+ const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
8
+
9
+ /** Which `key:` block a line sits in (flat CNL-P body). */
10
+ function blockOf(lines, i) {
11
+ for (let j = i; j >= 0; j--) {
12
+ const m = lines[j].match(/^([a-z_]+):\s*$/) ?? lines[j].match(/^([a-z_]+):\s+\S/);
13
+ if (m) return m[1];
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function lint(content) {
19
+ const lines = String(content).split('\n');
20
+ const issues = [];
21
+ const pragmas = [];
22
+ let fenceStart = -1;
23
+
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const line = lines[i];
26
+ const prev = lines[i - 1] ?? '';
27
+
28
+ const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
29
+ if (pragma && !pragma[1]) issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
30
+ const allowed = Boolean(pragma && pragma[1]);
31
+ if (pragma) pragmas.push({ line: i, reason: pragma[1] || null });
32
+
33
+ if (line.startsWith('```')) {
34
+ if (fenceStart < 0) fenceStart = i;
35
+ else {
36
+ if (i - fenceStart - 1 > 3) issues.push({ line: fenceStart + 1, rule: 'fenced-code-over-3-lines', text: `${i - fenceStart - 1} lines` });
37
+ fenceStart = -1;
38
+ }
39
+ continue;
40
+ }
41
+ if (fenceStart >= 0 || allowed) continue;
42
+
43
+ const block = blockOf(lines, i);
44
+ if (block !== 'feel' && UNIT_NUM.test(line)) {
45
+ issues.push({ line: i + 1, rule: 'number-with-unit-outside-feel', text: line.trim() });
46
+ }
47
+ if (SIGNATURE.test(line)) {
48
+ issues.push({ line: i + 1, rule: 'signature', text: line.trim() });
49
+ }
50
+ // Three or more consecutive bullets each naming CamelCase identifiers = a member list.
51
+ if (/^- /.test(line) && CAMEL.test(line)
52
+ && /^- /.test(prev) && CAMEL.test(prev)
53
+ && /^- /.test(lines[i - 2] ?? '') && CAMEL.test(lines[i - 2] ?? '')) {
54
+ issues.push({ line: i + 1, rule: 'camelcase-member-list', text: line.trim() });
55
+ }
56
+ }
57
+ // An unclosed fence leaves fenceStart set, so the loop above `continue`d past every
58
+ // remaining line: one stray fence would otherwise disable П2 for the rest of the file.
59
+ if (fenceStart >= 0) issues.push({ line: fenceStart + 1, rule: 'unterminated-fence', text: 'code fence never closed' });
60
+ return { issues, pragmas };
61
+ }
@@ -0,0 +1,13 @@
1
+ import { listCards, scalarOf } from './gcf-paths.js';
2
+
3
+ /** Playtest list (GCF-PRD §10): every built card with its miss-criterion question. */
4
+ export async function playtestList(root) {
5
+ return (await listCards(root))
6
+ .filter((c) => c.data.status === 'built')
7
+ .map((c) => ({
8
+ id: c.data.id,
9
+ slug: c.data.slug,
10
+ system: c.data.system ?? [],
11
+ question: scalarOf(c.body, 'miss_criterion') || '(miss_criterion is empty)',
12
+ }));
13
+ }
@@ -0,0 +1,71 @@
1
+ import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { stageMove } from './git.js';
4
+
5
+ /**
6
+ * Resolve `target` and assert it stays inside `projectDir` (reject traversal / outside-project).
7
+ * Throws on escape; returns the absolute resolved path otherwise.
8
+ * Deliberately not wired into `gcf anchor`: a GDD legitimately lives outside the project
9
+ * and is copied INTO it. Use this for any path that must stay inside the tree.
10
+ */
11
+ export function resolveInside(projectDir, target) {
12
+ const base = path.resolve(projectDir);
13
+ const resolved = path.resolve(base, target);
14
+ const rel = path.relative(base, resolved);
15
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
16
+ throw new Error(
17
+ 'Path escapes the project.\n' +
18
+ ` expected: a path inside ${base}\n` +
19
+ ` detected: ${target} → ${resolved}\n` +
20
+ ' files: nothing was changed\n' +
21
+ ' next: pass a path within the project root',
22
+ );
23
+ }
24
+ return resolved;
25
+ }
26
+
27
+ /** Write atomically: tmp file in the same dir, then rename over the target. */
28
+ export async function atomicWrite(file, data) {
29
+ // pid+random: writeIndex is reachable from both the pre-commit hook and applyVerdict,
30
+ // and a shared `${file}.tmp` lets one rename publish the other's half-written content.
31
+ const tmp = `${file}.${process.pid}-${Math.random().toString(36).slice(2, 8)}.tmp`;
32
+ await mkdir(path.dirname(file), { recursive: true });
33
+ await writeFile(tmp, data, 'utf8');
34
+ try {
35
+ await rename(tmp, file);
36
+ } catch (err) {
37
+ await unlink(tmp).catch(() => {});
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /** Restore every listed file to its original bytes when `action` fails. */
43
+ export async function withFileRollback(files, action) {
44
+ const snapshots = [];
45
+ for (const file of new Set(files)) {
46
+ try {
47
+ snapshots.push([file, await readFile(file, 'utf8')]);
48
+ } catch (err) {
49
+ if (err.code !== 'ENOENT') throw err;
50
+ snapshots.push([file, null]);
51
+ }
52
+ }
53
+
54
+ try {
55
+ return await action();
56
+ } catch (err) {
57
+ const rollbackErrors = [];
58
+ for (const [file, content] of snapshots) {
59
+ try {
60
+ if (content === null) await unlink(file).catch((e) => { if (e.code !== 'ENOENT') throw e; });
61
+ else await atomicWrite(file, content);
62
+ } catch (rollbackError) {
63
+ rollbackErrors.push(rollbackError);
64
+ }
65
+ }
66
+ // An inner step may already have staged its move; realign the index with the restored tree.
67
+ stageMove(...snapshots.map(([file]) => file));
68
+ if (rollbackErrors.length) throw new AggregateError([err, ...rollbackErrors], `Operation failed and rollback was incomplete: ${err.message}`);
69
+ throw err;
70
+ }
71
+ }
@@ -0,0 +1,30 @@
1
+ // GCF-PRD §7.1. `none` = materialization (no card file yet).
2
+ // `validated` and `killed` are terminal and NEVER stored: the verdict that produces them
3
+ // also deletes the card file, so a card file carrying one is a П1 violation (pre-commit check).
4
+ const STORED = new Set(['proposed', 'built', 'tuning']);
5
+ export const TERMINAL = new Set(['validated', 'killed']);
6
+
7
+ const LEGAL = new Set([
8
+ 'none>proposed',
9
+ 'proposed>built', // agent flips frontmatter itself on implementation commit (GCF-PRD §5.1)
10
+ 'built>tuning', // /gcf-verdict tune
11
+ 'tuning>built', // agent re-built after tuning
12
+ 'built>validated', // /gcf-verdict keep — file is deleted in the same atomic commit
13
+ 'built>killed', // /gcf-verdict kill — file is deleted in the same atomic commit
14
+ ]);
15
+
16
+ export function isLegal(from, to) {
17
+ return LEGAL.has(`${from}>${to}`);
18
+ }
19
+
20
+ /** Statuses reachable from `from`, for error messages. */
21
+ export function legalTargets(from) {
22
+ return [...LEGAL]
23
+ .filter((t) => t.startsWith(`${from}>`))
24
+ .map((t) => t.split('>')[1]);
25
+ }
26
+
27
+ /** A status a card FILE may carry. Terminal statuses exist only in the transition itself. */
28
+ export function isStorable(status) {
29
+ return STORED.has(status);
30
+ }
@@ -0,0 +1,48 @@
1
+ // Runtime mirror of schemas/card.schema.json for the pre-commit hook (no ajv in
2
+ // production deps). The parity test keeps the two in agreement.
3
+ const FIELDS = ['schema', 'id', 'slug', 'status', 'route', 'system', 'depends', 'source', 'tune_count', 'built_at', 'created'];
4
+ const REQUIRED = FIELDS.filter((f) => f !== 'built_at');
5
+
6
+ const RULES = {
7
+ schema: (v) => v === 1 || 'schema must be 1',
8
+ id: (v) => /^card-\d{3}$/.test(v) || 'id must match card-NNN',
9
+ slug: (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) || 'slug must be a lowercase hyphen slug',
10
+ status: (v) => ['proposed', 'built', 'tuning'].includes(v) || `status "${v}" is not storable (П1: terminal statuses never live in a file)`,
11
+ route: (v) => ['soft', 'hard'].includes(v) || 'route must be soft or hard',
12
+ system: (v) => (Array.isArray(v) && v.length >= 1 && v.every((s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s))) || 'system must list >=1 subsystem slugs',
13
+ depends: (v) => (Array.isArray(v) && v.every((d) => /^card-\d{3}$/.test(d))) || 'depends must list card-NNN ids',
14
+ source: (v) => /^(GDD#[A-Za-z0-9_-]+|manual|inferred)$/.test(v) || 'source must be GDD#<id>, manual or inferred',
15
+ tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
16
+ built_at: (v) => v === null || v === undefined || /^[0-9a-f]{7,40}$/.test(v) || 'built_at must be null or a commit hash',
17
+ created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
18
+ };
19
+
20
+ const dateStr = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v));
21
+
22
+ const UNTIL_OK = /^\[until:\s*(verdict\(card-\d{3}\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]$/;
23
+
24
+ /** Validate card frontmatter. Returns a list of one-line errors (empty = valid). */
25
+ export function validateCard(data) {
26
+ const errors = [];
27
+ for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
28
+ for (const key of Object.keys(data)) {
29
+ if (!FIELDS.includes(key)) errors.push(`unknown field: ${key}${key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''}`);
30
+ else if (key in RULES) {
31
+ const res = RULES[key](data[key]);
32
+ if (res !== true) errors.push(res);
33
+ }
34
+ }
35
+ return errors;
36
+ }
37
+
38
+ /** Validate every `[until: ...]` annotation in a body against the trigger grammar (§7.3). */
39
+ export function validateUntil(body) {
40
+ const errors = [];
41
+ const lines = String(body).split('\n');
42
+ lines.forEach((line, i) => {
43
+ for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
44
+ if (!UNTIL_OK.test(m[0])) errors.push(`line ${i + 1}: bad until annotation ${m[0]} (use verdict(card-NNN), retune(system) or manual(text))`);
45
+ }
46
+ });
47
+ return errors;
48
+ }
@@ -0,0 +1,126 @@
1
+ import { readFile, unlink } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { parse, serialize } from './frontmatter.js';
6
+ import { listCards } from './gcf-paths.js';
7
+ import { atomicWrite, withFileRollback } from './safe-path.js';
8
+ import { writeIndex } from './index-gen.js';
9
+ import { isLegal } from './transitions.js';
10
+
11
+ // Slot → systems-doc section map (GCF-PRD §8.2). Deterministic merge: lines are
12
+ // carried verbatim and deduplicated; the LLM compression of `behavior` happens in the
13
+ // card BEFORE the verdict (the skill edits the card), not here.
14
+ const MERGE_MAP = [
15
+ ['behavior', 'behavior'],
16
+ ['contract', 'code'],
17
+ ['feel', 'feel'],
18
+ ['constraints', 'constraints'],
19
+ ];
20
+
21
+ function blockLines(body, key) {
22
+ const m = String(body).match(new RegExp(`^${key}:\\s*\\n((?:- .*\\n?)*)`, 'm'));
23
+ return m ? m[1].split('\n').filter((l) => l.startsWith('- ')) : [];
24
+ }
25
+
26
+ function upsertBlock(body, key, addLines) {
27
+ if (!addLines.length) return body;
28
+ const existing = new Set(blockLines(body, key));
29
+ const fresh = addLines.filter((l) => !existing.has(l));
30
+ if (!fresh.length) return body;
31
+ const re = new RegExp(`(^${key}:\\s*\\n(?:- .*\\n?)*)`, 'm');
32
+ if (re.test(body)) return body.replace(re, (blk) => `${blk.endsWith('\n') ? blk : `${blk}\n`}${fresh.join('\n')}\n`);
33
+ return `${body.trimEnd()}\n\n${key}:\n${fresh.join('\n')}\n`;
34
+ }
35
+
36
+ // `git commit --only <paths>` is a partial commit, which git refuses outright while a merge
37
+ // or a sequencer operation is in progress. Detect it up front so the failure has a reason.
38
+ const IN_PROGRESS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply'];
39
+ function operationInProgress(projectDir) {
40
+ const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: projectDir, encoding: 'utf8' });
41
+ if (gitDir.status !== 0) return null;
42
+ const dir = path.resolve(projectDir, gitDir.stdout.trim());
43
+ return IN_PROGRESS.find((name) => existsSync(path.join(dir, name))) ?? null;
44
+ }
45
+
46
+ function gitCommit(projectDir, message, addPaths) {
47
+ const busy = operationInProgress(projectDir);
48
+ if (busy) throw new Error(`cannot record a verdict while ${busy} is in progress — finish or abort it first`);
49
+ // Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
50
+ // user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
51
+ for (const p of addPaths) spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
52
+ const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...addPaths], { cwd: projectDir, encoding: 'utf8' });
53
+ if (res.status !== 0) throw new Error(`verdict commit failed: ${res.stderr || res.stdout}`);
54
+ return spawnSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8' }).stdout.trim();
55
+ }
56
+
57
+ /**
58
+ * The one operation that collapses a card (GCF-PRD §8). Atomic: file changes roll back
59
+ * on failure; the verdict commit has the fixed greppable format. `keep` merges into every
60
+ * touched systems doc; `kill` writes the graveyard line; `tune` bumps the counter and
61
+ * appends the human note to the feel slot.
62
+ *
63
+ * The commit runs INSIDE the rollback: it fires the pre-commit gate, which can reject the
64
+ * merged systems doc, and a card unlinked by a commit that never landed is a lost card.
65
+ */
66
+ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tags = [], commit = true } = {}) {
67
+ const root = path.join(path.resolve(projectDir), 'gcf');
68
+ const card = (await listCards(root)).find((c) => c.data.id === cardId);
69
+ if (!card) throw new Error(`Card not found: ${cardId}`);
70
+ const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
71
+ if (!target) throw new Error(`Unknown verdict: ${verdict}`);
72
+ if (!isLegal(card.data.status, target)) {
73
+ throw new Error(`Illegal transition ${card.data.status} -> ${target} for ${cardId} (verdict needs status=built)`);
74
+ }
75
+
76
+ const touched = [card.file, path.join(root, 'INDEX.md')];
77
+ const systems = card.data.system ?? [];
78
+ let mutate;
79
+
80
+ if (verdict === 'tune') {
81
+ mutate = async () => {
82
+ const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
83
+ const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
84
+ await atomicWrite(card.file, serialize(data, body));
85
+ };
86
+ } else if (verdict === 'kill') {
87
+ const gyFile = path.join(root, 'graveyard.md');
88
+ touched.push(gyFile);
89
+ mutate = async () => {
90
+ const reason = note || 'no reason recorded';
91
+ const tagStr = (tags.length ? tags : systems).join(', ');
92
+ const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
93
+ const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
94
+ await atomicWrite(gyFile, existing + line);
95
+ await unlink(card.file);
96
+ };
97
+ } else {
98
+ const sysFiles = systems.map((s) => path.join(root, 'systems', `${s}.md`));
99
+ touched.push(...sysFiles);
100
+ mutate = async () => {
101
+ for (const [i, sysFile] of sysFiles.entries()) {
102
+ let doc = existsSync(sysFile)
103
+ ? parse(await readFile(sysFile, 'utf8'))
104
+ : { data: { system: systems[i] }, body: 'purpose:\n- (fill in)\n' };
105
+ let body = doc.body;
106
+ for (const [slot, section] of MERGE_MAP) {
107
+ body = upsertBlock(body, section, blockLines(card.body, slot));
108
+ }
109
+ // Lines with a live until: move to `open` (GCF-PRD §8.2.5).
110
+ const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
111
+ body = upsertBlock(body, 'open', openLines);
112
+ await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
113
+ }
114
+ await unlink(card.file);
115
+ };
116
+ }
117
+
118
+ const message = `gcf(verdict): ${verdict} ${cardId} ${card.data.slug} → ${systems.join(',')}`;
119
+ let hash = null;
120
+ await withFileRollback(touched, async () => {
121
+ await mutate();
122
+ await writeIndex(root);
123
+ if (commit) hash = gitCommit(projectDir, message, touched);
124
+ });
125
+ return { verdict, card: cardId, systems, hash };
126
+ }