@gobing-ai/spur 0.3.94 → 0.3.95

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 (140) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +42 -0
  3. package/board/index.d.ts +38 -0
  4. package/config/plugin-scripts.json +5 -0
  5. package/config/workflows/feature-verification.yaml +7 -4
  6. package/config/workflows/idea-pipeline.yaml +10 -4
  7. package/config/workflows/task-pipeline.yaml +54 -26
  8. package/config/workflows/wrapup-pipeline.yaml +92 -17
  9. package/package.json +15 -11
  10. package/plugins/sp/commands/dev-verify.md +7 -4
  11. package/plugins/sp/commands/dev-verifyall.md +4 -3
  12. package/plugins/sp/commands/dev-wrap.md +1 -1
  13. package/plugins/sp/hooks/context-post-tool.ts +2 -2
  14. package/plugins/sp/hooks/context-session-start.ts +2 -1
  15. package/plugins/sp/hooks/context-session-stop.ts +22 -14
  16. package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
  17. package/plugins/sp/lib/idea-handoff.generated.mjs +2 -2
  18. package/plugins/sp/lib/inline-run.generated.d.mts +2 -2
  19. package/plugins/sp/lib/inline-run.generated.mjs +10 -10
  20. package/plugins/sp/plugin.json +1 -1
  21. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
  22. package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
  23. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
  24. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
  25. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
  26. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
  27. package/plugins/sp/scripts/feature-sync-bounded.mjs +5 -2
  28. package/plugins/sp/scripts/feature-sync-bounded.ts +3 -1
  29. package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
  30. package/plugins/sp/scripts/history-anatomy-cache.mjs +3 -3
  31. package/plugins/sp/scripts/history-anatomy-cache.ts +4 -4
  32. package/plugins/sp/scripts/inline-run-setup.mjs +23 -13
  33. package/plugins/sp/scripts/inline-run-setup.ts +60 -27
  34. package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
  35. package/plugins/sp/scripts/pr-reviewing.ts +1 -0
  36. package/plugins/sp/scripts/quality-gate.mjs +4 -2
  37. package/plugins/sp/scripts/quality-gate.ts +8 -2
  38. package/plugins/sp/scripts/residual-scan.mjs +3 -3
  39. package/plugins/sp/scripts/residual-scan.ts +3 -3
  40. package/plugins/sp/scripts/script-contract-check.ts +175 -46
  41. package/plugins/sp/scripts/script-root.mjs +125 -0
  42. package/plugins/sp/scripts/script-root.ts +195 -0
  43. package/plugins/sp/scripts/stage-registry-adapter.ts +1 -1
  44. package/plugins/sp/scripts/task-evidence-precheck.ts +4 -2
  45. package/plugins/sp/scripts/task-size-precheck.ts +3 -1
  46. package/plugins/sp/scripts/validate-commands.ts +12 -12
  47. package/plugins/sp/scripts/validate-flag-contracts.ts +42 -30
  48. package/plugins/sp/scripts/verify-answer-lint.ts +3 -1
  49. package/plugins/sp/scripts/workflow-step-profile.mjs +5 -2
  50. package/plugins/sp/scripts/workflow-step-profile.ts +3 -1
  51. package/plugins/sp/scripts/wrapup-drift-probe.mjs +5 -2
  52. package/plugins/sp/scripts/wrapup-drift-probe.ts +4 -2
  53. package/plugins/sp/scripts/wrapup-steps.mjs +32 -8
  54. package/plugins/sp/scripts/wrapup-steps.ts +56 -10
  55. package/plugins/sp/skills/code-verification/SKILL.md +12 -7
  56. package/plugins/sp/skills/code-verification/references/verdict-schema.md +11 -0
  57. package/plugins/sp/skills/functional-review/SKILL.md +1 -1
  58. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  59. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +9 -1
  60. package/plugins/sp/skills/spur-dev/references/execution-batch.md +30 -10
  61. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +6 -3
  62. package/plugins/sp/tsconfig.json +8 -0
  63. package/spur.js +5194 -4457
  64. package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.BxJuwD7I.js} +150 -153
  65. package/web/_astro/BoardApp.Cpxntzad.js +1 -0
  66. package/web/_astro/TaskDetail.C5bW4WGV.js +1 -0
  67. package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
  68. package/web/_astro/{arc.uuAf51IT.js → arc.CyjRvNMY.js} +1 -1
  69. package/web/_astro/architectureDiagram-3BPJPVTR.B4lWRzJA.js +36 -0
  70. package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.Be38USQ2.js} +1 -1
  71. package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
  72. package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
  73. package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
  74. package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
  75. package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
  76. package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
  77. package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
  78. package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DT9Fj5Qx.js} +1 -1
  79. package/web/_astro/channel.CI6N_tCg.js +1 -0
  80. package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.SeSBWLg5.js} +1 -1
  81. package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.DoO4VE14.js} +1 -1
  82. package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.DrmTFA53.js} +1 -1
  83. package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.Bi-TUb_V.js} +1 -1
  84. package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.B-xp8Uhi.js} +1 -1
  85. package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.DGWrDnUU.js} +1 -1
  86. package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
  87. package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM._BagvPDy.js} +1 -1
  88. package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.VOboYWQ0.js} +1 -1
  89. package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
  90. package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.D_apfHS7.js} +1 -1
  91. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.D_apfHS7.js} +1 -1
  92. package/web/_astro/client.BhluLQqe.js +1 -0
  93. package/web/_astro/client.DBpSRLnx.js +9 -0
  94. package/web/_astro/cose-bilkent-S5V4N54A.DNLU_L-x.js +1 -0
  95. package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.D8borhV-.js} +1 -1
  96. package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.Uc0lvjcV.js} +1 -1
  97. package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.DDIxtDBo.js} +1 -1
  98. package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.DVoDnD3H.js} +1 -1
  99. package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.CRubTJ-0.js} +1 -1
  100. package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.m9SeYcXQ.js} +1 -1
  101. package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.C6s2ZjV5.js} +1 -1
  102. package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.nSzr5KTj.js} +1 -1
  103. package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DrZcys16.js} +1 -1
  104. package/web/_astro/ganttDiagram-6RSMTGT7.Do4M1b-K.js +292 -0
  105. package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.BWc9oJ4l.js} +1 -1
  106. package/web/_astro/index.5dn-Zm-2.js +1 -0
  107. package/web/_astro/index.C-IqPpW2.js +1 -0
  108. package/web/_astro/index.CENnIEqT.css +1 -0
  109. package/web/_astro/index.Cyc_GA4f.js +1 -0
  110. package/web/_astro/index.tZfqwSut.js +1 -0
  111. package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.BIdOctFk.js} +1 -1
  112. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.CV5SuG2t.js} +1 -1
  113. package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.CxE-rEJ-.js} +1 -1
  114. package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
  115. package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.BPRdKWs_.js} +1 -1
  116. package/web/_astro/{linear.Bb-3a1d7.js → linear.zRsuuDTE.js} +1 -1
  117. package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.jAJTcMKc.js} +4 -4
  118. package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.BffJxfCr.js} +1 -1
  119. package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.CacwWaE3.js} +1 -1
  120. package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.hXELD06-.js} +1 -1
  121. package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.DdEbPcqT.js} +1 -1
  122. package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.DgaDocKv.js} +1 -1
  123. package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.B0KScnAu.js} +1 -1
  124. package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.CV9M_WNc.js} +1 -1
  125. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.BOAo74Et.js} +1 -1
  126. package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.CqqepUMe.js} +1 -1
  127. package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.DDqe4dOG.js} +1 -1
  128. package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.B3R9OPdO.js} +1 -1
  129. package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3SvgXpQ.js} +1 -1
  130. package/web/board-runtime.json +69 -0
  131. package/web/index.html +2 -2
  132. package/web/_astro/BoardApp.C02hAHPO.js +0 -1
  133. package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
  134. package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
  135. package/web/_astro/channel.fsgl7o5j.js +0 -1
  136. package/web/_astro/client.yhYJvxCU.js +0 -9
  137. package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
  138. package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
  139. package/web/_astro/index.De90oHcH.js +0 -1
  140. package/web/_astro/index.Hjbr15fG.css +0 -1
