@bradheitmann/odin-sentinel 0.4.5 → 0.4.6

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 (57) hide show
  1. package/AGENTS.md +64 -0
  2. package/CLAUDE.md +43 -0
  3. package/README.md +102 -339
  4. package/dist/src/mcp/server.js +43 -12
  5. package/dist/src/mcp/server.js.map +1 -1
  6. package/dist/src/protocol/schemas.d.ts +2529 -4
  7. package/dist/src/protocol/schemas.js +214 -18
  8. package/dist/src/protocol/schemas.js.map +1 -1
  9. package/dist/src/protocol/service.d.ts +96 -2
  10. package/dist/src/protocol/service.js +516 -4
  11. package/dist/src/protocol/service.js.map +1 -1
  12. package/dist/src/protocol/surface-layout.d.ts +40 -1
  13. package/dist/src/protocol/surface-layout.js +98 -1
  14. package/dist/src/protocol/surface-layout.js.map +1 -1
  15. package/dist/src/protocol/validators.d.ts +3 -0
  16. package/dist/src/protocol/validators.js +28 -0
  17. package/dist/src/protocol/validators.js.map +1 -1
  18. package/dist/src/protocol/version.d.ts +3 -0
  19. package/dist/src/protocol/version.js +3 -0
  20. package/dist/src/protocol/version.js.map +1 -1
  21. package/dist/src/telemetry/config.d.ts +8 -0
  22. package/dist/src/telemetry/config.js +24 -0
  23. package/dist/src/telemetry/config.js.map +1 -1
  24. package/dist/src/telemetry/index.d.ts +5 -5
  25. package/dist/src/telemetry/index.js +3 -3
  26. package/dist/src/telemetry/index.js.map +1 -1
  27. package/dist/src/telemetry/redactor.js +25 -7
  28. package/dist/src/telemetry/redactor.js.map +1 -1
  29. package/dist/src/telemetry/report.d.ts +108 -0
  30. package/dist/src/telemetry/report.js +83 -3
  31. package/dist/src/telemetry/report.js.map +1 -1
  32. package/dist/src/telemetry/submit.d.ts +2 -0
  33. package/dist/src/telemetry/submit.js +79 -6
  34. package/dist/src/telemetry/submit.js.map +1 -1
  35. package/docs/guides/quick-start.md +112 -44
  36. package/docs/guides/quickstart-prompts.md +46 -113
  37. package/docs/guides/recommended-starter-team.md +45 -27
  38. package/docs/reference/client-compatibility.md +20 -43
  39. package/docs/reference/cost-and-privacy.md +26 -23
  40. package/docs/reference/distribution.md +40 -55
  41. package/docs/reference/public-surface-audit.md +35 -114
  42. package/package.json +19 -4
  43. package/protocol/SCP.md +8 -1
  44. package/protocol/bootstrap-skill.md +16 -11
  45. package/protocol/closeout.yaml +7 -1
  46. package/protocol/delegation.yaml +1 -1
  47. package/protocol/model-profiles.yaml +55 -1
  48. package/protocol/receipts/boot-receipt.yaml +42 -0
  49. package/protocol/receipts/team-manifest.yaml +41 -0
  50. package/protocol/roles.yaml +69 -1
  51. package/protocol/topology.yaml +78 -36
  52. package/scripts/audit/public-surface.mjs +47 -19
  53. package/scripts/audit/verify-pack.mjs +293 -27
  54. package/templates/dev-slice-template.md +56 -0
  55. package/templates/pm-role-template.md +61 -0
  56. package/templates/qa-slice-template.md +46 -0
  57. package/templates/team-manifest-template.yaml +163 -0
@@ -1,4 +1,4 @@
1
- version: 0.3.0
1
+ version: 0.4.6
2
2
  default_topology:
3
3
  executive_office:
4
4
  team: A
@@ -18,39 +18,81 @@ default_topology:
18
18
  fresh_repo_default_pods: 1
19
19
  odin_mesh:
20
20
  bootstrap_identity_exchange: true
21
- health_round_robin_minutes: 10
21
+ health_round_robin_seconds: 30
22
22
  aggregator: A/EXEC-ODIN
