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,31 @@
1
+ /**
2
+ * Install drift audit over caller-supplied listings. Pure function.
3
+ * Hash comparison lives in bin/install.js --check; this module does set math.
4
+ *
5
+ * @param {{version: string, files: string[]}} manifest
6
+ * @param {string[]} actualFiles all files present under target (relative, posix)
7
+ * @param {string[]} pkgFiles managed files in the current package
8
+ * @param {string} pkgVersion
9
+ * @returns {{
10
+ * missingManaged: string[],
11
+ * unmanaged: string[],
12
+ * versionMismatch: boolean,
13
+ * newInPackage: string[],
14
+ * }}
15
+ */
16
+ export function installAudit(manifest, actualFiles, pkgFiles, pkgVersion) {
17
+ const managedSet = new Set(manifest?.files ?? []);
18
+ const actualSet = new Set(Array.isArray(actualFiles) ? actualFiles : []);
19
+ const isBackup = (rel) => /\.bak-\d+(?:-\d+)?$/.test(rel);
20
+
21
+ const missingManaged = (manifest?.files ?? []).filter((f) => !actualSet.has(f));
22
+ const unmanaged = (Array.isArray(actualFiles) ? actualFiles : []).filter((f) => !managedSet.has(f) && !isBackup(f)).sort();
23
+ const newInPackage = (Array.isArray(pkgFiles) ? pkgFiles : []).filter((f) => !managedSet.has(f));
24
+
25
+ return {
26
+ missingManaged,
27
+ unmanaged,
28
+ versionMismatch: Boolean(manifest && pkgVersion && manifest.version !== pkgVersion),
29
+ newInPackage,
30
+ };
31
+ }
@@ -0,0 +1,53 @@
1
+ import { errorEnvelope, okEnvelope } from './envelope.js';
2
+
3
+ const list = (value) => Array.isArray(value) ? value : [];
4
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const clone = (value) => JSON.parse(JSON.stringify(value));
6
+ const EXECUTABLE_OPERATIONS = new Set(['apply', 'execute', 'deploy']);
7
+ const MARKER_PATTERN = /\b(apply|execute|deploy)\b/i;
8
+ const scanMarkers = (value, base, depth, errors) => {
9
+ if (depth > 24 || errors.length > 32) return;
10
+ if (typeof value === 'string') {
11
+ if (MARKER_PATTERN.test(value)) errors.push({ path: base, code: 'CAPABILITY_DENIED', message: 'executable marker inside intent payload' });
12
+ return;
13
+ }
14
+ if (Array.isArray(value)) { value.forEach((item, i) => scanMarkers(item, `${base}/${i}`, depth + 1, errors)); return; }
15
+ if (value !== null && typeof value === 'object') {
16
+ for (const key of Object.keys(value)) scanMarkers(value[key], `${base}/${key}`, depth + 1, errors);
17
+ }
18
+ };
19
+
20
+ export function validateMutationIntent(raw) {
21
+ const errors = [];
22
+ if (!object(raw)) return { valid: false, errors: [{ path: '/', message: 'intent must be an object' }], warnings: [] };
23
+ if (raw.schema !== 'tech-lead.mutation-intent.v1') errors.push({ path: '/schema', message: 'invalid schema' });
24
+ if (raw.mode !== 'read-only-preview') errors.push({ path: '/mode', code: 'CAPABILITY_DENIED', message: 'only read-only-preview is available' });
25
+ if (!list(raw.target).length) errors.push({ path: '/target', message: 'at least one target is required' });
26
+ for (const [index, target] of list(raw.target).entries()) if (EXECUTABLE_OPERATIONS.has(target?.operation)) errors.push({ path: `/target/${index}/operation`, code: 'CAPABILITY_DENIED', message: 'executable target operations are unavailable' });
27
+ if (!list(raw.expectedDiff).length) errors.push({ path: '/expectedDiff', message: 'expectedDiff is required' });
28
+ if (!object(raw.recoveryPoint) || raw.recoveryPoint.required !== true) errors.push({ path: '/recoveryPoint', message: 'recovery point is required' });
29
+ if (!list(raw.verification).length) errors.push({ path: '/verification', message: 'verification is required' });
30
+ if (!object(raw.authorization) || raw.authorization.required !== true) errors.push({ path: '/authorization', message: 'authorization declaration is required' });
31
+ return { valid: errors.length === 0, errors, warnings: [] };
32
+ }
33
+
34
+ export function previewMutation(raw) {
35
+ if (!object(raw) || raw.mode !== 'read-only-preview') return errorEnvelope('mutation_preview', 'CAPABILITY_DENIED', [{ code: 'CAPABILITY_DENIED', message: 'mutation execution is not enabled' }]);
36
+ const validation = validateMutationIntent(raw);
37
+ if (!validation.valid) return errorEnvelope('mutation_preview', 'SCHEMA_INVALID', validation.errors, validation);
38
+ const markers = [];
39
+ for (const field of ['target', 'expectedDiff', 'verification', 'recoveryPoint', 'authorization']) scanMarkers(raw[field], `/${field}`, 0, markers);
40
+ if (markers.length) return errorEnvelope('mutation_preview', 'CAPABILITY_DENIED', markers);
41
+ try {
42
+ return okEnvelope('mutation_preview', {
43
+ execution: 'not performed',
44
+ mode: 'read-only-preview',
45
+ targets: clone(list(raw.target)),
46
+ expectedDiff: clone(list(raw.expectedDiff)),
47
+ verification: clone(list(raw.verification)),
48
+ authorization: clone(raw.authorization),
49
+ });
50
+ } catch {
51
+ return errorEnvelope('mutation_preview', 'SERIALIZATION_FAILED', [{ code: 'SERIALIZATION_FAILED', path: '/', message: 'intent payload is not serializable for preview' }]);
52
+ }
53
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Plan completeness lint (SKILL §3.1 goal ledger + assumption/decision/risk/
3
+ * dependency contracts). Pure function.
4
+ *
5
+ * @param {Record<string, unknown>} plan
6
+ * @returns {Array<{severity:'error'|'warning', path:string, message:string}>}
7
+ */
8
+ export function planLint(plan) {
9
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) {
10
+ return [{ severity: 'error', path: 'plan', message: 'plan must be an object' }];
11
+ }
12
+ /** @type {Array<{severity:'error'|'warning', path:string, message:string}>} */
13
+ const f = [];
14
+ const reqStr = (obj, key) => typeof obj[key] === 'string' && !!obj[key].trim();
15
+
16
+ if (!reqStr(plan, 'goal')) {
17
+ f.push({ severity: 'error', path: 'goal', message: 'required non-empty goal' });
18
+ } else {
19
+ if (!reqStr(plan, 'metric')) f.push({ severity: 'error', path: 'metric', message: 'goal needs a measurable metric' });
20
+ if (!reqStr(plan, 'target')) f.push({ severity: 'error', path: 'target', message: 'goal needs a target value' });
21
+ }
22
+
23
+ for (const [i, item] of objectItemsOrGap(plan.assumptions, 'assumptions', f)) {
24
+ if (!reqStr(item, 'verification')) {
25
+ f.push({ severity: 'error', path: `assumptions[${i}].verification`, message: 'assumption needs a verification method' });
26
+ }
27
+ }
28
+ for (const [i, item] of objectItemsOrGap(plan.decisions, 'decisions', f)) {
29
+ if (!hasAlternatives(item)) f.push({ severity: 'error', path: `decisions[${i}].alternatives`, message: 'decision must record rejected alternatives' });
30
+ if (!reqStr(item, 'reason')) f.push({ severity: 'error', path: `decisions[${i}].reason`, message: 'decision must record its reason' });
31
+ }
32
+ for (const [i, item] of objectItemsOrGap(plan.risks, 'risks', f)) {
33
+ if (!reqStr(item, 'impact')) f.push({ severity: 'error', path: `risks[${i}].impact`, message: 'risk needs impact' });
34
+ if (!reqStr(item, 'mitigation')) f.push({ severity: 'error', path: `risks[${i}].mitigation`, message: 'risk needs mitigation' });
35
+ }
36
+ for (const [i, item] of objectItemsOrGap(plan.dependencies, 'dependencies', f)) {
37
+ if (!reqStr(item, 'blocker')) f.push({ severity: 'error', path: `dependencies[${i}].blocker`, message: 'dependency needs its blocking relation / alternative path' });
38
+ }
39
+
40
+ const irreversible = Array.isArray(plan.irreversibleOps) ? plan.irreversibleOps : [];
41
+ if (irreversible.length && !reqStr(plan, 'rollback')) {
42
+ f.push({ severity: 'error', path: 'rollback', message: 'irreversible operations require a rollback plan' });
43
+ }
44
+
45
+ return f;
46
+ }
47
+
48
+ function objectItemsOrGap(arr, name, findings) {
49
+ const out = [];
50
+ if (!Array.isArray(arr)) return out;
51
+ arr.forEach((item, i) => {
52
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) out.push([i, item]);
53
+ else findings.push({ severity: 'error', path: `${name}[${i}]`, message: 'must be an object ({claim,...}, {choice,...}, {description,...}, {what,...})' });
54
+ });
55
+ return out;
56
+ }
57
+
58
+ function hasAlternatives(item) {
59
+ const a = item.alternatives;
60
+ if (typeof a === 'string') return !!a.trim();
61
+ return Array.isArray(a) && a.length > 0;
62
+ }
@@ -0,0 +1,20 @@
1
+ const list = (value) => Array.isArray(value) ? value : [];
2
+
3
+ export function progressDecide(context, options = {}) {
4
+ if (!context || typeof context !== 'object' || Array.isArray(context)) return {
5
+ outcome: 'PAUSE', allowed: false, reasons: [{ code: 'BAD_INPUT', message: 'context must be an object' }], blockers: [], requiredActions: ['provide context'], confidence: 1,
6
+ };
7
+ options = options && typeof options === 'object' && !Array.isArray(options) ? options : {};
8
+ const blockers = list(context.dependencies).filter((item) => item?.blocker && item.status !== 'done').map((item) => String(item.id ?? 'unknown'));
9
+ const reasons = [];
10
+ const stale = list(context.evidence).filter((item) => item?.stale).map((item) => String(item.id ?? 'unknown'));
11
+ if (stale.length) reasons.push({ code: 'STALE_EVIDENCE', evidence: stale });
12
+ if (blockers.length) reasons.push({ code: 'DEPENDENCY_BLOCKED', dependencies: blockers });
13
+ const destructiveGate = list(context.gates).some((item) => item?.destructive && item.status !== 'pass');
14
+ if (destructiveGate) reasons.push({ code: 'GATE_BLOCKED', message: 'destructive gate is not passed' });
15
+ if (stale.length || blockers.length || destructiveGate) return {
16
+ outcome: 'PAUSE', allowed: false, reasons, blockers, requiredActions: ['resolve blockers and refresh evidence'], confidence: 1,
17
+ };
18
+ if (options.forcePivot === true) return { outcome: 'PIVOT', allowed: false, reasons: [{ code: 'PIVOT_REQUESTED' }], blockers: [], requiredActions: ['record decision'], confidence: 0.8 };
19
+ return { outcome: 'CONTINUE', allowed: true, reasons: [{ code: 'NO_BLOCKER' }], blockers: [], requiredActions: [], confidence: 0.7 };
20
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Release audit (SKILL §11 INVENTORY/CONTENT-SCAN, mechanical subset).
3
+ * Pure function: regex scan over provided content only — no filesystem access.
4
+ *
5
+ * @param {{
6
+ * allowlist: string[],
7
+ * files: Array<{path: string, content?: string}>,
8
+ * contentScan?: boolean,
9
+ * }} input
10
+ * @returns {Array<{type:string, path:string, line:number, detail:string}>}
11
+ */
12
+ export function releaseAudit(input = {}) {
13
+ /** @type {Array<{type:string, path:string, line:number, detail:string}>} */
14
+ const v = [];
15
+ if (input === null || typeof input !== 'object' || !Array.isArray(input.files)) {
16
+ return [{ type: 'BAD_INPUT', path: '', line: 0, detail: 'releaseAudit expects {allowlist[], files[]}' }];
17
+ }
18
+ const allowlist = new Set(input.allowlist ?? []);
19
+ const contentScan = input.contentScan !== false;
20
+
21
+ for (const file of input.files) {
22
+ if (file === null || typeof file !== 'object' || typeof file.path !== 'string') {
23
+ v.push({ type: 'BAD_ENTRY', path: '', line: 0, detail: 'files[] entries must be objects with a path' });
24
+ continue;
25
+ }
26
+ if (!allowlist.has(file.path)) {
27
+ v.push({ type: 'EXTRA_FILE', path: file.path, line: 0, detail: 'not in release allowlist' });
28
+ }
29
+ if (!contentScan) continue;
30
+ if (typeof file.content !== 'string') {
31
+ v.push({ type: 'UNSCANNED', path: file.path, line: 0, detail: 'no content provided; file was NOT leak-scanned' });
32
+ continue;
33
+ }
34
+
35
+ const lines = file.content.split('\n');
36
+ lines.forEach((line, idx) => {
37
+ if (ABS_PATH_RE.test(line)) {
38
+ push(v, 'ABS_PATH', file.path, idx + 1, 'absolute home/user path');
39
+ } else {
40
+ const tok = line.match(TOKEN_RE);
41
+ if (tok) push(v, 'TOKEN_SUSPECT', file.path, idx + 1, `token-like literal (${tok[0].slice(0, 6)}…)`);
42
+ const cred = line.match(CRED_LINE_RE);
43
+ if (cred) push(v, 'CREDENTIAL_LINE', file.path, idx + 1, 'credential assignment');
44
+ }
45
+ });
46
+ }
47
+ return v;
48
+ }
49
+
50
+ const ABS_PATH_RE =
51
+ /(\/data\/data\/com\.termux|\/Users\/[A-Za-z0-9_.-]+|\/home\/[A-Za-z0-9_.-]+|\/root(?:\/[^\s'"]*)?|C:\\+Users\\+[A-Za-z0-9_.-]+)/i;
52
+ const TOKEN_RE = /\b(sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[A-Za-z0-9_-]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})/;
53
+ const CRED_LINE_RE = /\b(bearer\s+[A-Za-z0-9._-]{10,}|(password|passwd|pwd|secret|api[_-]?key|token)\s*[=:]\s*['"]?[^\s'\"]{6,})/i;
54
+
55
+ function push(arr, type, path, line, detail) {
56
+ arr.push({ type, path, line, detail });
57
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Resume card renderer (SKILL §7). Pure function.
3
+ *
4
+ * @param {Record<string, unknown>} state validated-ish tech-lead state object
5
+ * @param {{ now?: string, maxAgeDays?: number }} [opts]
6
+ * @returns {{
7
+ * position: string,
8
+ * lastGate: string,
9
+ * nextStep: string,
10
+ * staleEvidenceIds: string[],
11
+ * warnings: string[],
12
+ * }}
13
+ */
14
+ export function resumeCard(maybeState, maybeOpts = {}) {
15
+ const opts = maybeOpts !== null && typeof maybeOpts === 'object' && !Array.isArray(maybeOpts) ? maybeOpts : {};
16
+ const warnings = [];
17
+ const state = maybeState !== null && typeof maybeState === 'object' ? maybeState : {};
18
+ if (maybeState === null || typeof maybeState !== 'object') {
19
+ warnings.push('state is missing or not an object; card rendered from defaults');
20
+ }
21
+ const tier = String(state.tier ?? '?');
22
+ const phase = String(state.phase ?? '?');
23
+ const mode = String(state.mode ?? '?');
24
+
25
+ const nextStep = typeof state.next_step === 'string' && state.next_step.trim()
26
+ ? state.next_step
27
+ : '(empty — set next_step before resuming)';
28
+ if (!(typeof state.next_step === 'string' && state.next_step.trim())) {
29
+ warnings.push('next_step is empty; set it before resuming');
30
+ }
31
+
32
+ if (Array.isArray(state.open_gates) && state.open_gates.length) {
33
+ warnings.push(`open gates pending: ${state.open_gates.slice(0, 20).join(', ')}${state.open_gates.length > 20 ? ` …+${state.open_gates.length - 20} more` : ''}`);
34
+ }
35
+
36
+ const staleEvidenceIds = [];
37
+ let nowMs = Date.now();
38
+ if (opts.now) {
39
+ const parsed = Date.parse(opts.now);
40
+ if (Number.isFinite(parsed)) nowMs = parsed;
41
+ else warnings.push('opts.now is not a parseable time; using current clock');
42
+ }
43
+ let maxAgeDays = 7;
44
+ if (opts.maxAgeDays !== undefined) {
45
+ const n = Number(opts.maxAgeDays);
46
+ if (typeof opts.maxAgeDays === 'number' && Number.isFinite(n) && n >= 0) maxAgeDays = n;
47
+ else warnings.push('maxAgeDays must be a finite number >= 0; using default 7');
48
+ }
49
+ if (Number.isFinite(nowMs) && Array.isArray(state.evidence)) {
50
+ for (const e of state.evidence) {
51
+ if (!e || typeof e !== 'object') continue;
52
+ const t = Date.parse(String(e.time ?? ''));
53
+ if (!Number.isFinite(t)) {
54
+ warnings.push(`evidence ${String(e.id ?? '?')} has unparseable time; excluded from staleness check`);
55
+ continue;
56
+ }
57
+ if (t > nowMs || nowMs - t > maxAgeDays * 86400_000) {
58
+ staleEvidenceIds.push(String(e.id));
59
+ }
60
+ }
61
+ }
62
+ if (staleEvidenceIds.length) {
63
+ warnings.push(`stale evidence (> ${maxAgeDays}d): ${staleEvidenceIds.slice(0, 20).join(', ')}${staleEvidenceIds.length > 20 ? ` …+${staleEvidenceIds.length - 20} more` : ''}`);
64
+ }
65
+
66
+ return {
67
+ position: `[${tier}] ${phase} · mode=${mode}`,
68
+ lastGate: String(state.last_outcome ?? '') || 'none',
69
+ nextStep,
70
+ staleEvidenceIds,
71
+ warnings,
72
+ };
73
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Machine-checkable validation for tech-lead state.json (schema v1).
3
+ * Pure function: no I/O, throws only on programmer error.
4
+ *
5
+ * @param {unknown} raw parsed JSON value
6
+ * @returns {{
7
+ * valid: boolean,
8
+ * errors: Array<{path: string, message: string}>,
9
+ * warnings: Array<{path: string, message: string}>,
10
+ * unknownFields: string[],
11
+ * }}
12
+ */
13
+ export function validateState(raw) {
14
+ const errors = [];
15
+ const warnings = [];
16
+ const unknownFields = [];
17
+
18
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
19
+ return {
20
+ valid: false,
21
+ errors: [{ path: '', message: 'state must be a JSON object' }],
22
+ warnings,
23
+ unknownFields,
24
+ };
25
+ }
26
+
27
+ const s = /** @type {Record<string, unknown>} */ (raw);
28
+
29
+ const KNOWN_FIELDS = new Set([
30
+ 'schema_version', 'mode', 'tier', 'phase', 'repository_mode',
31
+ 'state_persistence', 'done', 'open_gates', 'goal_ledger', 'constraints',
32
+ 'decisions', 'risks', 'dependencies', 'evidence', 'critical_path',
33
+ 'protected_assets', 'hypotheses', 'assumptions', 'last_outcome',
34
+ 'next_review_trigger', 'degraded_reason', 'tags', 'next_step', 'updated_at',
35
+ ]);
36
+ for (const key of Object.keys(s)) {
37
+ if (!KNOWN_FIELDS.has(key)) unknownFields.push(key);
38
+ }
39
+ for (const key of unknownFields) {
40
+ warnings.push({ path: key, message: 'unknown field preserved (schema v1 does not define it)' });
41
+ }
42
+
43
+ // schema_version: number 1 or legacy string "1"
44
+ if (s.schema_version !== 1 && s.schema_version !== '1') {
45
+ errors.push({ path: 'schema_version', message: "must be 1 (number) or \"1\" (legacy)" });
46
+ }
47
+
48
+ requireEnum(s, 'mode', ['PLAN', 'EXECUTE'], errors);
49
+ requireEnum(s, 'tier', ['T0', 'T1', 'T2'], errors);
50
+ requireEnum(s, 'repository_mode', ['git', 'non-git', 'read-only'], errors);
51
+ optionalEnum(s, 'state_persistence', ['available', 'unavailable'], errors);
52
+ optionalEnum(s, 'last_outcome',
53
+ ['', 'CONTINUE', 'PAUSE', 'SCOPE-DOWN', 'PIVOT', 'STOP'], errors);
54
+
55
+ requireNonEmptyString(s, 'phase', errors);
56
+ requireNonEmptyString(s, 'updated_at', errors);
57
+
58
+ // done[]: each entry needs item + non-empty anchor
59
+ if (Array.isArray(s.done)) {
60
+ s.done.forEach((entry, i) => {
61
+ if (entry === null || typeof entry !== 'object') {
62
+ errors.push({ path: `done[${i}]`, message: 'must be an object' });
63
+ return;
64
+ }
65
+ const e = /** @type {Record<string, unknown>} */ (entry);
66
+ if (typeof e.item !== 'string' || !e.item.trim()) {
67
+ errors.push({ path: `done[${i}].item`, message: 'required non-empty string' });
68
+ }
69
+ if (typeof e.anchor !== 'string' || !e.anchor.trim()) {
70
+ errors.push({ path: `done[${i}].anchor`, message: 'required non-empty anchor (commit/tag/file:line)' });
71
+ }
72
+ });
73
+ } else if (s.done !== undefined) {
74
+ errors.push({ path: 'done', message: 'must be an array' });
75
+ }
76
+
77
+ // evidence[]: full provenance + bounded level
78
+ if (Array.isArray(s.evidence)) {
79
+ const LEVELS = new Set(['E0', 'E1', 'E2', 'E3', 'E4']);
80
+ const REQUIRED = ['id', 'level', 'source', 'time', 'scope', 'repro'];
81
+ s.evidence.forEach((entry, i) => {
82
+ if (entry === null || typeof entry !== 'object') {
83
+ errors.push({ path: `evidence[${i}]`, message: 'must be an object' });
84
+ return;
85
+ }
86
+ const e = /** @type {Record<string, unknown>} */ (entry);
87
+ for (const field of REQUIRED) {
88
+ const v = e[field];
89
+ if (typeof v !== 'string' || !v.trim()) {
90
+ errors.push({ path: `evidence[${i}].${field}`, message: 'required non-empty string' });
91
+ }
92
+ }
93
+ if (typeof e.level === 'string' && !LEVELS.has(e.level)) {
94
+ errors.push({ path: `evidence[${i}].level`, message: 'must be one of E0,E1,E2,E3,E4' });
95
+ }
96
+ });
97
+ } else if (s.evidence !== undefined) {
98
+ errors.push({ path: 'evidence', message: 'must be an array' });
99
+ }
100
+
101
+ return { valid: errors.length === 0, errors, warnings, unknownFields };
102
+ }
103
+
104
+ function requireEnum(obj, key, values, errors) {
105
+ const v = obj[key];
106
+ if (!values.includes(v)) {
107
+ errors.push({ path: key, message: `must be one of ${values.join('|')}` });
108
+ }
109
+ }
110
+
111
+ function optionalEnum(obj, key, values, errors) {
112
+ const v = obj[key];
113
+ if (v !== undefined && !values.includes(v)) {
114
+ errors.push({ path: key, message: `must be one of ${values.join('|')} (or omitted)` });
115
+ }
116
+ }
117
+
118
+ function requireNonEmptyString(obj, key, errors) {
119
+ const v = obj[key];
120
+ if (typeof v !== 'string' || !v.trim()) {
121
+ errors.push({ path: key, message: 'required non-empty string' });
122
+ }
123
+ }
@@ -0,0 +1,42 @@
1
+ const OUTCOMES = ['CONTINUE', 'PAUSE', 'SCOPE-DOWN', 'PIVOT', 'STOP'];
2
+
3
+ /**
4
+ * Mechanical outcome-transition check (SKILL §4.8 subset). Pure function.
5
+ *
6
+ * @param {Record<string, unknown>} state tech-lead state object
7
+ * @param {string} proposed proposed last_outcome
8
+ * @returns {{ allowed: boolean, reason: string }}
9
+ */
10
+ export function transitionCheck(state, proposed) {
11
+ if (!OUTCOMES.includes(proposed)) {
12
+ return { allowed: false, reason: `proposed must be one of ${OUTCOMES.join('|')}` };
13
+ }
14
+ if (proposed === 'CONTINUE') {
15
+ return { allowed: true, reason: 'CONTINUE is always available when evidence supports the next step' };
16
+ }
17
+
18
+ const arr = (k) => (Array.isArray(state?.[k]) ? state[k] : []);
19
+ const decisions = arr('decisions');
20
+ const goalLedger = arr('goal_ledger');
21
+ const risks = arr('risks');
22
+ const done = arr('done');
23
+ const degraded = typeof state?.degraded_reason === 'string' && !!state.degraded_reason.trim();
24
+
25
+ if (proposed === 'PIVOT') {
26
+ return decisions.length > 0
27
+ ? { allowed: true, reason: 'PIVOT requires a falsified recorded decision; decisions ledger present' }
28
+ : { allowed: false, reason: 'PIVOT requires at least one recorded decision being falsified (decisions[] empty)' };
29
+ }
30
+ if (proposed === 'SCOPE-DOWN') {
31
+ return goalLedger.length > 0 && risks.length > 0
32
+ ? { allowed: true, reason: 'SCOPE-DOWN rewrites goal/DoD; ledger and risks present' }
33
+ : { allowed: false, reason: 'SCOPE-DOWN requires non-empty goal_ledger[] and risks[]' };
34
+ }
35
+ if (proposed === 'STOP') {
36
+ return done.length > 0 || degraded
37
+ ? { allowed: true, reason: done.length ? 'STOP backed by completed anchored items' : 'STOP justified via degraded_reason' }
38
+ : { allowed: false, reason: 'STOP requires achieved anchored items or a degraded_reason justification' };
39
+ }
40
+ // PAUSE
41
+ return { allowed: true, reason: 'PAUSE halts side effects and preserves resume conditions' };
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,33 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import {
3
+ classify, validateState, transitionCheck,
4
+ evidenceLint, planLint, gatePrecheck,
5
+ releaseAudit, installAudit, resumeCard,
6
+ getCapabilities,
7
+ validateContext, evidenceGraphLint, evidenceFreshness,
8
+ progressDecide, criticalPath, changeImpact,
9
+ gatePlan, gateAggregate, gateReopen, previewMutation,
10
+ } from './core/index.js';
11
+ import { registerTools } from './tools.js';
12
+
13
+ export const name = 'tech-lead-tools';
14
+ export const inject = ['tools'];
15
+ export { getCapabilities };
16
+
17
+ /**
18
+ * Registers the read-only tech-lead tool surface. Every tool computes over
19
+ * caller-supplied JSON — no filesystem writes, no subprocesses, no network.
20
+ * @param {import('@deepseek-ai/cordis').Context} ctx
21
+ */
22
+ export function apply(ctx) {
23
+ for (const tool of registerTools(defineTool, {
24
+ classify, validateState, transitionCheck,
25
+ evidenceLint, planLint, gatePrecheck,
26
+ releaseAudit, installAudit, resumeCard,
27
+ validateContext, evidenceGraphLint, evidenceFreshness,
28
+ progressDecide, criticalPath, changeImpact,
29
+ gatePlan, gateAggregate, gateReopen, previewMutation,
30
+ })) {
31
+ ctx.tools.register(tool);
32
+ }
33
+ }
@@ -0,0 +1,119 @@
1
+ import { errorEnvelope } from './core/index.js';
2
+
3
+ export function parseJsonString(value, path = 'input') {
4
+ if (typeof value !== 'string') {
5
+ return { ok: false, error: { code: 'BAD_INPUT', path, message: 'expected JSON text string' } };
6
+ }
7
+ try {
8
+ return { ok: true, value: JSON.parse(value) };
9
+ } catch (error) {
10
+ return { ok: false, error: { code: 'BAD_INPUT', path, message: `invalid JSON: ${error.message}` } };
11
+ }
12
+ }
13
+
14
+ export function parseJsonFields(args, fields) {
15
+ const values = {};
16
+ const errors = [];
17
+ for (const field of fields) {
18
+ const result = parseJsonString(args?.[field], field);
19
+ if (result.ok) values[field] = result.value;
20
+ else errors.push(result.error);
21
+ }
22
+ return errors.length ? { ok: false, errors } : { ok: true, values };
23
+ }
24
+
25
+ const FINDINGS_LIMIT = 500;
26
+ const ECHO_LIMIT = 100;
27
+ const RESULT_ARRAY_LIMIT = 1000;
28
+ const WALK_DEPTH_LIMIT = 64;
29
+ const COMPACT_THRESHOLD = 262144;
30
+ const ECHO_KEYS = new Set(['evidence', 'targets', 'expectedDiff', 'verification', 'items']);
31
+
32
+ export function clampEnvelope(envelope) {
33
+ if (Array.isArray(envelope)) return envelope.length > FINDINGS_LIMIT ? envelope.slice(0, FINDINGS_LIMIT) : envelope;
34
+ if (envelope === null || typeof envelope !== 'object') return envelope;
35
+ const out = { ...envelope };
36
+ let truncatedTotal = 0;
37
+ for (const field of ['errors', 'warnings']) {
38
+ if (Array.isArray(out[field]) && out[field].length > FINDINGS_LIMIT) {
39
+ truncatedTotal = Math.max(truncatedTotal, out[field].length);
40
+ out[field] = out[field].slice(0, FINDINGS_LIMIT);
41
+ }
42
+ }
43
+ if (truncatedTotal > 0) {
44
+ out.warnings = [...(out.warnings ?? []), { code: 'FINDINGS_TRUNCATED', total: truncatedTotal, message: `output truncated to first ${FINDINGS_LIMIT} entries per findings field` }];
45
+ }
46
+ if (out.data !== null && typeof out.data === 'object') {
47
+ out.data = clampNode(out.data, 0);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Iterative walk with a hard depth cap: subtrees deeper than WALK_DEPTH_LIMIT are
53
+ // passed through untouched (native JSON serialization handles arbitrary depth),
54
+ // so hostile nesting can no longer turn the renderer into an INTERNAL error.
55
+ function clampNode(root, rootDepth) {
56
+ const result = Array.isArray(root) ? [...root] : { ...root };
57
+ const stack = [[result, rootDepth]];
58
+ while (stack.length) {
59
+ const [node, depth] = stack.pop();
60
+ const entries = Object.keys(node);
61
+ for (const key of entries) {
62
+ const value = node[key];
63
+ if (!value || typeof value !== 'object') continue;
64
+ if (depth + 1 > WALK_DEPTH_LIMIT) {
65
+ node[key] = { truncated: true, reason: 'DEPTH_LIMIT', depth: WALK_DEPTH_LIMIT };
66
+ continue;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ if (ECHO_KEYS.has(key) && value.length > ECHO_LIMIT) {
70
+ node[key] = { truncated: true, total: value.length };
71
+ } else if (value.length > RESULT_ARRAY_LIMIT) {
72
+ node[key] = value.slice(0, RESULT_ARRAY_LIMIT);
73
+ } else {
74
+ node[key] = [...value];
75
+ stack.push([node[key], depth + 1]);
76
+ }
77
+ } else {
78
+ node[key] = { ...value };
79
+ stack.push([node[key], depth + 1]);
80
+ }
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+
86
+ export function canonicalStringify(value) {
87
+ return canonicalize(value, 0);
88
+ }
89
+
90
+ function canonicalize(value, depth) {
91
+ if (depth > WALK_DEPTH_LIMIT) return null; // deep subtrees compare as equal; drift beyond the cap is unreported by design
92
+ if (Array.isArray(value)) return value.map((item) => canonicalize(item, depth + 1));
93
+ if (value !== null && typeof value === 'object') {
94
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key], depth + 1)]));
95
+ }
96
+ return value;
97
+ }
98
+
99
+ export function runGuarded(operation, fn) {
100
+ try {
101
+ return fn();
102
+ } catch (error) {
103
+ return JSON.stringify(
104
+ errorEnvelope(operation, 'INTERNAL', [{ code: 'INTERNAL', message: `${error?.name ?? 'Error'}: unexpected internal failure` }]),
105
+ null,
106
+ 2,
107
+ );
108
+ }
109
+ }
110
+
111
+ export function csv(value) {
112
+ return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean);
113
+ }
114
+
115
+ export function renderEnvelope(value) {
116
+ const clamped = clampEnvelope(value);
117
+ const pretty = JSON.stringify(clamped, null, 2);
118
+ return pretty.length > COMPACT_THRESHOLD ? JSON.stringify(clamped) : pretty;
119
+ }