@@ -670,16 +670,22 @@ export function runQualityGate(
670
670
  export const QUALITY_GATE_USAGE =
671
671
  'usage: quality-gate.ts <run|recheck|light|status> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest, runId)';
672
672
 
673
- export function main(argv: string[], env: QualityGateEnv = getEnvVars(), options: QualityGateOptions = {}): number {
673
+ export function main(
674
+ argv: string[],
675
+ rawEnv: Partial<QualityGateEnv> = getEnvVars(),
676
+ options: QualityGateOptions = {},
677
+ ): number {
674
678
  const mode = argv[0];
675
679
  if (mode !== 'run' && mode !== 'recheck' && mode !== 'light' && mode !== 'status') {
676
680
  process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
677
681
  return 2;
678
682
  }
679
- if ((env.wbs ?? '').length === 0) {
683
+ const wbs = rawEnv.wbs ?? '';
684
+ if (wbs.length === 0) {
680
685
  process.stderr.write('quality-gate: env `wbs` is required\n');
681
686
  return 2;
682
687
  }
688
+ const env: QualityGateEnv = { ...rawEnv, wbs };
683
689
  if (mode === 'light') {
684
690
  runLightGate(env);
685
691
  } else if (mode === 'status') {
@@ -47,14 +47,14 @@ function locationOf(locationCell, finding) {
47
47
  const cell = locationCell.trim().replace(/`/g, "");
48
48
  if (cell.length > 0 && cell !== "\u2014")
49
49
  return normalizeAnchor(cell);
50
- const backtick = finding.match(/`([^`]+)`/);
51
- return backtick === null ? "" : normalizeAnchor(backtick[1]);
50
+ const backtick = finding.match(/`([^`]+)`/)?.[1];
51
+ return backtick === undefined ? "" : normalizeAnchor(backtick);
52
52
  }
53
53
  function parseReviewFindings(taskContent) {
54
54
  const section = taskContent.split(/^### Review\b/m)[1];
55
55
  if (section === undefined)
56
56
  return [];
57
- const body = section.split(/^### /m)[0];
57
+ const body = section.split(/^### /m)[0] ?? "";
58
58
  const out = [];
59
59
  const lines = body.split(`
60
60
  `);
@@ -105,8 +105,8 @@ export function normalizeAnchor(location: string): string {
105
105
  export function locationOf(locationCell: string, finding: string): string {
106
106
  const cell = locationCell.trim().replace(/`/g, '');
107
107
  if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
108
- const backtick = finding.match(/`([^`]+)`/);
109
- return backtick === null ? '' : normalizeAnchor(backtick[1]);
108
+ const backtick = finding.match(/`([^`]+)`/)?.[1];
109
+ return backtick === undefined ? '' : normalizeAnchor(backtick);
110
110
  }
111
111
 
112
112
  /**
@@ -120,7 +120,7 @@ export function parseReviewFindings(
120
120
  ): Array<{ priority: string; location: string; text: string; deferral?: string }> {
121
121
  const section = taskContent.split(/^### Review\b/m)[1];
122
122
  if (section === undefined) return [];
123
- const body = section.split(/^### /m)[0];
123
+ const body = section.split(/^### /m)[0] ?? '';
124
124
  const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
125
125
  const lines = body.split('\n');
126
126
  for (let i = 0; i < lines.length; i++) {
@@ -4,7 +4,7 @@
4
4
  * (task 0600, feature I, ADR-065).
5
5
  *
6
6
  * Enforces superskill's standard plugin script contract across plugins/sp:
7
- * 1. A 'standard' entry must have a valid .mjs twin not older than its .ts source.
7
+ * 1. A 'standard' entry must have a valid .mjs twin byte-identical to a fresh convert of its .ts source.
8
8
  * 2. A .mjs twin on disk must be registered under a 'standard' entry (never 'repo-only' or unlisted).
9
9
  * 3. Every script file under plugins/sp/scripts/ must have a manifest entry (two-sided).
10
10
  * 4. No shipped surface (commands/, skills/, agents/, README.md) may reference 'bun plugins/sp/scripts/'.
@@ -18,8 +18,10 @@
18
18
  * Exit code: 0 on success, 1 on any violation.
19
19
  */
20
20
 
21
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
22
- import { join, resolve } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { dirname, join, resolve } from 'node:path';
23
25
 
24
26
  export type ScriptContractType = 'standard' | 'repo-only';
25
27
 
@@ -42,6 +44,8 @@ export interface Violation {
42
44
  | 'unexpected_twin'
43
45
  | 'unregistered_script'
44
46
  | 'forbidden_invocation'
47
+ | 'converter_unavailable'
48
+ | 'gobing_ai_import'
45
49
  | 'incomplete';
46
50
  target: string;
47
51
  message: string;
@@ -116,12 +120,24 @@ export function listDiskScripts(scriptsDir: string): { tsFiles: string[]; mjsFil
116
120
 
117
121
  export function scanShippedSurfaces(pluginDir: string): Array<{ file: string; line: number; content: string }> {
118
122
  const matches: Array<{ file: string; line: number; content: string }> = [];
119
- const forbiddenPattern = 'bun plugins/sp/scripts/';
120
-
121
- const searchDirs = ['commands', 'skills', 'agents'];
122
- const singleFiles = ['README.md'];
123
+ const guardMarker = 'config/plugin-scripts.json';
124
+
125
+ // 0960 R5: the probe shapes the guard idiom introduces, plus the legacy literal. A hit is
126
+ // legal only when its line (or, for a YAML command block, its folded block) names the
127
+ // source-repo marker — otherwise it is a project-first probe that shadows the installed
128
+ // twin in a consumer.
129
+ const projectFirstPatterns = [
130
+ /\[ -f "?plugins\/sp\/scripts\//,
131
+ /\b[A-Z_]*S(?:CRIPT)?="?plugins\/sp\/scripts\//,
132
+ /\bbun plugins\/sp\/scripts\//,
133
+ ];
134
+ const isHit = (line: string): boolean => projectFirstPatterns.some((pattern) => pattern.test(line));
135
+
136
+ function push(file: string, line: number, content: string): void {
137
+ matches.push({ file, line, content: content.trim() });
138
+ }
123
139
 
124
- function scanFile(filePath: string): void {
140
+ function scanLines(filePath: string): void {
125
141
  if (!existsSync(filePath)) return;
126
142
  let text: string;
127
143
  try {
@@ -132,13 +148,72 @@ export function scanShippedSurfaces(pluginDir: string): Array<{ file: string; li
132
148
  const lines = text.split('\n');
133
149
  for (let i = 0; i < lines.length; i++) {
134
150
  const line = lines[i] ?? '';
135
- if (line.includes(forbiddenPattern)) {
136
- matches.push({
137
- file: filePath,
138
- line: i + 1,
139
- content: line.trim(),
140
- });
151
+ const trimmed = line.trim();
152
+ if (trimmed.startsWith('#')) continue; // comment lines are not executable probes
153
+ if (!isHit(line)) continue;
154
+ if (line.includes(guardMarker)) continue; // same-line source-repo guard
155
+ push(filePath, i + 1, trimmed);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 0960 R5: the repo-root workflow definitions also ship, and their shell lives in folded
161
+ * `command:` blocks. A guard on one physical line legalizes the invocation on the next, so
162
+ * the block — not the line — is the unit of judgment. Markdown prose references (backticked
163
+ * paths with no `[ -f` / assignment / `bun ` prefix) never match the patterns, so they stay
164
+ * legal.
165
+ */
166
+ function scanYamlCommands(filePath: string): void {
167
+ if (!existsSync(filePath)) return;
168
+ let text: string;
169
+ try {
170
+ text = readFileSync(filePath, 'utf8');
171
+ } catch {
172
+ return;
173
+ }
174
+ const lines = text.split('\n');
175
+ let i = 0;
176
+ while (i < lines.length) {
177
+ const line = lines[i] ?? '';
178
+ const blockKey = /^(\s*)command:\s*(?:>[+-]?|\|[+-]?)?\s*$/.exec(line);
179
+ const inlineKey = /^(\s*)command:\s+(.+)$/.exec(line);
180
+ if (blockKey) {
181
+ const indent = (blockKey[1] ?? '').length;
182
+ const block: Array<{ no: number; text: string }> = [];
183
+ let j = i + 1;
184
+ while (j < lines.length) {
185
+ const next = lines[j] ?? '';
186
+ if (next.trim() === '') {
187
+ block.push({ no: j + 1, text: next });
188
+ j++;
189
+ continue;
190
+ }
191
+ const nextIndent = (next.match(/^\s*/)?.[0] ?? '').length;
192
+ if (nextIndent <= indent) break;
193
+ block.push({ no: j + 1, text: next });
194
+ j++;
195
+ }
196
+ if (!block.some((b) => b.text.includes(guardMarker))) {
197
+ for (const b of block) {
198
+ const trimmed = b.text.trim();
199
+ if (trimmed.startsWith('#')) continue;
200
+ if (!isHit(b.text)) continue;
201
+ if (b.text.includes(guardMarker)) continue;
202
+ push(filePath, b.no, trimmed);
203
+ }
204
+ }
205
+ i = j;
206
+ continue;
141
207
  }
208
+ if (inlineKey) {
209
+ const value = inlineKey[2] ?? '';
210
+ if (!line.trim().startsWith('#') && isHit(value) && !value.includes(guardMarker)) {
211
+ push(filePath, i + 1, line.trim());
212
+ }
213
+ i++;
214
+ continue;
215
+ }
216
+ i++;
142
217
  }
143
218
  }
144
219
 
@@ -163,16 +238,27 @@ export function scanShippedSurfaces(pluginDir: string): Array<{ file: string; li
163
238
  st.isFile() &&
164
239
  (entry.endsWith('.md') || entry.endsWith('.json') || entry.endsWith('.yaml') || entry.endsWith('.yml'))
165
240
  ) {
166
- scanFile(fullPath);
241
+ scanLines(fullPath);
167
242
  }
168
243
  }
169
244
  }
170
245
 
171
- for (const d of searchDirs) {
246
+ for (const d of ['commands', 'skills', 'agents']) {
172
247
  walk(join(pluginDir, d));
173
248
  }
174
- for (const f of singleFiles) {
175
- scanFile(join(pluginDir, f));
249
+ scanLines(join(pluginDir, 'README.md'));
250
+
251
+ // 0960 R5: the workflow tree is repo-only (resolved from pluginDir/../..); a consumer's own
252
+ // workflow definitions are not this rule's subject.
253
+ const workflowsDir = resolve(pluginDir, '..', '..', 'config', 'workflows');
254
+ let workflowFiles: string[] = [];
255
+ try {
256
+ workflowFiles = readdirSync(workflowsDir).filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
257
+ } catch {
258
+ workflowFiles = [];
259
+ }
260
+ for (const name of workflowFiles.sort()) {
261
+ scanYamlCommands(join(workflowsDir, name));
176
262
  }
177
263
 
178
264
  return matches;
@@ -206,8 +292,9 @@ function findGobingAiValueImports(dir: string): { file: string; line: number }[]
206
292
  const lines = readFileSync(full, 'utf8').split('\n');
207
293
  let stmtStart = 0;
208
294
  for (let i = 0; i < lines.length; i++) {
209
- if (/^\s*(?:import|export)\b/.test(lines[i])) stmtStart = i;
210
- if (/(?:from\s*|import\s*|import\s*\(\s*)['"]@gobing-ai\//.test(lines[i])) {
295
+ const line = lines[i] ?? '';
296
+ if (/^\s*(?:import|export)\b/.test(line)) stmtStart = i;
297
+ if (/(?:from\s*|import\s*|import\s*\(\s*)['"]@gobing-ai\//.test(line)) {
211
298
  const opener = lines.slice(stmtStart, i + 1).join(' ');
212
299
  if (!/^\s*(?:import|export)\s+type\b/.test(opener)) {
213
300
  hits.push({ file: full, line: i + 1 });
@@ -219,7 +306,35 @@ function findGobingAiValueImports(dir: string): { file: string; line: number }[]
219
306
  return hits;
220
307
  }
221
308
 
222
- export function validateContract(manifest: ScriptManifest, scriptsDir: string, pluginDir: string): Violation[] {
309
+ /** Regenerates a standard script's twin into `outPath`; false when conversion could not run. */
310
+ export type ConvertTwin = (rel: string, outPath: string) => boolean;
311
+
312
+ /**
313
+ * The real converter: `superskill script convert sp <rel> --out <outPath>` run from the repo
314
+ * root, because `convert` resolves `plugins/<plugin>/scripts/<rel>` against the process cwd.
315
+ * Returns false — never throws — on spawn error, non-zero exit, or a missing output file, and
316
+ * records the command plus stderr in `lastError` for the `converter_unavailable` violation.
317
+ */
318
+ export function spawnConvertTwin(repoRoot: string, bin = 'superskill'): ConvertTwin & { lastError?: string } {
319
+ const convert: ConvertTwin & { lastError?: string } = (rel, outPath) => {
320
+ const args = ['script', 'convert', 'sp', rel, '--out', outPath];
321
+ const res = spawnSync(bin, args, { cwd: repoRoot, encoding: 'utf-8' });
322
+ if (res.error || res.status !== 0 || !existsSync(outPath)) {
323
+ const detail = (res.stderr ?? '').trim() || res.error?.message || `exit ${res.status}`;
324
+ convert.lastError = `${bin} ${args.join(' ')}: ${detail}`;
325
+ return false;
326
+ }
327
+ return true;
328
+ };
329
+ return convert;
330
+ }
331
+
332
+ export function validateContract(
333
+ manifest: ScriptManifest,
334
+ scriptsDir: string,
335
+ pluginDir: string,
336
+ opts: { convertTwin?: ConvertTwin } = {},
337
+ ): Violation[] {
223
338
  const violations: Violation[] = [];
224
339
  const { tsFiles, mjsFiles } = listDiskScripts(scriptsDir);
225
340
 
@@ -236,37 +351,51 @@ export function validateContract(manifest: ScriptManifest, scriptsDir: string, p
236
351
  manifestMap.set(entry.rel, entry);
237
352
  }
238
353
 
239
- // Rule 1: standard entries must have valid .mjs twins not older than the .ts source.
240
- // mtime is only meaningful when a build genuinely ran: a `git worktree add` stamps
241
- // every checked-out file within the same millisecond, and the lexicographic write
242
- // order (`tool.mjs` < `tool.ts`) makes the twin spuriously "older" by <1ms. Tolerate
243
- // sub-second deltas so a fresh checkout is never flagged; real build staleness is
244
- // seconds-to-minutes, far beyond this window (task 0606 R1 eval-worktree blocker).
245
- const STALE_TWIN_TOLERANCE_MS = 1000;
246
- for (const entry of manifest.entries) {
247
- if (entry.rel && entry.contract === 'standard') {
248
- const expectedTwinRel = entry.twin ?? entry.rel.replace(/\.ts$/, '.mjs');
249
- const twinPath = join(scriptsDir, expectedTwinRel);
250
- const tsPath = join(scriptsDir, entry.rel);
251
-
252
- if (!existsSync(twinPath)) {
253
- violations.push({
254
- kind: 'missing_twin',
255
- target: entry.rel,
256
- message: `standard script ${entry.rel} is missing its .mjs twin (${expectedTwinRel})`,
257
- });
258
- } else if (existsSync(tsPath)) {
259
- const tsStat = statSync(tsPath);
260
- const twinStat = statSync(twinPath);
261
- if (twinStat.mtimeMs < tsStat.mtimeMs - STALE_TWIN_TOLERANCE_MS) {
354
+ // Rule 1: standard entries must have a .mjs twin byte-identical to a fresh convert.
355
+ // mtime is never consulted: git does not store mtimes, so a committed stale twin looks
356
+ // freshly checked-out in every clone and worktree. Only regenerating the twin proves it
357
+ // matches its source (task 0970; replaces the 0606 sub-second mtime tolerance).
358
+ const twinTmpDir = mkdtempSync(join(tmpdir(), 'script-twin-'));
359
+ try {
360
+ const convertTwin = opts.convertTwin ?? spawnConvertTwin(resolve(pluginDir, '..', '..'));
361
+ let converterDown = false;
362
+ for (const entry of manifest.entries) {
363
+ if (entry.rel && entry.contract === 'standard') {
364
+ const expectedTwinRel = entry.twin ?? entry.rel.replace(/\.ts$/, '.mjs');
365
+ const twinPath = join(scriptsDir, expectedTwinRel);
366
+ const tsPath = join(scriptsDir, entry.rel);
367
+
368
+ if (!existsSync(twinPath)) {
262
369
  violations.push({
263
- kind: 'stale_twin',
370
+ kind: 'missing_twin',
264
371
  target: entry.rel,
265
- message: `standard script .mjs twin ${expectedTwinRel} is older than source ${entry.rel}`,
372
+ message: `standard script ${entry.rel} is missing its .mjs twin (${expectedTwinRel})`,
266
373
  });
374
+ } else if (existsSync(tsPath) && !converterDown) {
375
+ const outPath = join(twinTmpDir, expectedTwinRel);
376
+ mkdirSync(dirname(outPath), { recursive: true });
377
+ if (!convertTwin(entry.rel, outPath)) {
378
+ // One violation, then stop: a broken converter is one environment failure,
379
+ // not 17 stale twins. It never silently passes (task 0970 R3).
380
+ const detail = (convertTwin as { lastError?: string }).lastError;
381
+ violations.push({
382
+ kind: 'converter_unavailable',
383
+ target: entry.rel,
384
+ message: `could not regenerate ${expectedTwinRel} from ${entry.rel}${detail ? ` — ${detail}` : ''}; twin content cannot be verified`,
385
+ });
386
+ converterDown = true;
387
+ } else if (!readFileSync(outPath).equals(readFileSync(twinPath))) {
388
+ violations.push({
389
+ kind: 'stale_twin',
390
+ target: entry.rel,
391
+ message: `standard script .mjs twin ${expectedTwinRel} does not match a fresh convert of ${entry.rel} — run bun run build:scripts`,
392
+ });
393
+ }
267
394
  }
268
395
  }
269
396
  }
397
+ } finally {
398
+ rmSync(twinTmpDir, { recursive: true, force: true });
270
399
  }
271
400
 
272
401
  // Rule 2: committed .mjs files must belong to a 'standard' entry
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/script-root.ts
5
+ import { spawnSync } from "child_process";
6
+ import { createHash } from "crypto";
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
8
+ import { dirname, join } from "path";
9
+
10
+ // plugins/sp/lib/env.ts
11
+ function getEnvVars() {
12
+ return process.env;
13
+ }
14
+
15
+ // plugins/sp/scripts/script-root.ts
16
+ var SOURCE_REPO_MARKER = join("config", "plugin-scripts.json");
17
+ var PROJECT_SCRIPTS_DIR = join("plugins", "sp", "scripts");
18
+ var SCRIPT_ROOT_TWIN_REL = "script-root.mjs";
19
+ function defaultResolveScript(cwd) {
20
+ return (rel) => {
21
+ const run = spawnSync("superskill", ["script", "path", "sp", rel, "--json"], {
22
+ cwd,
23
+ encoding: "utf8",
24
+ env: getEnvVars()
25
+ });
26
+ if (run.status !== 0 || !run.stdout)
27
+ return null;
28
+ const lastLine = run.stdout.trim().split(`
29
+ `).pop() ?? "";
30
+ try {
31
+ const parsed = JSON.parse(lastLine);
32
+ return typeof parsed.path === "string" && parsed.path.length > 0 ? parsed : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
37
+ }
38
+ function digestScriptSet(dir, cwd) {
39
+ const abs = cwd ? join(cwd, dir) : dir;
40
+ const names = readdirSync(abs).filter((name) => {
41
+ try {
42
+ return statSync(join(abs, name)).isFile();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }).sort();
47
+ const list = names.map((name) => `${name}:${createHash("sha256").update(readFileSync(join(abs, name))).digest("hex")}`);
48
+ return `sha256:${createHash("sha256").update(list.join(`
49
+ `)).digest("hex")}`;
50
+ }
51
+ function runScriptRoot(env, options = {}) {
52
+ const cwd = options.cwd;
53
+ const runId = env.__runId ?? env.runId ?? "";
54
+ const relResult = join(".spur", "run", `${runId}-script-root.json`);
55
+ const abs = (p) => cwd ? join(cwd, p) : p;
56
+ const warn = options.warn ?? ((message) => void process.stderr.write(`${message}
57
+ `));
58
+ mkdirSync(abs(join(".spur", "run")), { recursive: true });
59
+ const write = (row) => {
60
+ writeFileSync(abs(relResult), `${JSON.stringify(row)}
61
+ `);
62
+ return { ...row, resultFile: relResult, exitCode: 0 };
63
+ };
64
+ if (existsSync(abs(SOURCE_REPO_MARKER))) {
65
+ try {
66
+ return write({
67
+ mode: "source-repo",
68
+ source: "project",
69
+ dir: PROJECT_SCRIPTS_DIR,
70
+ scriptSetDigest: digestScriptSet(PROJECT_SCRIPTS_DIR, cwd)
71
+ });
72
+ } catch (error) {
73
+ return write({ mode: "unresolved", error: `source-repo digest failed: ${String(error)}` });
74
+ }
75
+ }
76
+ if (existsSync(abs(PROJECT_SCRIPTS_DIR))) {
77
+ warn(`script-root: ignoring vendored ${PROJECT_SCRIPTS_DIR}/ (no ${SOURCE_REPO_MARKER} marker) \u2014 ` + `steps resolve the installed twin via 'superskill script path sp <rel>'`);
78
+ }
79
+ const resolve = options.resolveScript ?? defaultResolveScript(cwd);
80
+ const resolved = resolve(SCRIPT_ROOT_TWIN_REL);
81
+ if (!resolved?.path) {
82
+ return write({
83
+ mode: "unresolved",
84
+ error: `'superskill script path sp ${SCRIPT_ROOT_TWIN_REL}' resolved nothing`
85
+ });
86
+ }
87
+ const dir = dirname(resolved.path);
88
+ try {
89
+ return write({
90
+ mode: "installed",
91
+ source: resolved.source ?? "unknown",
92
+ dir,
93
+ scriptSetDigest: digestScriptSet(dir)
94
+ });
95
+ } catch (error) {
96
+ return write({ mode: "unresolved", error: `installed digest failed: ${String(error)}` });
97
+ }
98
+ }
99
+ var SCRIPT_ROOT_USAGE = "usage: script-root --run-id <id>";
100
+ function main(argv, env = getEnvVars(), options = {}) {
101
+ let runId = env.__runId ?? "";
102
+ for (let i = 0;i < argv.length; i++) {
103
+ if (argv[i] === "--run-id") {
104
+ runId = argv[i + 1] ?? "";
105
+ i++;
106
+ continue;
107
+ }
108
+ process.stderr.write(`${SCRIPT_ROOT_USAGE}
109
+ `);
110
+ return 2;
111
+ }
112
+ return runScriptRoot({ ...env, __runId: runId }, options).exitCode;
113
+ }
114
+ {
115
+ process.exit(main(process.argv.slice(2)));
116
+ }
117
+ export {
118
+ runScriptRoot,
119
+ main,
120
+ digestScriptSet,
121
+ SOURCE_REPO_MARKER,
122
+ SCRIPT_ROOT_USAGE,
123
+ SCRIPT_ROOT_TWIN_REL,
124
+ PROJECT_SCRIPTS_DIR
125
+ };