dsh-themis 1.0.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.
@@ -0,0 +1,6 @@
1
+ # tech-lead bundle: one insert row over the profile root. The plugin is
2
+ # strictly read-only (no fs writes, no subprocesses, no network); see
3
+ # packages/dsh-tech-lead-plugin/src/tools.js for the full surface.
4
+ - insert:
5
+ - id: tech-lead-tools
6
+ name: 'dsh-themis'
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "dsh-themis",
3
+ "version": "1.0.0",
4
+ "description": "Themis — tech-lead lifecycle governance for DeepSeek Harness: 21 read-only tools (classify/state/plan/evidence/gates/release/install audits, context/evidence/progress analysis, mutation preview). No writes, no subprocesses, no network.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "cordis.patch.yml"
14
+ ],
15
+ "keywords": [
16
+ "dsh",
17
+ "deepseek-harness",
18
+ "cordis",
19
+ "tech-lead",
20
+ "plugin",
21
+ "governance",
22
+ "themis"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/240xu/tech-lead-skill.git",
30
+ "directory": "packages/dsh-tech-lead"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.7"
39
+ },
40
+ "engines": {
41
+ "node": ">=16"
42
+ }
43
+ }
@@ -0,0 +1,34 @@
1
+ const CAPABILITIES = Object.freeze([
2
+ ['tech_lead_classify', 'classification', 'primitive+csv', 'medium'],
3
+ ['tech_lead_state_validate', 'state', 'json-string', 'medium'],
4
+ ['tech_lead_transition_check', 'state', 'json-string+primitive', 'high'],
5
+ ['tech_lead_plan_lint', 'planning', 'json-string', 'medium'],
6
+ ['tech_lead_evidence_lint', 'evidence', 'json-string+primitive', 'high'],
7
+ ['tech_lead_gate_precheck', 'gates', 'json-string', 'high'],
8
+ ['tech_lead_release_audit', 'release', 'json-string+csv', 'high'],
9
+ ['tech_lead_install_audit', 'installation', 'json-string+csv', 'high'],
10
+ ['tech_lead_resume_card', 'reconcile', 'json-string+primitive', 'medium'],
11
+ ['tech_lead_context_validate', 'context', 'json-string', 'medium'],
12
+ ['tech_lead_evidence_graph_lint', 'evidence', 'json-string', 'high'],
13
+ ['tech_lead_evidence_freshness', 'evidence', 'json-string', 'high'],
14
+ ['tech_lead_assumption_register', 'context', 'json-string', 'medium'],
15
+ ['tech_lead_progress_decide', 'progress', 'json-string', 'high'],
16
+ ['tech_lead_critical_path', 'progress', 'json-string', 'high'],
17
+ ['tech_lead_change_impact', 'impact', 'json-string', 'high'],
18
+ ['tech_lead_resume_reconcile', 'reconcile', 'json-string', 'high'],
19
+ ['tech_lead_gate_plan', 'gates', 'json-string', 'high'],
20
+ ['tech_lead_gate_aggregate', 'gates', 'json-string', 'high'],
21
+ ['tech_lead_gate_reopen', 'gates', 'json-string', 'high'],
22
+ ['tech_lead_mutation_preview', 'mutation', 'json-string', 'high'],
23
+ ].map(([name, domain, inputMode, risk]) => Object.freeze({
24
+ name,
25
+ version: '1',
26
+ domain,
27
+ sideEffects: false,
28
+ inputMode,
29
+ risk,
30
+ })));
31
+
32
+ export function getCapabilities() {
33
+ return CAPABILITIES.map((capability) => ({ ...capability }));
34
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Task tier classifier (SKILL §1). Pure function.
3
+ *
4
+ * @param {{
5
+ * touchesMultipleModules?: boolean,
6
+ * estimatedDays?: number,
7
+ * irreversibleOps?: string[],
8
+ * protectedAssetTypes?: Array<'SOURCE'|'USER_DATA'|'CONFIG'|'SECRET'|'RUNTIME'|'GENERATED'>,
9
+ * publicInterfaceChange?: boolean,
10
+ * uncertainRisk?: boolean,
11
+ * }} [input]
12
+ * @returns {{ tier: 'T0'|'T1'|'T2', reasons: string[], escalated: boolean }}
13
+ */
14
+ export function classify(maybeInput) {
15
+ const input = maybeInput !== null && typeof maybeInput === 'object' ? maybeInput : {};
16
+ const reasons = [];
17
+ let tier = 'T0';
18
+ const provided = ['touchesMultipleModules', 'estimatedDays', 'irreversibleOps', 'protectedAssetTypes', 'publicInterfaceChange', 'uncertainRisk'];
19
+ if (provided.every((key) => input[key] === undefined)) {
20
+ reasons.unshift('no classification inputs provided; defaulting to T0');
21
+ }
22
+
23
+ const t2 =
24
+ input.touchesMultipleModules === true ||
25
+ (Array.isArray(input.irreversibleOps) && input.irreversibleOps.length > 0) ||
26
+ (Array.isArray(input.protectedAssetTypes) &&
27
+ input.protectedAssetTypes.some((t) =>
28
+ ['USER_DATA', 'SECRET', 'RUNTIME'].includes(t)
29
+ )) ||
30
+ input.publicInterfaceChange === true;
31
+
32
+ if (input.touchesMultipleModules === true) reasons.push('multi-module change → T2');
33
+ if (Array.isArray(input.irreversibleOps) && input.irreversibleOps.length) {
34
+ reasons.push(`irreversible ops (${input.irreversibleOps.slice(0, 10).join(', ')}${input.irreversibleOps.length > 10 ? ' …' : ''}) → T2`);
35
+ }
36
+ if (Array.isArray(input.protectedAssetTypes)) {
37
+ for (const t of input.protectedAssetTypes) {
38
+ if (['USER_DATA', 'SECRET', 'RUNTIME'].includes(t)) {
39
+ reasons.push(`protected asset ${t} → T2`);
40
+ }
41
+ }
42
+ }
43
+ if (input.publicInterfaceChange === true) reasons.push('public interface change → T2');
44
+
45
+ const days = Number(input.estimatedDays) || 0;
46
+ if (t2) {
47
+ tier = 'T2';
48
+ } else if (days >= 7) {
49
+ tier = 'T2';
50
+ reasons.push(`estimated ${days}d crosses a week boundary → T2`);
51
+ } else if (days >= 1) {
52
+ tier = 'T1';
53
+ reasons.push(`estimated ${days}d single-module work → T1`);
54
+ } else {
55
+ reasons.push('trivial, single-file, discardable → T0');
56
+ }
57
+
58
+ let escalated = false;
59
+ if (input.uncertainRisk === true && tier !== 'T2') {
60
+ tier = tier === 'T0' ? 'T1' : 'T2';
61
+ reasons.push('risk uncertain: escalated one tier');
62
+ escalated = true;
63
+ }
64
+
65
+ return { tier, reasons, escalated };
66
+ }
@@ -0,0 +1,68 @@
1
+ const MODES = new Set(['PLAN', 'EXECUTE']);
2
+ const TIERS = new Set(['T0', 'T1', 'T2']);
3
+ const OUTCOMES = new Set(['', 'CONTINUE', 'PAUSE', 'SCOPE-DOWN', 'PIVOT', 'STOP']);
4
+ const REPOSITORIES = new Set(['git', 'non-git', 'read-only']);
5
+ const FIELDS = new Set([
6
+ 'schema', 'project', 'goalLedger', 'nonGoals', 'constraints', 'assets',
7
+ 'assumptions', 'decisions', 'risks', 'dependencies', 'evidence', 'gates',
8
+ 'current', 'snapshot',
9
+ ]);
10
+
11
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const nonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
13
+ const field = (errors, path, message) => errors.push({ path, message });
14
+
15
+ export function validateContext(raw) {
16
+ const errors = [];
17
+ const warnings = [];
18
+ const unknownFields = [];
19
+ if (!isObject(raw)) {
20
+ return { valid: false, errors: [{ path: '/', message: 'context must be an object' }], warnings, unknownFields };
21
+ }
22
+ for (const key of Object.keys(raw)) {
23
+ if (!FIELDS.has(key)) {
24
+ unknownFields.push(key);
25
+ warnings.push({ path: `/${key}`, message: 'unknown field preserved' });
26
+ }
27
+ }
28
+ if (raw.schema !== 'tech-lead.context.v1') field(errors, '/schema', 'must equal tech-lead.context.v1');
29
+ if (!isObject(raw.project)) field(errors, '/project', 'must be an object');
30
+ else {
31
+ for (const key of ['id', 'name']) if (!nonEmptyString(raw.project[key])) field(errors, `/project/${key}`, 'must be a non-empty string');
32
+ if (!REPOSITORIES.has(raw.project.repositoryMode)) field(errors, '/project/repositoryMode', 'invalid repository mode');
33
+ }
34
+ if (!Array.isArray(raw.goalLedger) || raw.goalLedger.length === 0) field(errors, '/goalLedger', 'must contain at least one goal');
35
+ else if (!raw.goalLedger.some((goal) => isObject(goal) && nonEmptyString(goal.id) && nonEmptyString(goal.goal))) field(errors, '/goalLedger', 'must contain a goal with string id and goal');
36
+ for (const key of ['nonGoals', 'constraints', 'assets', 'assumptions', 'decisions', 'risks', 'dependencies', 'evidence', 'gates']) {
37
+ if (!Array.isArray(raw[key])) field(errors, `/${key}`, 'must be an array');
38
+ }
39
+ if (!isObject(raw.current)) field(errors, '/current', 'must be an object');
40
+ else {
41
+ if (!MODES.has(raw.current.mode)) field(errors, '/current/mode', 'invalid mode');
42
+ if (!TIERS.has(raw.current.tier)) field(errors, '/current/tier', 'invalid tier');
43
+ if (!nonEmptyString(raw.current.phase)) field(errors, '/current/phase', 'must be a non-empty string');
44
+ if (!OUTCOMES.has(raw.current.lastOutcome ?? '')) field(errors, '/current/lastOutcome', 'invalid outcome');
45
+ if (!nonEmptyString(raw.current.nextStep)) field(errors, '/current/nextStep', 'must be a non-empty string');
46
+ }
47
+ if (!isObject(raw.snapshot)) field(errors, '/snapshot', 'must be an object');
48
+ else {
49
+ if (!nonEmptyString(raw.snapshot.at)) field(errors, '/snapshot/at', 'must be a non-empty string');
50
+ if (raw.snapshot.source !== 'inline') field(errors, '/snapshot/source', 'must be inline');
51
+ if (!nonEmptyString(raw.snapshot.fingerprint)) field(errors, '/snapshot/fingerprint', 'must be a non-empty string');
52
+ }
53
+ return { valid: errors.length === 0, errors, warnings, unknownFields };
54
+ }
55
+
56
+ export function normalizeContext(raw) {
57
+ if (!isObject(raw)) return { value: raw, warnings: [] };
58
+ let value;
59
+ try {
60
+ value = JSON.parse(JSON.stringify(raw));
61
+ } catch {
62
+ return { value: raw, warnings: [{ code: 'CLONE_FAILED', path: '/', message: 'input is not serializable; returning original reference without copy guarantees' }] };
63
+ }
64
+ const warnings = Object.keys(raw)
65
+ .filter((key) => !FIELDS.has(key))
66
+ .map((key) => ({ path: `/${key}`, message: 'unknown field preserved' }));
67
+ return { value, warnings };
68
+ }
@@ -0,0 +1,50 @@
1
+ const items = (value) => Array.isArray(value) ? value : [];
2
+
3
+ export function criticalPath(tasks, dependencies) {
4
+ const findings = [];
5
+ const nodes = new Map();
6
+ for (const [index, task] of items(tasks).entries()) {
7
+ const id = typeof task?.id === 'string' ? task.id : '';
8
+ if (!id) {
9
+ findings.push({ code: 'INVALID_TASK_ID', path: `/tasks/${index}/id` });
10
+ continue;
11
+ }
12
+ if (nodes.has(id)) {
13
+ findings.push({ code: 'DUPLICATE_TASK_ID', path: `/tasks/${index}/id`, id });
14
+ continue;
15
+ }
16
+ nodes.set(id, task);
17
+ }
18
+ const outgoing = new Map([...nodes.keys()].map((id) => [id, []]));
19
+ const incoming = new Map([...nodes.keys()].map((id) => [id, 0]));
20
+ for (const edge of items(dependencies)) {
21
+ const from = String(edge?.from);
22
+ const to = String(edge?.to);
23
+ if (!nodes.has(from) || !nodes.has(to)) {
24
+ findings.push({ code: 'UNKNOWN_DEPENDENCY', from, to });
25
+ continue;
26
+ }
27
+ outgoing.get(to).push(from);
28
+ incoming.set(from, incoming.get(from) + 1);
29
+ }
30
+ const originalIncoming = new Map(incoming);
31
+ const queue = [...incoming.entries()].filter(([, count]) => count === 0).map(([id]) => id);
32
+ const order = [];
33
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
34
+ const id = queue[cursor];
35
+ order.push(id);
36
+ for (const next of outgoing.get(id)) {
37
+ incoming.set(next, incoming.get(next) - 1);
38
+ if (incoming.get(next) === 0) queue.push(next);
39
+ }
40
+ }
41
+ if (order.length !== nodes.size) {
42
+ const orderedSet = new Set(order);
43
+ const cycleNodes = [...nodes.keys()].filter((id) => !orderedSet.has(id)).sort();
44
+ findings.push({ code: 'CYCLE', message: 'dependency graph contains a cycle', cycleNodes });
45
+ }
46
+ const active = (id) => nodes.get(id)?.status !== 'done';
47
+ const critical = order.filter((id) => active(id) && (outgoing.get(id).length > 0 || originalIncoming.get(id) > 0));
48
+ const parallelWindows = order.filter((id) => active(id) && originalIncoming.get(id) === 0 && outgoing.get(id).length === 0).map((id) => [id]);
49
+ return { blockers: order.filter((id) => nodes.get(id)?.blocker && active(id)), criticalPath: critical, parallelWindows, findings };
50
+ }
@@ -0,0 +1,39 @@
1
+ const SCHEMA = 'tech-lead.result.v1';
2
+
3
+ const asArray = (value) => {
4
+ if (value == null) return [];
5
+ return Array.isArray(value) ? value.slice() : [value];
6
+ };
7
+
8
+ export function makeEnvelope({
9
+ ok = false,
10
+ code = ok ? 'OK' : 'UNKNOWN_ERROR',
11
+ data = null,
12
+ errors = [],
13
+ warnings = [],
14
+ operation = 'unknown',
15
+ meta = {},
16
+ } = {}) {
17
+ return {
18
+ ok: Boolean(ok),
19
+ code,
20
+ data,
21
+ errors: asArray(errors),
22
+ warnings: asArray(warnings),
23
+ meta: {
24
+ ...meta,
25
+ schema: SCHEMA,
26
+ operation,
27
+ deterministic: true,
28
+ sideEffects: false,
29
+ },
30
+ };
31
+ }
32
+
33
+ export function okEnvelope(operation, data, warnings = [], meta = {}) {
34
+ return makeEnvelope({ ok: true, code: 'OK', data, warnings, operation, meta });
35
+ }
36
+
37
+ export function errorEnvelope(operation, code, errors = [], data = null) {
38
+ return makeEnvelope({ ok: false, code, data, errors, operation });
39
+ }
@@ -0,0 +1,99 @@
1
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ const asArray = (value) => Array.isArray(value) ? value : [];
3
+ const finding = (code, path, message) => ({ code, path, message });
4
+
5
+ export function evidenceGraphLint(context) {
6
+ const findings = [];
7
+ const nodes = new Map();
8
+ const collections = ['goalLedger', 'risks', 'decisions', 'gates'];
9
+ for (const collection of collections) {
10
+ for (const [index, item] of asArray(context?.[collection]).entries()) {
11
+ if (!isObject(item) || !String(item.id ?? '').trim()) {
12
+ findings.push(finding('INVALID_LEDGER_ENTRY', `/${collection}/${index}`, 'ledger entry must be an object with a non-empty id'));
13
+ continue;
14
+ }
15
+ const id = String(item.id);
16
+ if (nodes.has(id)) findings.push(finding('DUPLICATE_ID', `/${collection}/${index}/id`, `duplicate id: ${id}`));
17
+ nodes.set(id, { type: collection, item });
18
+ }
19
+ }
20
+ const evidence = asArray(context?.evidence);
21
+ for (const [index, item] of evidence.entries()) {
22
+ if (!isObject(item) || !String(item.id ?? '').trim()) {
23
+ findings.push(finding('INVALID_EVIDENCE', `/evidence/${index}`, 'evidence must have an id'));
24
+ continue;
25
+ }
26
+ const evidenceId = String(item.id);
27
+ if (nodes.has(evidenceId)) findings.push(finding('DUPLICATE_ID', `/evidence/${index}/id`, `duplicate id: ${evidenceId}`));
28
+ nodes.set(evidenceId, { type: 'evidence', item });
29
+ }
30
+ const edges = [];
31
+ const evidenceEdges = new Map();
32
+ for (const [index, item] of evidence.entries()) {
33
+ if (!isObject(item) || !String(item.id ?? '').trim()) continue;
34
+ const evidenceId = String(item.id);
35
+ for (const target of asArray(item.supports)) {
36
+ if (!nodes.has(String(target))) findings.push(finding('UNKNOWN_REFERENCE', `/evidence/${index}/supports`, `unknown reference: ${target}`));
37
+ else {
38
+ const targetId = String(target);
39
+ edges.push({ from: evidenceId, to: targetId, relation: 'supports' });
40
+ if (nodes.get(targetId)?.type === 'evidence') {
41
+ if (!evidenceEdges.has(evidenceId)) evidenceEdges.set(evidenceId, []);
42
+ evidenceEdges.get(evidenceId).push(targetId);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ const color = new Map();
48
+ let hasCycle = false;
49
+ const stack = [];
50
+ for (const start of evidenceEdges.keys()) {
51
+ if (color.get(start)) continue;
52
+ stack.push([start, false]);
53
+ while (stack.length) {
54
+ const [id, processed] = stack.pop();
55
+ if (processed) { color.set(id, 2); continue; }
56
+ const state = color.get(id);
57
+ if (state === 1) { hasCycle = true; continue; }
58
+ if (state === 2) continue;
59
+ color.set(id, 1);
60
+ stack.push([id, true]);
61
+ for (const next of evidenceEdges.get(id) ?? []) {
62
+ if (!color.get(next)) stack.push([next, false]);
63
+ else if (color.get(next) === 1) hasCycle = true;
64
+ }
65
+ }
66
+ }
67
+ if (hasCycle) findings.push(finding('CYCLE', '/evidence', 'evidence graph contains a cycle'));
68
+ return { valid: findings.length === 0, findings, graph: { nodes: [...nodes.keys()], edges } };
69
+ }
70
+
71
+ export function evidenceFreshness(context, options = {}) {
72
+ options = isObject(options) ? options : {};
73
+ const warnings = [];
74
+ const findings = [];
75
+ let ageDays = 7;
76
+ if (options.maxAgeDays !== undefined) {
77
+ const n = Number(options.maxAgeDays);
78
+ if (typeof options.maxAgeDays === 'number' && Number.isFinite(n) && n >= 0) ageDays = n;
79
+ else warnings.push({ code: 'INVALID_MAX_AGE', message: 'maxAgeDays must be a finite number >= 0; using default 7' });
80
+ }
81
+ let now = Date.parse(options.now ?? new Date().toISOString());
82
+ if (!Number.isFinite(now)) {
83
+ warnings.push({ code: 'INVALID_NOW', message: 'using current time because now is not parseable' });
84
+ now = Date.now();
85
+ }
86
+ for (const [index, item] of asArray(context?.evidence).entries()) {
87
+ const time = Date.parse(item?.time);
88
+ if (!Number.isFinite(time)) {
89
+ findings.push(finding('INVALID_EVIDENCE_TIME', `/evidence/${index}/time`, 'time is not parseable'));
90
+ continue;
91
+ }
92
+ if (time > now) findings.push(finding('FUTURE_EVIDENCE', `/evidence/${index}/time`, 'evidence time is in the future'));
93
+ if (now - time > ageDays * 86400000) findings.push(finding('STALE_EVIDENCE', `/evidence/${index}`, 'evidence exceeds freshness window'));
94
+ if (options.fingerprint != null && item.fingerprint != null && item.fingerprint !== options.fingerprint) {
95
+ findings.push(finding('FINGERPRINT_DRIFT', `/evidence/${index}/fingerprint`, 'evidence fingerprint differs from current snapshot'));
96
+ }
97
+ }
98
+ return { stale: findings.some((item) => item.code === 'STALE_EVIDENCE' || item.code === 'FINGERPRINT_DRIFT' || item.code === 'FUTURE_EVIDENCE'), findings, warnings, evidence: asArray(context?.evidence) };
99
+ }
@@ -0,0 +1,50 @@
1
+ const LEVELS = ['E0', 'E1', 'E2', 'E3', 'E4'];
2
+ const REQUIRED = ['id', 'level', 'source', 'time', 'scope', 'repro'];
3
+
4
+ /**
5
+ * Evidence provenance lint (SKILL §5). Pure function.
6
+ *
7
+ * @param {unknown} evidence expected array of evidence entries
8
+ * @param {{ highRiskChange?: boolean }} [opts]
9
+ * @returns {Array<{severity:'error'|'warning', path:string, message:string}>}
10
+ */
11
+ export function evidenceLint(evidence, opts = {}) {
12
+ if (!Array.isArray(evidence)) {
13
+ return [{ severity: 'error', path: 'evidence', message: 'evidence must be an array' }];
14
+ }
15
+ /** @type {Array<{severity:'error'|'warning', path:string, message:string}>} */
16
+ const f = [];
17
+
18
+ evidence.forEach((entry, i) => {
19
+ if (entry === null || typeof entry !== 'object') {
20
+ f.push({ severity: 'error', path: `evidence[${i}]`, message: 'must be an object' });
21
+ return;
22
+ }
23
+ const e = /** @type {Record<string, unknown>} */ (entry);
24
+ for (const field of REQUIRED) {
25
+ const v = e[field];
26
+ if (typeof v !== 'string' || !v.trim()) {
27
+ f.push({ severity: 'error', path: `evidence[${i}].${field}`, message: 'required non-empty string' });
28
+ }
29
+ }
30
+ if (typeof e.level === 'string' && !LEVELS.includes(e.level)) {
31
+ f.push({ severity: 'error', path: `evidence[${i}].level`, message: 'must be one of E0,E1,E2,E3,E4' });
32
+ }
33
+ });
34
+
35
+ if (opts.highRiskChange) {
36
+ const maxLevel = evidence.reduce((max, entry) => {
37
+ const lvl = entry && typeof entry === 'object' ? entry.level : undefined;
38
+ return LEVELS.includes(lvl) ? Math.max(max, LEVELS.indexOf(lvl)) : max;
39
+ }, -1);
40
+ if (maxLevel < LEVELS.indexOf('E3')) {
41
+ f.push({
42
+ severity: 'error',
43
+ path: 'evidence',
44
+ message: 'high-risk change requires at least one E3+ evidence (integration/real-process)',
45
+ });
46
+ }
47
+ }
48
+
49
+ return f;
50
+ }
@@ -0,0 +1,79 @@
1
+ const VERDICTS = new Set(['pass', 'conditional', 'reject']);
2
+
3
+ /**
4
+ * Gate precheck (SKILL §5 referee separation + §6 blind protocol), mechanical
5
+ * subset. Pure function.
6
+ *
7
+ * @param {{
8
+ * proposalAuthorId?: string,
9
+ * executorId?: string,
10
+ * reviewerIds?: string[],
11
+ * solo?: boolean,
12
+ * blindRequired?: boolean,
13
+ * destructiveScope?: string[],
14
+ * reports?: Array<{reviewerId?: string, verdict?: string, anchors?: unknown[]}>,
15
+ * }} input
16
+ * @returns {{ pass: boolean, violations: Array<{type:string, detail:string}> }}
17
+ */
18
+ export function gatePrecheck(input = {}) {
19
+ if (input === null || typeof input !== 'object') {
20
+ return { pass: false, violations: [{ type: 'BAD_INPUT', detail: 'gate precheck input must be an object' }] };
21
+ }
22
+ const reviewerIds = Array.isArray(input.reviewerIds) ? input.reviewerIds : [];
23
+ const reports = Array.isArray(input.reports) ? input.reports : [];
24
+ if (input.reports !== undefined && !Array.isArray(input.reports)) {
25
+ // fall through with empty reports; violation recorded below for parity
26
+ }
27
+ /** @type {Array<{type:string, detail:string}>} */
28
+ const v = [];
29
+
30
+ if (input.proposalAuthorId && input.proposalAuthorId === input.executorId) {
31
+ v.push({ type: 'PROPOSER_IS_EXECUTOR', detail: 'referee separation requires proposalAuthorId != executorId' });
32
+ }
33
+
34
+ for (const who of ['proposalAuthorId', 'executorId']) {
35
+ const id = input[who];
36
+ if (id && reviewerIds.includes(id)) {
37
+ v.push({ type: 'IDENTITY_OVERLAP', detail: `${who} "${id}" may not also review` });
38
+ }
39
+ }
40
+
41
+ for (const [i, r] of reports.entries()) {
42
+ if (r === null || typeof r !== 'object') {
43
+ v.push({ type: 'BAD_REPORT', detail: `report[${i}] must be an object` });
44
+ continue;
45
+ }
46
+ if (!VERDICTS.has(r.verdict)) {
47
+ v.push({ type: 'BAD_VERDICT', detail: `report[${i}] verdict must be pass|conditional|reject` });
48
+ }
49
+ const anchors = Array.isArray(r.anchors) ? r.anchors.filter((a) => typeof a === 'string' && a.trim()) : [];
50
+ if (anchors.length === 0) {
51
+ v.push({ type: 'ANCHOR_MISSING', detail: `report[${i}] needs at least one file:line or command anchor` });
52
+ }
53
+ }
54
+
55
+ const destructiveScope = Array.isArray(input.destructiveScope) ? input.destructiveScope : [];
56
+ if (input.destructiveScope !== undefined && !Array.isArray(input.destructiveScope)) {
57
+ v.push({ type: 'BAD_DESTRUCTIVE_SCOPE', detail: 'destructiveScope must be an array of strings when provided' });
58
+ }
59
+ if (input.solo && destructiveScope.length > 0) {
60
+ v.push({
61
+ type: 'SOLO_FORBIDDEN',
62
+ detail: `solo:true cannot clear destructive scope (${destructiveScope.slice(0, 8).join(', ')}${destructiveScope.length > 8 ? ' …' : ''})`,
63
+ });
64
+ }
65
+
66
+ if (input.blindRequired) {
67
+ const distinctAnchored = new Set(
68
+ reports
69
+ .filter((r) => VERDICTS.has(r.verdict) &&
70
+ Array.isArray(r.anchors) && r.anchors.some((a) => typeof a === 'string' && a.trim()))
71
+ .map((r) => r.reviewerId)
72
+ ).size;
73
+ if (distinctAnchored < 3) {
74
+ v.push({ type: 'BLIND_QUORUM', detail: `blind gate needs ≥3 distinct anchored reviewers, got ${distinctAnchored}` });
75
+ }
76
+ }
77
+
78
+ return { pass: v.length === 0, violations: v };
79
+ }
@@ -0,0 +1,77 @@
1
+ const roles = ['pm', 'arch', 'eng', 'ops'];
2
+ const list = (value) => Array.isArray(value) ? value : [];
3
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
4
+
5
+ export function gatePlan(impact = {}, context = {}) {
6
+ impact = object(impact) ? impact : {};
7
+ context = object(context) ? context : {};
8
+ const high = impact.tier === 'T2' || impact.destructive === true || context.tier === 'T2';
9
+ const requiredRoles = high ? roles.slice() : ['eng'];
10
+ return {
11
+ requiredRoles,
12
+ minimumEvidence: high ? 'E3' : 'E2',
13
+ quorum: requiredRoles.length,
14
+ conditions: high ? ['all roles anchored', 'no reject', 'snapshot current'] : ['one anchored reviewer', 'no reject'],
15
+ };
16
+ }
17
+
18
+ export function gateAggregate(reports, plan = {}) {
19
+ plan = object(plan) ? plan : {};
20
+ const findings = [];
21
+ const seen = new Set();
22
+ const rolesSeen = new Set();
23
+ const validReports = [];
24
+ const rejects = [];
25
+ const conditionals = [];
26
+ list(reports).forEach((report, index) => {
27
+ const collect = () => {
28
+ for (const item of list(report.findings)) {
29
+ const id = String(item?.id ?? `${report.role}:${item?.message ?? ''}`);
30
+ if (!seen.has(id)) { seen.add(id); findings.push(item); }
31
+ }
32
+ if (report.verdict === 'reject') rejects.push(report);
33
+ if (report.verdict === 'conditional') conditionals.push(report);
34
+ };
35
+ if (!object(report) || typeof report.role !== 'string' || !report.role || !Array.isArray(report.anchors) || report.anchors.length === 0) {
36
+ findings.push({ code: 'INVALID_REPORT', path: `/reports/${index}`, message: 'report needs role and anchors' });
37
+ return;
38
+ }
39
+ if (report.verdict !== 'pass' && report.verdict !== 'reject' && report.verdict !== 'conditional') {
40
+ findings.push({ code: 'INVALID_VERDICT', path: `/reports/${index}/verdict`, message: 'invalid report verdict' });
41
+ collect();
42
+ return;
43
+ }
44
+ if (rolesSeen.has(report.role)) {
45
+ findings.push({ code: 'DUPLICATE_ROLE', path: `/reports/${index}/role`, message: `duplicate reviewer role: ${report.role}` });
46
+ collect();
47
+ return;
48
+ }
49
+ if (!report.anchors.every((anchor) => typeof anchor === 'string' && anchor.trim().length > 0)) {
50
+ findings.push({ code: 'INVALID_REPORT', path: `/reports/${index}/anchors`, message: 'anchors must be non-empty strings' });
51
+ collect();
52
+ return;
53
+ }
54
+ rolesSeen.add(report.role);
55
+ validReports.push(report);
56
+ collect();
57
+ });
58
+ const required = [...new Set(list(plan.requiredRoles).filter((role) => typeof role === 'string' && role))];
59
+ const quorum = Number.isInteger(plan.quorum) && plan.quorum > 0 ? plan.quorum : required.length;
60
+ if (!required.length || quorum > required.length) findings.push({ code: 'INVALID_PLAN', message: 'plan needs non-empty requiredRoles and a valid quorum' });
61
+ const satisfiedRoles = new Set(validReports.map((report) => report.role));
62
+ const missing = required.filter((role) => !satisfiedRoles.has(role));
63
+ const pass = findings.length === 0 && missing.length === 0 && validReports.length >= quorum && rejects.length === 0 && conditionals.length === 0;
64
+ const verdict = rejects.length ? 'reject' : pass ? 'pass' : 'conditional';
65
+ return { pass, verdict, findings, missingRoles: missing, unresolved: missing.length > 0 || rejects.length > 0 || conditionals.length > 0 || findings.length > 0 };
66
+ }
67
+
68
+ export function gateReopen(previous = {}, current = {}) {
69
+ previous = object(previous) ? previous : {};
70
+ current = object(current) ? current : {};
71
+ const changedInputs = [];
72
+ if (previous.contextFingerprint !== current.contextFingerprint) changedInputs.push('context');
73
+ if (previous.evidenceFingerprint !== current.evidenceFingerprint) changedInputs.push('evidence');
74
+ if (previous.dependencyFingerprint !== current.dependencyFingerprint) changedInputs.push('dependencies');
75
+ if (previous.impactFingerprint !== current.impactFingerprint) changedInputs.push('impact');
76
+ return { reopen: changedInputs.length > 0, changedInputs, reasons: changedInputs.map((item) => `${item} changed`) };
77
+ }
@@ -0,0 +1,15 @@
1
+ const items = (value) => Array.isArray(value) ? value : [];
2
+
3
+ export function changeImpact(change = {}, context = {}) {
4
+ change = change !== null && typeof change === 'object' && !Array.isArray(change) ? change : {};
5
+ context = context !== null && typeof context === 'object' && !Array.isArray(context) ? context : {};
6
+ const modules = items(change.modules);
7
+ const assets = items(change.assets);
8
+ const irreversible = Boolean(change.irreversible);
9
+ const high = irreversible || Boolean(change.publicInterface) || modules.length >= 2 || assets.some((item) => ['USER_DATA', 'SECRET', 'RUNTIME'].includes(item));
10
+ const medium = !high && assets.some((item) => ['CONFIG', 'GENERATED'].includes(item));
11
+ const tier = high ? 'T2' : medium ? 'T1' : 'T0';
12
+ const reversible = !irreversible;
13
+ const reopenGates = high ? items(context.gates).map((gate) => String(gate?.id)).filter(Boolean) : [];
14
+ return { tier, assets, reversible, blastRadius: high ? 'high' : medium ? 'medium' : 'low', reopenGates, reasons: [high ? 'high-impact trigger matched' : medium ? 'single-module or config change' : 'reversible source-only change'] };
15
+ }
@@ -0,0 +1,18 @@
1
+ export { validateState } from './state.js';
2
+ export { classify } from './classify.js';
3
+ export { transitionCheck } from './transition.js';
4
+ export { evidenceLint } from './evidence.js';
5
+ export { planLint } from './plan.js';
6
+ export { gatePrecheck } from './gate.js';
7
+ export { releaseAudit } from './release.js';
8
+ export { installAudit } from './install.js';
9
+ export { resumeCard } from './resume.js';
10
+ export { makeEnvelope, okEnvelope, errorEnvelope } from './envelope.js';
11
+ export { getCapabilities } from './capabilities.js';
12
+ export { validateContext, normalizeContext } from './context.js';
13
+ export { evidenceGraphLint, evidenceFreshness } from './evidence-graph.js';
14
+ export { progressDecide } from './progress.js';
15
+ export { criticalPath } from './critical-path.js';
16
+ export { changeImpact } from './impact.js';
17
+ export { gatePlan, gateAggregate, gateReopen } from './gates.js';
18
+ export { validateMutationIntent, previewMutation } from './mutation.js';