@patwoodman/dsh-sdlc-flow-core 0.0.0-stage → 0.4.0-beta.14
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.
- package/LICENSE +21 -0
- package/README.md +28 -2
- package/cordis.patch.yml +51 -0
- package/icon.svg +28 -0
- package/lib/a11y.js +126 -0
- package/lib/budget.js +167 -0
- package/lib/complexity/index.js +160 -0
- package/lib/config.js +291 -0
- package/lib/context.js +165 -0
- package/lib/debug.js +233 -0
- package/lib/escalation.js +122 -0
- package/lib/gates.js +230 -0
- package/lib/glob-lite.js +77 -0
- package/lib/index.js +37 -0
- package/lib/instance-lock.js +176 -0
- package/lib/integration.js +90 -0
- package/lib/loop.js +148 -0
- package/lib/metrics.js +106 -0
- package/lib/project.js +184 -0
- package/lib/recovery.js +119 -0
- package/lib/risk.js +169 -0
- package/lib/router.js +260 -0
- package/lib/runtime.js +414 -0
- package/lib/scheduler.js +229 -0
- package/lib/schema.js +280 -0
- package/lib/selftest.js +119 -0
- package/lib/services.js +126 -0
- package/lib/state-machine.js +368 -0
- package/lib/verifier.js +106 -0
- package/lib/visual.js +102 -0
- package/lib/worker-mgr.js +184 -0
- package/lib/worker-result.js +299 -0
- package/lib/yaml-lite.js +246 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SDLC Orchestrator contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @patwoodman/dsh-sdlc-flow-core — host services
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime authority of the orchestrator: task engine and state machine,
|
|
4
|
+
scheduler, router, verifier, integration manager, evidence log, escalation
|
|
5
|
+
ledger, worker manager and recovery. It owns the SQLite state in `.sdlc/`.
|
|
6
|
+
|
|
7
|
+
**Never install this into a worker profile.** Mounting it there would start a
|
|
8
|
+
second scheduler and a second writer against `sdlc.db` (errata E6 in the spec).
|
|
9
|
+
Worker profiles mount [`@patwoodman/dsh-sdlc-flow-guard`](https://www.npmjs.com/package/@patwoodman/dsh-sdlc-flow-guard)
|
|
10
|
+
and [`@patwoodman/dsh-sdlc-flow-tools`](https://www.npmjs.com/package/@patwoodman/dsh-sdlc-flow-tools)
|
|
11
|
+
only.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Installed as a bundle by the worker-free profiles (`sdlc-probe`) and as a module
|
|
16
|
+
dependency of [`@patwoodman/dsh-sdlc-flow`](https://www.npmjs.com/package/@patwoodman/dsh-sdlc-flow):
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
dsh plugin --profile <profile> add @patwoodman/dsh-sdlc-flow-core
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- `@deepseek-ai/dsh` `>=0.2.0-rc.2 <0.3.0-0`
|
|
25
|
+
- Node.js ≥ 22.5 (`node:sqlite`)
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# sdlc-core — host services (spec v3 C.1/C.2).
|
|
2
|
+
#
|
|
3
|
+
# HOST BUNDLE. This package declares the runtime authority: task engine, state
|
|
4
|
+
# transitions, scheduler, router, verifier, integration manager, evidence,
|
|
5
|
+
# escalation and worker dispatch. It must NEVER be installed into a worker
|
|
6
|
+
# profile — mounting it there would start a second scheduler and a second
|
|
7
|
+
# writer against sdlc.db (errata E6). install-local.mjs and doctor.mjs assert
|
|
8
|
+
# the partition.
|
|
9
|
+
#
|
|
10
|
+
# NOTE (discrepancy D2): spec C.1's inline comment shows sdlc-core declaring
|
|
11
|
+
# both cordis.patch.yml and presets.patch.yml. The 26 preset rows live in
|
|
12
|
+
# @patwoodman/dsh-sdlc-flow-presets (C.1 body text); declaring them here as well would
|
|
13
|
+
# produce duplicate loader row ids and fail M0-02-no-dup-ids. Core therefore
|
|
14
|
+
# declares host rows only.
|
|
15
|
+
- insert:
|
|
16
|
+
- id: sdlc-tasks
|
|
17
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
18
|
+
config:
|
|
19
|
+
service: tasks
|
|
20
|
+
- id: sdlc-scheduler
|
|
21
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
22
|
+
config:
|
|
23
|
+
service: scheduler
|
|
24
|
+
- id: sdlc-router
|
|
25
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
26
|
+
config:
|
|
27
|
+
service: router
|
|
28
|
+
- id: sdlc-verifier
|
|
29
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
30
|
+
config:
|
|
31
|
+
service: verifier
|
|
32
|
+
- id: sdlc-integration
|
|
33
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
34
|
+
config:
|
|
35
|
+
service: integration
|
|
36
|
+
- id: sdlc-evidence
|
|
37
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
38
|
+
config:
|
|
39
|
+
service: evidence
|
|
40
|
+
- id: sdlc-escalation
|
|
41
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
42
|
+
config:
|
|
43
|
+
service: escalation
|
|
44
|
+
- id: sdlc-worker-mgr
|
|
45
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
46
|
+
config:
|
|
47
|
+
service: worker-mgr
|
|
48
|
+
- id: sdlc-recovery
|
|
49
|
+
name: '@patwoodman/dsh-sdlc-flow-core/services'
|
|
50
|
+
config:
|
|
51
|
+
service: recovery
|
package/icon.svg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Universal SDLC Orchestrator">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sdlc-bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#1e3a8a"/>
|
|
5
|
+
<stop offset="1" stop-color="#0e7490"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="64" height="64" rx="14" fill="url(#sdlc-bg)"/>
|
|
9
|
+
<g fill="none" stroke="#e0f2fe" stroke-width="2.4" stroke-linecap="round">
|
|
10
|
+
<path d="M32 17v9"/>
|
|
11
|
+
<path d="M32 34v4"/>
|
|
12
|
+
<path d="M24 44l-8 6"/>
|
|
13
|
+
<path d="M40 44l8 6"/>
|
|
14
|
+
</g>
|
|
15
|
+
<g fill="#e0f2fe">
|
|
16
|
+
<circle cx="32" cy="13" r="4.6"/>
|
|
17
|
+
<circle cx="32" cy="29" r="4.6"/>
|
|
18
|
+
<circle cx="19" cy="47" r="4.6"/>
|
|
19
|
+
<circle cx="45" cy="47" r="4.6"/>
|
|
20
|
+
</g>
|
|
21
|
+
<g fill="#0e7490">
|
|
22
|
+
<circle cx="32" cy="13" r="1.7"/>
|
|
23
|
+
<circle cx="32" cy="29" r="1.7"/>
|
|
24
|
+
<circle cx="19" cy="47" r="1.7"/>
|
|
25
|
+
<circle cx="45" cy="47" r="1.7"/>
|
|
26
|
+
</g>
|
|
27
|
+
<path d="M27.5 39.5l4.5 4.5 4.5-4.5" fill="none" stroke="#67e8f9" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
28
|
+
</svg>
|
package/lib/a11y.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-core/src/a11y.js — the always-on accessibility floor (spec v3 B.5).
|
|
3
|
+
*
|
|
4
|
+
* B.5 names three checks that are ALWAYS on, owned by the Accessibility
|
|
5
|
+
* Engineer: basic contrast on primary text, images have alt text, and form
|
|
6
|
+
* controls have label associations — plus keyboard focus must be visible on
|
|
7
|
+
* every interactive element. These are pure predicates over a DOM description
|
|
8
|
+
* so they run in the gate suite without a browser; the Playwright layer feeds
|
|
9
|
+
* them real measurements.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** WCAG relative luminance for a #rgb / #rrggbb colour. */
|
|
13
|
+
export function relativeLuminance(hex) {
|
|
14
|
+
const value = String(hex).replace(/^#/, '');
|
|
15
|
+
const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value;
|
|
16
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) throw new Error(`invalid colour: ${hex}`);
|
|
17
|
+
const channel = (c) => {
|
|
18
|
+
const v = parseInt(c, 16) / 255;
|
|
19
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
20
|
+
};
|
|
21
|
+
const r = channel(full.slice(0, 2));
|
|
22
|
+
const g = channel(full.slice(2, 4));
|
|
23
|
+
const b = channel(full.slice(4, 6));
|
|
24
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** WCAG contrast ratio between two colours (1..21). */
|
|
28
|
+
export function contrastRatio(foreground, background) {
|
|
29
|
+
const l1 = relativeLuminance(foreground);
|
|
30
|
+
const l2 = relativeLuminance(background);
|
|
31
|
+
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
|
|
32
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* WCAG 2.1 AA thresholds: 4.5:1 for normal text, 3:1 for large text (≥18.66px
|
|
37
|
+
* bold or ≥24px) and for UI components.
|
|
38
|
+
*/
|
|
39
|
+
export function meetsContrast(foreground, background, { large = false, level = 'AA' } = {}) {
|
|
40
|
+
const ratio = contrastRatio(foreground, background);
|
|
41
|
+
const threshold = level === 'AAA' ? (large ? 4.5 : 7) : (large ? 3 : 4.5);
|
|
42
|
+
return { pass: ratio >= threshold, ratio: Number(ratio.toFixed(2)), threshold };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every meaningful image must carry a non-empty alt attribute. */
|
|
46
|
+
export function checkAltText(images = []) {
|
|
47
|
+
const violations = [];
|
|
48
|
+
for (const img of images) {
|
|
49
|
+
if (img.decorative) continue; // role="presentation"/aria-hidden is legitimate
|
|
50
|
+
if (!img.alt || String(img.alt).trim() === '') {
|
|
51
|
+
violations.push({ check: 'alt-text', element: img.selector ?? img.src ?? '<img>', reason: 'missing alt text' });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return violations;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every form control must have a label association (for/id, aria-label or aria-labelledby). */
|
|
58
|
+
export function checkLabels(controls = []) {
|
|
59
|
+
const violations = [];
|
|
60
|
+
for (const control of controls) {
|
|
61
|
+
const labelled = Boolean(control.label) || Boolean(control['aria-label']) || Boolean(control['aria-labelledby']);
|
|
62
|
+
if (!labelled) {
|
|
63
|
+
violations.push({ check: 'label-association', element: control.selector ?? control.name ?? '<control>', reason: 'no label association' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return violations;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Keyboard focus must be visible on every interactive element. */
|
|
70
|
+
export function checkFocusVisible(elements = []) {
|
|
71
|
+
const violations = [];
|
|
72
|
+
for (const element of elements) {
|
|
73
|
+
if (!element.interactive) continue;
|
|
74
|
+
if (element.focusVisible === false) {
|
|
75
|
+
violations.push({ check: 'focus-visible', element: element.selector ?? '<element>', reason: 'no visible focus indicator' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return violations;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run the floor. `measurements` come from either the Playwright layer or a
|
|
83
|
+
* fixture; absence of a measurement is a SKIP for that check, never a pass.
|
|
84
|
+
*/
|
|
85
|
+
export function runA11yFloor({ images = null, controls = null, interactive = null, textSamples = null, enabled = true } = {}) {
|
|
86
|
+
if (!enabled) return { pass: true, checks: [{ check: 'a11y-floor', status: 'skipped:disabled' }] };
|
|
87
|
+
const checks = [];
|
|
88
|
+
const violations = [];
|
|
89
|
+
|
|
90
|
+
const add = (name, data, fn) => {
|
|
91
|
+
if (data == null) { checks.push({ check: name, status: 'skipped:no-measurement' }); return; }
|
|
92
|
+
const found = fn(data);
|
|
93
|
+
violations.push(...found);
|
|
94
|
+
checks.push({ check: name, status: found.length ? 'fail' : 'pass', violations: found.length });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
add('alt-text', images, checkAltText);
|
|
98
|
+
add('label-association', controls, checkLabels);
|
|
99
|
+
add('focus-visible', interactive, checkFocusVisible);
|
|
100
|
+
|
|
101
|
+
if (textSamples == null) {
|
|
102
|
+
checks.push({ check: 'contrast', status: 'skipped:no-measurement' });
|
|
103
|
+
} else {
|
|
104
|
+
const contrastViolations = [];
|
|
105
|
+
for (const sample of textSamples) {
|
|
106
|
+
const result = meetsContrast(sample.color, sample.background, { large: sample.large });
|
|
107
|
+
if (!result.pass) {
|
|
108
|
+
contrastViolations.push({ check: 'contrast', element: sample.selector ?? '<text>', reason: `ratio ${result.ratio} < ${result.threshold}` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
violations.push(...contrastViolations);
|
|
112
|
+
checks.push({ check: 'contrast', status: contrastViolations.length ? 'fail' : 'pass', violations: contrastViolations.length });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
pass: violations.length === 0,
|
|
117
|
+
// A stable, documented order (A11Y_FLOOR_CHECKS) so evidence bundles are
|
|
118
|
+
// comparable across runs.
|
|
119
|
+
checks: checks.sort((a, b) => A11Y_FLOOR_CHECKS.indexOf(a.check) - A11Y_FLOOR_CHECKS.indexOf(b.check)),
|
|
120
|
+
violations,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const A11Y_FLOOR_CHECKS = ['contrast', 'alt-text', 'label-association', 'focus-visible'];
|
|
125
|
+
|
|
126
|
+
export default { contrastRatio, meetsContrast, checkAltText, checkLabels, checkFocusVisible, runA11yFloor, A11Y_FLOOR_CHECKS };
|
package/lib/budget.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-core/src/budget.js — per-attempt vs per-task budgets (spec v3 B.7).
|
|
3
|
+
*
|
|
4
|
+
* Semantics fixed in v3:
|
|
5
|
+
* - `max_attempts` is PER TASK.
|
|
6
|
+
* - `max_duration_ms`, `max_cost_usd`, `max_tokens` are PER ATTEMPT; the task
|
|
7
|
+
* also carries derived cumulative limits `attempts × per-attempt`, which the
|
|
8
|
+
* scheduler checks before dispatch.
|
|
9
|
+
* - A run that exceeds its per-attempt wall clock is killed by the timeout
|
|
10
|
+
* policy (soft at 80 %, hard at 100 %).
|
|
11
|
+
* - A task whose CUMULATIVE limits are exhausted moves to ESCALATED, not
|
|
12
|
+
* FAILED, so the decision is explicit.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const RISK_BUDGET_DEFAULTS = Object.freeze({
|
|
16
|
+
R1: { maxAttempts: 2, maxDurationMs: 1800000, maxCostUsd: 2.0, maxTokens: 200000 },
|
|
17
|
+
R2: { maxAttempts: 3, maxDurationMs: 3600000, maxCostUsd: 5.0, maxTokens: 400000 },
|
|
18
|
+
R3: { maxAttempts: 3, maxDurationMs: 7200000, maxCostUsd: 15.0, maxTokens: 1000000 },
|
|
19
|
+
R4: { maxAttempts: 4, maxDurationMs: 14400000, maxCostUsd: 50.0, maxTokens: 3000000 },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const PROJECT_BUDGET_DEFAULTS = Object.freeze({
|
|
23
|
+
maxCostUsd: 200.0,
|
|
24
|
+
maxDurationMs: 86400000,
|
|
25
|
+
maxParallelWorkers: 'auto',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const SOFT_BUDGET_RATIO = 0.8;
|
|
29
|
+
|
|
30
|
+
export function budgetsForRisk(risk, perTask = RISK_BUDGET_DEFAULTS) {
|
|
31
|
+
return { ...(perTask[risk] ?? perTask.R2) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The canonical task-contract budget keys, per spec B.3.1 / H.1 (snake_case). */
|
|
35
|
+
export const BUDGET_ALIASES = {
|
|
36
|
+
max_attempts: 'maxAttempts',
|
|
37
|
+
maxAttempts: 'maxAttempts',
|
|
38
|
+
max_duration_ms: 'maxDurationMs',
|
|
39
|
+
maxDurationMs: 'maxDurationMs',
|
|
40
|
+
max_cost_usd: 'maxCostUsd',
|
|
41
|
+
maxCostUsd: 'maxCostUsd',
|
|
42
|
+
max_tokens: 'maxTokens',
|
|
43
|
+
maxTokens: 'maxTokens',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a task-contract `budgets` block. B.3.1 writes snake_case
|
|
48
|
+
* (`max_attempts`, `max_duration_ms`, …) while the internal model is camelCase;
|
|
49
|
+
* BOTH are accepted so a spec-shaped contract round-trips without translation by
|
|
50
|
+
* the caller. Unknown keys are reported rather than silently dropped.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{budget:object, unknownKeys:string[]}}
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeTaskBudget(budgets, risk = 'R2', perTask = RISK_BUDGET_DEFAULTS) {
|
|
55
|
+
const base = budgetsForRisk(risk, perTask);
|
|
56
|
+
if (budgets == null) return { budget: base, unknownKeys: [] };
|
|
57
|
+
const unknownKeys = [];
|
|
58
|
+
const out = { ...base };
|
|
59
|
+
for (const [key, value] of Object.entries(budgets)) {
|
|
60
|
+
const canonical = BUDGET_ALIASES[key];
|
|
61
|
+
if (!canonical) { unknownKeys.push(key); continue; }
|
|
62
|
+
out[canonical] = typeof value === 'number' ? value : Number(value);
|
|
63
|
+
}
|
|
64
|
+
return { budget: out, unknownKeys };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Derive the cumulative (whole-task) ceilings from a per-attempt budget. */
|
|
68
|
+
export function cumulativeBudgets(perAttempt) {
|
|
69
|
+
return {
|
|
70
|
+
maxAttempts: perAttempt.maxAttempts,
|
|
71
|
+
maxDurationMs: perAttempt.maxDurationMs * perAttempt.maxAttempts,
|
|
72
|
+
maxCostUsd: round6(perAttempt.maxCostUsd * perAttempt.maxAttempts),
|
|
73
|
+
maxTokens: perAttempt.maxTokens * perAttempt.maxAttempts,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function round6(n) {
|
|
78
|
+
return Math.round(n * 1e6) / 1e6;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scheduler gate before dispatch.
|
|
83
|
+
* @returns {{ok:boolean, reason?:string}} `BUDGET_EXHAUSTED` blocks dispatch.
|
|
84
|
+
*/
|
|
85
|
+
export function checkDispatch({ attempt = 0, spentCostUsd = 0, spentTokens = 0, spentDurationMs = 0 } = {}, budget) {
|
|
86
|
+
const cumulative = cumulativeBudgets(budget);
|
|
87
|
+
if (attempt >= budget.maxAttempts) return { ok: false, reason: 'BUDGET_EXHAUSTED:max_attempts' };
|
|
88
|
+
if (spentCostUsd >= cumulative.maxCostUsd) return { ok: false, reason: 'BUDGET_EXHAUSTED:cost' };
|
|
89
|
+
if (spentTokens >= cumulative.maxTokens) return { ok: false, reason: 'BUDGET_EXHAUSTED:tokens' };
|
|
90
|
+
if (spentDurationMs >= cumulative.maxDurationMs) return { ok: false, reason: 'BUDGET_EXHAUSTED:duration' };
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Project-level run budget. */
|
|
95
|
+
export function checkProjectBudget({ spentCostUsd = 0, spentDurationMs = 0 } = {}, projectBudget = PROJECT_BUDGET_DEFAULTS) {
|
|
96
|
+
if (projectBudget.maxCostUsd != null && spentCostUsd >= projectBudget.maxCostUsd) {
|
|
97
|
+
return { ok: false, reason: 'BUDGET_EXHAUSTED:project-cost' };
|
|
98
|
+
}
|
|
99
|
+
if (projectBudget.maxDurationMs != null && spentDurationMs >= projectBudget.maxDurationMs) {
|
|
100
|
+
return { ok: false, reason: 'BUDGET_EXHAUSTED:project-duration' };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Timeout policy thresholds for a single run (B.3.2). */
|
|
106
|
+
export function timeoutThresholds(maxDurationMs, now = () => Date.now()) {
|
|
107
|
+
const start = now();
|
|
108
|
+
return {
|
|
109
|
+
startedAt: start,
|
|
110
|
+
softAt: start + maxDurationMs * SOFT_BUDGET_RATIO,
|
|
111
|
+
hardAt: start + maxDurationMs,
|
|
112
|
+
phase(t = now()) {
|
|
113
|
+
if (t >= start + maxDurationMs) return 'hard';
|
|
114
|
+
if (t >= start + maxDurationMs * SOFT_BUDGET_RATIO) return 'soft';
|
|
115
|
+
return 'ok';
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Post-run accounting. Returns the next action the scheduler must take.
|
|
122
|
+
* `exhausted` ⇒ ESCALATED (explicit decision), `overrun` ⇒ kill/retry path.
|
|
123
|
+
*
|
|
124
|
+
* `taskSpend` is THIS task's spend from earlier attempts — it feeds the task
|
|
125
|
+
* cumulative cost ceiling. `projectSpend` is the whole project's spend and is
|
|
126
|
+
* used ONLY for the project ceiling; conflating the two let an unrelated task's
|
|
127
|
+
* spend escalate a task that had barely started.
|
|
128
|
+
*/
|
|
129
|
+
export function evaluateRun({
|
|
130
|
+
attempt = 0,
|
|
131
|
+
run = { costUsd: 0, tokens: 0, durationMs: 0 },
|
|
132
|
+
taskBudget,
|
|
133
|
+
taskSpend = { costUsd: 0 },
|
|
134
|
+
projectBudget = PROJECT_BUDGET_DEFAULTS,
|
|
135
|
+
projectSpend = { costUsd: 0, durationMs: 0 },
|
|
136
|
+
} = {}) {
|
|
137
|
+
const perAttemptOver =
|
|
138
|
+
(run.costUsd ?? 0) > taskBudget.maxCostUsd ||
|
|
139
|
+
(run.tokens ?? 0) > taskBudget.maxTokens ||
|
|
140
|
+
(run.durationMs ?? 0) > taskBudget.maxDurationMs;
|
|
141
|
+
|
|
142
|
+
const cumulative = cumulativeBudgets(taskBudget);
|
|
143
|
+
const nextAttempt = attempt + 1;
|
|
144
|
+
const cumulativeExhausted =
|
|
145
|
+
nextAttempt >= taskBudget.maxAttempts ||
|
|
146
|
+
(taskSpend.costUsd ?? 0) + (run.costUsd ?? 0) >= cumulative.maxCostUsd;
|
|
147
|
+
|
|
148
|
+
const project = checkProjectBudget(
|
|
149
|
+
{ spentCostUsd: (projectSpend.costUsd ?? 0) + (run.costUsd ?? 0), spentDurationMs: (projectSpend.durationMs ?? 0) + (run.durationMs ?? 0) },
|
|
150
|
+
projectBudget,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (!project.ok) return { action: 'block', reason: project.reason, perAttemptOver, cumulativeExhausted };
|
|
154
|
+
if (cumulativeExhausted) return { action: 'escalate', reason: 'BUDGET_EXHAUSTED:cumulative', perAttemptOver, cumulativeExhausted };
|
|
155
|
+
if (perAttemptOver) return { action: 'retry', reason: 'PER_ATTEMPT_BUDGET_EXCEEDED', perAttemptOver, cumulativeExhausted };
|
|
156
|
+
return { action: 'continue', reason: null, perAttemptOver, cumulativeExhausted };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Provider 429 backpressure: exponential cooldown, capped (B.7). */
|
|
160
|
+
export function backoffDelay(attempt, { baseMs = 1000, capMs = 60000 } = {}) {
|
|
161
|
+
return Math.min(capMs, baseMs * 2 ** Math.max(0, attempt));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default {
|
|
165
|
+
RISK_BUDGET_DEFAULTS, PROJECT_BUDGET_DEFAULTS, budgetsForRisk, cumulativeBudgets,
|
|
166
|
+
checkDispatch, checkProjectBudget, evaluateRun, timeoutThresholds, backoffDelay,
|
|
167
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-core/src/complexity/index.js — complexity engine v2 (spec v3 B.1).
|
|
3
|
+
*
|
|
4
|
+
* Pure arithmetic: every feature has a deterministic extraction rule and is
|
|
5
|
+
* normalized to [0,1] before weighting; the weights sum to exactly 1.0, so
|
|
6
|
+
* `score ∈ [0,1]` by construction. LLM involvement is limited to producing the
|
|
7
|
+
* structured estimate (the integers/booleans below) — never the score.
|
|
8
|
+
*
|
|
9
|
+
* Determinism contract: same repo state + same requirement ⇒ same score.
|
|
10
|
+
*
|
|
11
|
+
* NOTE ON SPEC G.2.1: the illustrative unit test in Part G asserts
|
|
12
|
+
* `normalizeFeature('files', 1) < 0.1`. The normative formula in B.1 is
|
|
13
|
+
* `min(ln(1+F)/ln(201), 1)`, which yields ≈0.131 for F=1. Part B is normative,
|
|
14
|
+
* so the formula is implemented as written and the test asserts the real value
|
|
15
|
+
* (see SDLC_IMPLEMENTATION_NOTES.md, discrepancy D1).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Feature weights — must sum to exactly 1.0 (asserted in unit tests). */
|
|
19
|
+
export const WEIGHTS = Object.freeze({
|
|
20
|
+
d: 0.15,
|
|
21
|
+
f: 0.10,
|
|
22
|
+
i: 0.15,
|
|
23
|
+
db: 0.10,
|
|
24
|
+
sec: 0.10,
|
|
25
|
+
ui: 0.10,
|
|
26
|
+
e: 0.10,
|
|
27
|
+
dep: 0.10,
|
|
28
|
+
t: 0.10,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const FILE_SCALE_LN = Math.log(201);
|
|
32
|
+
|
|
33
|
+
export const BANDS = Object.freeze([
|
|
34
|
+
{ name: 'Tiny', lo: 0.0, hi: 0.25 },
|
|
35
|
+
{ name: 'Small', lo: 0.25, hi: 0.45 },
|
|
36
|
+
{ name: 'Medium', lo: 0.45, hi: 0.65 },
|
|
37
|
+
{ name: 'Large', lo: 0.65, hi: 0.8 },
|
|
38
|
+
{ name: 'Enterprise', lo: 0.8, hi: 1.0 },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function clamp01(n) {
|
|
42
|
+
if (Number.isNaN(n) || n == null) return 0;
|
|
43
|
+
return Math.min(1, Math.max(0, n));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function intIn(value, lo, hi, fallback = 0) {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
if (!Number.isFinite(n)) return fallback;
|
|
49
|
+
return Math.min(hi, Math.max(lo, Math.trunc(n)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bool01(value) {
|
|
53
|
+
return value ? 1 : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a single feature. Exported because Part K.3 property-tests each
|
|
58
|
+
* normalization for monotonicity and range.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeFeature(name, value) {
|
|
61
|
+
switch (name) {
|
|
62
|
+
case 'domains': return clamp01(intIn(value, 0, 1000, 0) / 5);
|
|
63
|
+
case 'files': {
|
|
64
|
+
const f = intIn(value, 0, 1000, 0);
|
|
65
|
+
return clamp01(Math.log(1 + f) / FILE_SCALE_LN);
|
|
66
|
+
}
|
|
67
|
+
case 'integrations': return clamp01(intIn(value, 0, 1000, 0) / 5);
|
|
68
|
+
case 'external_deps': return clamp01(intIn(value, 0, 1000, 0) / 10);
|
|
69
|
+
case 'testing': {
|
|
70
|
+
const t = Number(value);
|
|
71
|
+
if (t >= 1) return 1;
|
|
72
|
+
if (t >= 0.5) return 0.5;
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
case 'db': case 'sec': case 'ui': case 'dep': return bool01(value);
|
|
76
|
+
default: throw new Error(`unknown feature: ${name}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Band lookup; half-open `[lo, hi)` except the last band, which is closed. */
|
|
81
|
+
export function bandFor(score, bands = BANDS) {
|
|
82
|
+
const s = clamp01(score);
|
|
83
|
+
for (let i = 0; i < bands.length; i += 1) {
|
|
84
|
+
const b = bands[i];
|
|
85
|
+
const isLast = i === bands.length - 1;
|
|
86
|
+
if (s >= b.lo && (isLast ? s <= b.hi : s < b.hi)) return b.name;
|
|
87
|
+
}
|
|
88
|
+
return bands[bands.length - 1].name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Security classifier for the `sec` feature (B.1): a security-sensitive
|
|
93
|
+
* `allowed_paths` glob OR a requirement mentioning auth/authz/payments/PII.
|
|
94
|
+
*/
|
|
95
|
+
export const SECURITY_PATH_PATTERNS = [
|
|
96
|
+
'**/auth*', '**/*auth*', '**/*token*', '**/*secret*', '**/*credential*',
|
|
97
|
+
'**/crypto*', '**/*permission*', '**/*session*', '**/*cors*', '**/*csrf*',
|
|
98
|
+
];
|
|
99
|
+
export const SECURITY_REQUIREMENT_TERMS = [
|
|
100
|
+
'auth', 'authz', 'authorization', 'authentication', 'payment', 'pii',
|
|
101
|
+
'password', 'credential', 'secret', 'token', 'permission', 'encrypt',
|
|
102
|
+
'oauth', 'sso', 'login',
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compute the complexity score.
|
|
107
|
+
* @param {object} input structured estimate (validated integers/booleans)
|
|
108
|
+
* @returns {{score:number, band:string, normalized:object, weights:object}}
|
|
109
|
+
*/
|
|
110
|
+
export function computeComplexity(input = {}) {
|
|
111
|
+
const normalized = {
|
|
112
|
+
d: normalizeFeature('domains', input.domains),
|
|
113
|
+
f: normalizeFeature('files', input.files),
|
|
114
|
+
i: normalizeFeature('integrations', input.integrations),
|
|
115
|
+
db: normalizeFeature('db', input.db),
|
|
116
|
+
sec: normalizeFeature('sec', input.sec),
|
|
117
|
+
ui: normalizeFeature('ui', input.ui),
|
|
118
|
+
e: normalizeFeature('external_deps', input.external_deps),
|
|
119
|
+
dep: normalizeFeature('dep', input.dep),
|
|
120
|
+
t: normalizeFeature('testing', input.testing),
|
|
121
|
+
};
|
|
122
|
+
const score = clamp01(
|
|
123
|
+
WEIGHTS.d * normalized.d +
|
|
124
|
+
WEIGHTS.f * normalized.f +
|
|
125
|
+
WEIGHTS.i * normalized.i +
|
|
126
|
+
WEIGHTS.db * normalized.db +
|
|
127
|
+
WEIGHTS.sec * normalized.sec +
|
|
128
|
+
WEIGHTS.ui * normalized.ui +
|
|
129
|
+
WEIGHTS.e * normalized.e +
|
|
130
|
+
WEIGHTS.dep * normalized.dep +
|
|
131
|
+
WEIGHTS.t * normalized.t,
|
|
132
|
+
);
|
|
133
|
+
return { score, band: bandFor(score), normalized, weights: WEIGHTS };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Detect the project's impure `sec` flag from repo/requirement context (B.1). */
|
|
137
|
+
export function deriveSec({ allowedPaths = [], requirement = '' } = {}) {
|
|
138
|
+
for (const p of allowedPaths) {
|
|
139
|
+
if (/(^|[\/_.-])(auth|token|secret|credential|crypto|permission|session|cors|csrf)/i.test(String(p))) return true;
|
|
140
|
+
}
|
|
141
|
+
const text = String(requirement).toLowerCase();
|
|
142
|
+
return SECURITY_REQUIREMENT_TERMS.some((t) => text.includes(t));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* task_class (B.8): trivial = single-file/mechanical, architectural =
|
|
147
|
+
* contracts/schemas/cross-cutting, normal = everything else.
|
|
148
|
+
*/
|
|
149
|
+
export function classifyTask({ estimate = {}, domains = [], objective = '' } = {}) {
|
|
150
|
+
const files = Number(estimate.files ?? 0);
|
|
151
|
+
const text = String(objective).toLowerCase();
|
|
152
|
+
const architectural = domains.some((d) =>
|
|
153
|
+
['architecture', 'database', 'data-pipeline', 'integration', 'security'].includes(d),
|
|
154
|
+
) || /schema|migration|contract|interface|architecture|refactor|cross-cutting/.test(text);
|
|
155
|
+
if (architectural) return 'architectural';
|
|
156
|
+
if (files <= 1 && domains.length <= 1) return 'trivial';
|
|
157
|
+
return 'normal';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default { computeComplexity, normalizeFeature, bandFor, WEIGHTS, BANDS, classifyTask };
|