23
- surface_layout:
24
- description: >-
25
- Canonical CMUX surface layout rule for EXEC PM. Teams arrive as letters
26
- (Team A is executive; Team B onward are development pods). Columns are
27
- equal width. Surfaces stack vertically inside a column with at most two
28
- surfaces per column. Team A always occupies column 0. When the team count
29
- is odd and at least 3, column 0 holds only Team A (the "tall column").
30
- rules:
31
- max_surfaces_per_column: 2
32
- column_widths: equal
33
- exec_team_column: 0
34
- tall_column_when_odd_team: A
35
- custodianship:
36
- sole_custodian: A/EXEC-PM
37
- includes:
38
- - cmux new-split
39
- - cmux new-surface
40
- - cmux move-surface
41
- - cmux close-surface
42
- pre_staffing_gate: >-
43
- Before adding any agent beyond A/EXEC, EXEC PM must (1) call
44
- odin.compute_surface_layout for teamCount=N+1, (2) execute the splits
45
- required to reach that layout via cmux, (3) confirm each new surface
46
- is empty and addressable via cmux list-pane-surfaces, and only then
47
- (4) dispatch the spawn to the newly created surface.
48
- reference_layouts:
49
- teams_1: "[A]"
50
- teams_2: "[A] [B]"
51
- teams_3: "[A] [B/C]"
52
- teams_4: "[A/D] [B/C]"
53
- teams_5: "[A] [B/C] [D/E]"
54
- teams_6: "[A/F] [B/C] [D/E]"
55
- teams_7: "[A] [B/C] [D/E] [F/G]"
56
- teams_8: "[A/H] [B/C] [D/E] [F/G]"
23
+ launch_phases:
24
+ - SURFACE_PROVISIONED
25
+ - OCCUPANT_READINESS
26
+ - OCCUPANT_LAUNCH
27
+ - BOOT_RECEIPT_VALIDATION
28
+ - TEAM_ACTIVATION
29
+ - ACTIVE_WATCH
30
+ readiness_gate:
31
+ minimum_mcp_version: 0.4.5
32
+ cmux_required_for_governed_team: true
33
+ occupant_launch_allowed_when:
34
+ - PASS
35
+ - WAIVED_BY_EXEC_PM
36
+ - SUBSTITUTION_APPROVED_BY_EXEC_PM
37
+ team_activation_allowed_states:
38
+ - BOOTSTRAPPED_IDLE
39
+ - ACTIVE_WATCH
40
+ - VACANT_ROLE_SLOT
41
+ - AGENT_SUBSTITUTION_REQUIRED
42
+ safe_auth_failure_outcomes:
43
+ - choose a different harness
44
+ - receive setup guidance without pasting secrets
45
+ - mark slot VACANT_ROLE_SLOT
46
+ - request EXEC PM-approved substitution
47
+ surface_layout:
48
+ description: >-
49
+ Canonical CMUX surface layout rule for EXEC PM. Governed team bootstrap
50
+ uses human_cmux_quad by default: one CMUX workspace with four panes/regions
51
+ (executive office, Team B pod, Team C pod, blockers/status/intake).
52
+ Legacy columns remain available for compatibility but tab-only layout is
53
+ degraded unless explicitly approved.
54
+ canonical_profile: human_cmux_quad
55
+ human_cmux_quad:
56
+ workspace_policy: A/EXEC-PM remains in the same CMUX workspace as all governed teams
57
+ quadrants:
58
+ A: executive office
59
+ B: Team B pod
60
+ C: Team C pod
61
+ D: blockers/status/intake or overflow
62
+ empty_role_slots_before_launch: true
63
+ reject_tab_only_unless_degraded_mode_approved: true
64
+ reject_split_workspace_without_routing_proof: true
65
+ no_cmux_governed_status: NOT_GOVERNED_NO_CMUX
66
+ legacy_columns:
67
+ description: >-
68
+ Teams arrive as letters (Team A is executive; Team B onward are
69
+ development pods). Columns are equal width. Surfaces stack vertically
70
+ inside a column with at most two surfaces per column. Team A always
71
+ occupies column 0.
72
+ rules:
73
+ max_surfaces_per_column: 2
74
+ column_widths: equal
75
+ exec_team_column: 0
76
+ tall_column_when_odd_team: A
77
+ custodianship:
78
+ sole_custodian: A/EXEC-PM
79
+ includes:
80
+ - cmux new-split
81
+ - cmux new-surface
82
+ - cmux move-surface
83
+ - cmux close-surface
84
+ pre_staffing_gate: >-
85
+ Before adding any agent beyond A/EXEC, EXEC PM must (1) call
86
+ odin.compute_surface_layout for teamCount=N+1, (2) execute the splits
87
+ required to reach that layout via cmux, (3) confirm each new surface
88
+ is empty and addressable via cmux list-pane-surfaces, and only then
89
+ (4) dispatch the spawn to the newly created surface.
90
+ reference_layouts:
91
+ teams_1: "[A]"
92
+ teams_2: "[A] [B]"
93
+ teams_3: "[A] [B/C]"
94
+ teams_4: "[A/D] [B/C]"
95
+ teams_5: "[A] [B/C] [D/E]"
96
+ teams_6: "[A/F] [B/C] [D/E]"
97
+ teams_7: "[A] [B/C] [D/E] [F/G]"
98
+ teams_8: "[A/H] [B/C] [D/E] [F/G]"
@@ -1,6 +1,22 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const PUBLIC_ROOTS = [
7
+ "README.md",
8
+ "AGENTS.md",
9
+ "CLAUDE.md",
10
+ "LICENSE",
11
+ "package.json",
12
+ "docs/",
13
+ "protocol/",
14
+ "templates/",
15
+ "plugins/",
16
+ "scripts/audit/"
17
+ ];
18
+
19
+ const EXCLUDED_PREFIXES = [".git/", "dist/", "node_modules/", "project/" + "planning" + "/", "." + "edge-" + "agentic" + "/local/", "tests/"];
4
20
 
5
21
  function walk(dir) {
6
22
  return readdirSync(dir).flatMap((entry) => {
@@ -11,6 +27,11 @@ function walk(dir) {
11
27
  });
12
28
  }
13
29
 
30
+ export function isPublicAuditFile(file) {
31
+ if (EXCLUDED_PREFIXES.some((prefix) => file.startsWith(prefix))) return false;
32
+ return PUBLIC_ROOTS.some((root) => file === root || file.startsWith(root));
33
+ }
34
+
14
35
  function filesToAudit() {
15
36
  try {
16
37
  const tracked = execFileSync("git", ["ls-files"], {
@@ -25,17 +46,13 @@ function filesToAudit() {
25
46
  return `${tracked}\n${untracked}`
26
47
  .split("\n")
27
48
  .filter(Boolean)
28
- .filter((file) => !file.startsWith("pnpm-lock.yaml"));
49
+ .filter((file) => file !== "pnpm-lock.yaml")
50
+ .filter(isPublicAuditFile);
29
51
  } catch {
30
- return walk(".");
52
+ return walk(".").filter(isPublicAuditFile);
31
53
  }
32
54
  }
33
55
 
34
- const publicFiles = filesToAudit();
35
-
36
- // Install and protocol documentation legitimately references agent harness
37
- // config directories and tilde home paths. The path-style rules below skip
38
- // these files; the rest of the rules still apply.
39
56
  const BUNDLED_DOC = new Set([
40
57
  "README.md",
41
58
  "docs/guides/quickstart-prompts.md",
@@ -47,7 +64,9 @@ const forbidden = [
47
64
  { name: "macOS home path", pattern: new RegExp(`/${"Users"}/[A-Za-z0-9._-]+/`) },
48
65
  { name: "Linux home path", pattern: /\/home\/[A-Za-z0-9._-]+\// },
49
66
  { name: "tilde home path", pattern: /~\//, exemptFiles: BUNDLED_DOC },
50
- { name: "local agent config path", pattern: new RegExp(`\\.${"codex"}|\\.${"claude"}|\\.${"agents"}`, "i"), exemptFiles: BUNDLED_DOC },
67
+ { name: "local agent config path", pattern: new RegExp(`\\.(?:${"codex"}|${"claude"}|${"agents"})(?:/|$)`, "i"), exemptFiles: BUNDLED_DOC },
68
+ { name: "local evidence path", pattern: new RegExp(`\\.${"edge-" + "agentic"}/local`, "i") },
69
+ { name: "private planning path", pattern: new RegExp(`project/${"planning"}/`, "i") },
51
70
  {
52
71
  name: "private project or account marker",
53
72
  pattern: new RegExp(
@@ -58,20 +77,29 @@ const forbidden = [
58
77
  { name: "secret-looking assignment", pattern: /(api[_-]?key|secret|token|password)\s*[:=]\s*["'][^"']+["']/i }
59
78
  ];
60
79
 
61
- const findings = [];
62
-
63
- for (const file of publicFiles) {
64
- const text = readFileSync(file, "utf8");
65
- for (const rule of forbidden) {
66
- if (rule.exemptFiles?.has(file)) continue;
67
- if (rule.pattern.test(text)) {
68
- findings.push(`${file}: ${rule.name}`);
80
+ export function auditPublicSurface(fileTextByPath) {
81
+ const findings = [];
82
+ for (const [file, text] of Object.entries(fileTextByPath)) {
83
+ if (!isPublicAuditFile(file)) continue;
84
+ for (const rule of forbidden) {
85
+ if (rule.exemptFiles?.has(file)) continue;
86
+ if (rule.pattern.test(text)) findings.push(`${file}: ${rule.name}`);
69
87
  }
70
88
  }
89
+ return findings;
71
90
  }
72
91
 
73
- if (findings.length > 0) {
74
- throw new Error(`Public surface audit failed:\n${findings.join("\n")}`);
92
+ export function main() {
93
+ const publicFiles = filesToAudit();
94
+ const findings = auditPublicSurface(Object.fromEntries(publicFiles.map((file) => [file, readFileSync(file, "utf8")])));
95
+
96
+ if (findings.length > 0) {
97
+ throw new Error(`Public surface audit failed:\n${findings.join("\n")}`);
98
+ }
99
+
100
+ console.log(`Public surface audit PASS: ${publicFiles.length} files checked`);
75
101
  }
76
102
 
77
- console.log(`Public surface audit PASS: ${publicFiles.length} files checked`);
103
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
104
+ main();
105
+ }
@@ -1,21 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
2
4
 
3
- const output = execFileSync("pnpm", ["pack", "--dry-run", "--json"], {
4
- encoding: "utf8",
5
- stdio: ["ignore", "pipe", "pipe"]
6
- });
7
-
8
- // pnpm 11 writes JSON directly to stdout (no preamble); pnpm <=10 prefixed
9
- // it with lifecycle logs. Handle both shapes: take from "\n{" if present, or
10
- // from the start if output already begins with "{".
11
- const newlineBrace = output.lastIndexOf("\n{");
12
- const jsonStart = newlineBrace !== -1 ? newlineBrace + 1 : (output.trimStart().startsWith("{") ? output.indexOf("{") : -1);
13
- if (jsonStart === -1) {
14
- throw new Error("pnpm pack did not return JSON metadata");
15
- }
16
-
17
- const pack = JSON.parse(output.slice(jsonStart));
18
- const paths = new Set(pack.files.map((file) => file.path));
5
+ export const MINIMUM_COMPATIBLE_CHILD_MCP_VERSION = "0.4.5";
19
6
 
20
7
  const requiredProtocolFiles = [
21
8
  "protocol/SCP.md",
@@ -29,7 +16,14 @@ const requiredProtocolFiles = [
29
16
  "protocol/bootstrap-skill.md"
30
17
  ];
31
18
 
32
- const requiredFiles = [
19
+ const requiredTemplateFiles = [
20
+ "templates/dev-slice-template.md",
21
+ "templates/qa-slice-template.md",
22
+ "templates/pm-role-template.md",
23
+ "templates/team-manifest-template.yaml"
24
+ ];
25
+
26
+ export const requiredPackageFiles = [
33
27
  "dist/src/bin/index.js",
34
28
  "dist/src/mcp/server.js",
35
29
  "dist/src/protocol/index.js",
@@ -46,28 +40,300 @@ const requiredFiles = [
46
40
  "docs/reference/distribution.md",
47
41
  "docs/reference/public-surface-audit.md",
48
42
  ...requiredProtocolFiles,
43
+ ...requiredTemplateFiles,
49
44
  "scripts/audit/public-surface.mjs",
50
45
  "scripts/audit/verify-pack.mjs",
46
+ "AGENTS.md",
47
+ "CLAUDE.md",
51
48
  "README.md",
52
49
  "LICENSE",
53
50
  "package.json"
54
51
  ];
55
52
 
56
- const missing = requiredFiles.filter((file) => !paths.has(file));
57
- if (missing.length > 0) {
58
- throw new Error(`Package is missing required files: ${missing.join(", ")}`);
59
- }
60
-
61
- const staleFiles = [
53
+ const staleBuildFiles = [
62
54
  "dist/src/index.js",
63
55
  "dist/src/server.js",
64
56
  "dist/src/protocol.js",
65
57
  "dist/src/protocol-repository.js",
66
58
  "dist/src/validators.js"
67
- ].filter((file) => paths.has(file));
59
+ ];
60
+
61
+ const protocolResourceVersionLockedFiles = new Set([
62
+ "protocol/SCP.md",
63
+ "protocol/closeout.yaml",
64
+ "protocol/delegation.yaml",
65
+ "protocol/model-profiles.yaml",
66
+ "protocol/roles.yaml",
67
+ "protocol/topology.yaml"
68
+ ]);
69
+
70
+ const forbiddenPackagePrefixes = ["project/" + "planning" + "/", "." + "edge-" + "agentic" + "/local/"];
71
+ const forbiddenPackagedContentRules = [
72
+ { name: "local evidence path", pattern: new RegExp(`\\.${"edge-" + "agentic"}/local`, "i") },
73
+ { name: "local ODIN audit path", pattern: /\.odin\/local\//i },
74
+ { name: "private planning path", pattern: new RegExp(`project/${"planning"}/`, "i") },
75
+ { name: "macOS home path", pattern: new RegExp(`/${"Users"}/[A-Za-z0-9._-]+/`) },
76
+ { name: "Linux home path", pattern: /\/home\/[A-Za-z0-9._-]+\// },
77
+ { name: "secret-looking assignment", pattern: /(api[_-]?key|secret|token|password)\s*[:=]\s*["'][^"']+["']/i }
78
+ ];
79
+
80
+ function asPathSet(paths) {
81
+ return new Set(Array.from(paths));
82
+ }
83
+
84
+ export function validatePackageMetadata(packageJson) {
85
+ const errors = [];
86
+ if (!packageJson.repository?.url) errors.push("package.json missing repository.url");
87
+ if (!packageJson.homepage) errors.push("package.json missing homepage");
88
+ if (!packageJson.bugs?.url) errors.push("package.json missing bugs.url");
89
+ if (!packageJson.license) errors.push("package.json missing license");
90
+ if (!packageJson.engines?.node) errors.push("package.json missing engines.node");
91
+ if (!Array.isArray(packageJson.files) || packageJson.files.length === 0) errors.push("package.json missing files allowlist");
92
+ for (const file of ["docs", "protocol", "templates", "AGENTS.md", "CLAUDE.md", "README.md", "LICENSE"]) {
93
+ if (!packageJson.files?.includes(file)) errors.push(`package.json files allowlist missing ${file}`);
94
+ }
95
+ if (packageJson.odin?.publicVersion !== packageJson.version) {
96
+ errors.push("package.json odin.publicVersion must match package version");
97
+ }
98
+ if (packageJson.odin?.minimumCompatibleChildMcpVersion !== MINIMUM_COMPATIBLE_CHILD_MCP_VERSION) {
99
+ errors.push("package.json odin.minimumCompatibleChildMcpVersion drifted");
100
+ }
101
+ for (const [name, version] of Object.entries(packageJson.dependencies ?? {})) {
102
+ if (typeof version !== "string" || /^[~^]/.test(version) || /[<>=*x|]/i.test(version)) {
103
+ errors.push(`package.json runtime dependency ${name} must be pinned exactly`);
104
+ }
105
+ }
106
+ return errors;
107
+ }
108
+
109
+ export function validatePackFileList(pathsInput) {
110
+ const paths = asPathSet(pathsInput);
111
+ const errors = [];
112
+ const missing = requiredPackageFiles.filter((file) => !paths.has(file));
113
+ if (missing.length > 0) errors.push(`Package is missing required files: ${missing.join(", ")}`);
114
+
115
+ const stale = staleBuildFiles.filter((file) => paths.has(file));
116
+ if (stale.length > 0) errors.push(`Package includes stale build files: ${stale.join(", ")}`);
117
+
118
+ const privatePaths = Array.from(paths).filter((file) => forbiddenPackagePrefixes.some((prefix) => file.startsWith(prefix)));
119
+ if (privatePaths.length > 0) errors.push(`Package includes private local paths: ${privatePaths.join(", ")}`);
68
120
 
69
- if (staleFiles.length > 0) {
70
- throw new Error(`Package includes stale build files: ${staleFiles.join(", ")}`);
121
+ return errors;
71
122
  }
72
123
 
73
- console.log(`Package smoke PASS: ${pack.files.length} files included in ${pack.filename}`);
124
+ export function validatePackFileContents(fileTextByPath) {
125
+ const findings = [];
126
+ for (const [file, text] of Object.entries(fileTextByPath)) {
127
+ for (const rule of forbiddenPackagedContentRules) {
128
+ if (rule.pattern.test(text)) findings.push(`${file}: ${rule.name}`);
129
+ }
130
+ }
131
+ return findings;
132
+ }
133
+
134
+ function isTextPackageFile(file) {
135
+ return /\.(js|mjs|cjs|ts|json|md|ya?ml|txt|html|css|sh)$/.test(file) || !file.includes(".");
136
+ }
137
+
138
+ function readPackFileTexts(paths) {
139
+ return Object.fromEntries(
140
+ paths
141
+ .filter(isTextPackageFile)
142
+ .flatMap((file) => {
143
+ try {
144
+ return [[file, readFileSync(file, "utf8")]];
145
+ } catch {
146
+ return [];
147
+ }
148
+ })
149
+ );
150
+ }
151
+
152
+ export function findStaleVersionReferences(fileTextByPath, currentVersion, minimumCompatibleVersion = MINIMUM_COMPATIBLE_CHILD_MCP_VERSION) {
153
+ const allowed = new Set([currentVersion, minimumCompatibleVersion]);
154
+ const findings = [];
155
+ const versionPattern = /\b\d+\.\d+\.\d+\b/g;
156
+ const relevantVersionLine = (line) => {
157
+ const lower = line.toLowerCase();
158
+ if (/\bnode\.?js\b/.test(lower) || /\bengines?\b/.test(lower)) return false;
159
+ if (/^version:\s*\d+\.\d+\.\d+\s*$/.test(line.trim())) return false;
160
+ return [
161
+ "odin-sentinel",
162
+ "serverinfo",
163
+ "package/server",
164
+ "public version",
165
+ "minimum compatible",
166
+ "compatible child mcp",
167
+ "mcp version",
168
+ "server version",
169
+ "expected version",
170
+ "stale mcp version",
171
+ "confirm >=",
172
+ "server "
173
+ ].some((marker) => lower.includes(marker));
174
+ };
175
+
176
+ for (const [file, text] of Object.entries(fileTextByPath)) {
177
+ for (const line of text.split("\n")) {
178
+ if (!relevantVersionLine(line)) continue;
179
+ const matches = line.match(versionPattern) ?? [];
180
+ for (const version of matches) {
181
+ if (!allowed.has(version)) findings.push(`${file}: stale version reference ${version}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ return findings;
187
+ }
188
+
189
+ export function validatePublicProtocolSync({ scpText, bootstrapText, currentVersion, minimumCompatibleVersion = MINIMUM_COMPATIBLE_CHILD_MCP_VERSION }) {
190
+ const errors = [];
191
+ const requiredMarkers = [
192
+ `SCP_PUBLIC_VERSION: ${currentVersion}`,
193
+ `MIN_COMPATIBLE_CHILD_MCP: ${minimumCompatibleVersion}`
194
+ ];
195
+
196
+ for (const marker of requiredMarkers) {
197
+ if (!scpText.includes(marker)) errors.push(`protocol/SCP.md missing ${marker}`);
198
+ if (!bootstrapText.includes(marker)) errors.push(`protocol/bootstrap-skill.md missing ${marker}`);
199
+ }
200
+
201
+ return errors;
202
+ }
203
+
204
+ export function validatePluginSync({ pluginManifestText, pluginSkillText, pluginReadmeText, currentVersion, minimumCompatibleVersion = MINIMUM_COMPATIBLE_CHILD_MCP_VERSION }) {
205
+ const errors = [];
206
+ let manifest;
207
+ try {
208
+ manifest = JSON.parse(pluginManifestText);
209
+ } catch {
210
+ errors.push("Claude plugin manifest must be valid JSON");
211
+ manifest = {};
212
+ }
213
+
214
+ if (manifest.version !== currentVersion) {
215
+ errors.push(`Claude plugin manifest version ${manifest.version ?? "<missing>"} must match package version ${currentVersion}`);
216
+ }
217
+ const server = manifest.mcpServers?.["odin-sentinel"];
218
+ if (!server) {
219
+ errors.push("Claude plugin manifest missing odin-sentinel MCP server");
220
+ } else {
221
+ if (server.command !== "npx") errors.push("Claude plugin odin-sentinel server must use npx");
222
+ const args = Array.isArray(server.args) ? server.args : [];
223
+ for (const requiredArg of ["-y", "-p", "@bradheitmann/odin-sentinel", "odin-sentinel-mcp"]) {
224
+ if (!args.includes(requiredArg)) errors.push(`Claude plugin odin-sentinel args missing ${requiredArg}`);
225
+ }
226
+ }
227
+
228
+ for (const marker of [`SCP_PUBLIC_VERSION: ${currentVersion}`, `MIN_COMPATIBLE_CHILD_MCP: ${minimumCompatibleVersion}`]) {
229
+ if (!pluginSkillText.includes(marker)) errors.push(`Claude plugin skill missing ${marker}`);
230
+ }
231
+ if (!/23\s+`?odin\.\*`?\s+tools/i.test(pluginReadmeText)) {
232
+ errors.push("Claude plugin README must advertise 23 odin.* tools");
233
+ }
234
+
235
+ return errors;
236
+ }
237
+
238
+ export function validatePackagedProtocolVersions(fileTextByPath, currentVersion) {
239
+ const errors = [];
240
+ for (const [file, text] of Object.entries(fileTextByPath)) {
241
+ if (!protocolResourceVersionLockedFiles.has(file)) continue;
242
+ const firstVersion = text.match(/^(?:version|Version):\s*([0-9]+\.[0-9]+\.[0-9]+)\s*$/m);
243
+ if (firstVersion && firstVersion[1] !== currentVersion) {
244
+ errors.push(`${file}: protocol resource version ${firstVersion[1]} must match package version ${currentVersion}`);
245
+ }
246
+ }
247
+ return errors;
248
+ }
249
+
250
+ export function validateBootstrapReadiness(bootstrapText) {
251
+ const required = ["MCP server", "native skill", "full prompt fallback", "CMUX", "auth/account readiness", "local inference", "role compatibility"];
252
+ return required.filter((term) => !bootstrapText.toLowerCase().includes(term.toLowerCase())).map((term) => `protocol/bootstrap-skill.md missing readiness term: ${term}`);
253
+ }
254
+
255
+ export function validateTelemetryWording(costPrivacyText) {
256
+ const errors = [];
257
+ if (/does not .*telemetry/i.test(costPrivacyText) && !/optional|user-invoked|not automatic/i.test(costPrivacyText)) {
258
+ errors.push("Telemetry wording must explain optional/user-invoked behavior");
259
+ }
260
+ if (!/optional telemetry/i.test(costPrivacyText) || !/user-invoked/i.test(costPrivacyText)) {
261
+ errors.push("Cost/privacy docs must describe optional user-invoked telemetry");
262
+ }
263
+ return errors;
264
+ }
265
+
266
+ function readPublicVersionFiles() {
267
+ return Object.fromEntries([
268
+ "README.md",
269
+ "docs/guides/quick-start.md",
270
+ "docs/guides/quickstart-prompts.md",
271
+ "docs/reference/client-compatibility.md",
272
+ "docs/reference/distribution.md",
273
+ "docs/reference/public-surface-audit.md",
274
+ "protocol/SCP.md",
275
+ "protocol/bootstrap-skill.md",
276
+ "plugins/sentinel-coordination-protocol/.claude-plugin/plugin.json",
277
+ "plugins/sentinel-coordination-protocol/skills/sentinel-coordination-protocol/SKILL.md",
278
+ "plugins/sentinel-coordination-protocol/README.md"
279
+ ].map((file) => [file, readFileSync(file, "utf8")]));
280
+ }
281
+
282
+ function parsePackOutput(output) {
283
+ const newlineBrace = output.lastIndexOf("\n{");
284
+ const jsonStart = newlineBrace !== -1 ? newlineBrace + 1 : (output.trimStart().startsWith("{") ? output.indexOf("{") : -1);
285
+ if (jsonStart === -1) throw new Error("pnpm pack did not return JSON metadata");
286
+ return JSON.parse(output.slice(jsonStart));
287
+ }
288
+
289
+ export function runVerifyPack({ pack, packageJson, publicVersionFiles, costPrivacyText, packFileTextByPath }) {
290
+ const packPaths = pack.files.map((file) => file.path);
291
+ const packFileTexts = packFileTextByPath ?? readPackFileTexts(packPaths);
292
+ const errors = [
293
+ ...validatePackageMetadata(packageJson),
294
+ ...validatePackFileList(packPaths),
295
+ ...validatePackFileContents(packFileTexts),
296
+ ...validatePackagedProtocolVersions(packFileTexts, packageJson.version),
297
+ ...findStaleVersionReferences(publicVersionFiles, packageJson.version),
298
+ ...validatePublicProtocolSync({
299
+ scpText: publicVersionFiles["protocol/SCP.md"],
300
+ bootstrapText: publicVersionFiles["protocol/bootstrap-skill.md"],
301
+ currentVersion: packageJson.version
302
+ }),
303
+ ...validatePluginSync({
304
+ pluginManifestText: publicVersionFiles["plugins/sentinel-coordination-protocol/.claude-plugin/plugin.json"],
305
+ pluginSkillText: publicVersionFiles["plugins/sentinel-coordination-protocol/skills/sentinel-coordination-protocol/SKILL.md"],
306
+ pluginReadmeText: publicVersionFiles["plugins/sentinel-coordination-protocol/README.md"],
307
+ currentVersion: packageJson.version
308
+ }),
309
+ ...validateBootstrapReadiness(publicVersionFiles["protocol/bootstrap-skill.md"]),
310
+ ...validateTelemetryWording(costPrivacyText)
311
+ ];
312
+
313
+ if (errors.length > 0) throw new Error(`Package release sync failed:\n${errors.join("\n")}`);
314
+ return {
315
+ fileCount: pack.files.length,
316
+ filename: pack.filename,
317
+ version: packageJson.version,
318
+ minimumCompatibleChildMcpVersion: MINIMUM_COMPATIBLE_CHILD_MCP_VERSION
319
+ };
320
+ }
321
+
322
+ export function main() {
323
+ const output = execFileSync("pnpm", ["pack", "--dry-run", "--json"], {
324
+ encoding: "utf8",
325
+ stdio: ["ignore", "pipe", "pipe"]
326
+ });
327
+ const pack = parsePackOutput(output);
328
+ const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
329
+ const publicVersionFiles = readPublicVersionFiles();
330
+ const costPrivacyText = readFileSync("docs/reference/cost-and-privacy.md", "utf8");
331
+ const result = runVerifyPack({ pack, packageJson, publicVersionFiles, costPrivacyText });
332
+
333
+ console.log(`Package smoke PASS: ${result.fileCount} files included in ${result.filename}`);
334
+ console.log(`Release sync PASS: public version ${result.version}; minimum compatible child MCP ${result.minimumCompatibleChildMcpVersion}`);
335
+ }
336
+
337
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
338
+ main();
339
+ }
@@ -0,0 +1,56 @@
1
+ # DEV Slice Template
2
+
3
+ Use this as a public starter template. Replace every placeholder before launch.
4
+
5
+ ## Goal
6
+
7
+ - Task: `<what to build or change>`
8
+ - Owner: `<DEV role slot or agent>`
9
+ - PM contact: `<PM role slot or human>`
10
+
11
+ ## Scope
12
+
13
+ - Write-allowed files:
14
+ - `<path>`
15
+ - Read-only context:
16
+ - `<path or doc>`
17
+ - Out of scope:
18
+ - `<explicit non-goals>`
19
+
20
+ ## Requirements
21
+
22
+ - Functional acceptance criteria:
23
+ - `<criterion 1>`
24
+ - `<criterion 2>`
25
+ - User-defined criteria:
26
+ - `<project-specific criterion>`
27
+
28
+ ## Verification
29
+
30
+ - Required commands:
31
+ - `<command>` -> expected `<result>`
32
+ - Manual checks:
33
+ - `<check>`
34
+
35
+ ## DEV Report
36
+
37
+ Return:
38
+
39
+ - files changed
40
+ - summary of implementation
41
+ - verification commands and results
42
+ - unmet criteria or blockers
43
+ - risks for QA/integration
44
+
45
+ ## Boundaries
46
+
47
+ DEV implements only the assigned scope and does not QA-accept its own work.
48
+
49
+ ## Minimal Filled Example
50
+
51
+ - Task: `Update one README paragraph to clarify install steps`
52
+ - Owner: `B/DEV-1`
53
+ - Write-allowed files: `README.md`
54
+ - Out of scope: `package code, release scripts, unrelated docs`
55
+ - Required command: `pnpm test` -> expected `passes`
56
+ - DEV report: `changed README.md; tests passed or blocker reported`