@ngockhoale/ukit 2.6.11 → 2.7.1

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 (40) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/manifests/documentation.yaml +24 -2
  3. package/manifests/instructionRules.yaml +62 -0
  4. package/package.json +1 -1
  5. package/scripts/perf/audit-perf.mjs +920 -0
  6. package/src/cli/commands/doctor.js +23 -4
  7. package/src/core/diffPlan.js +8 -0
  8. package/src/core/ompConfigMerge.js +251 -0
  9. package/src/core/runInstallPipeline.js +11 -0
  10. package/src/core/runtimeConfig.js +16 -3
  11. package/src/core/unattendedDoctor.js +227 -0
  12. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  13. package/templates/.claude/commands/ukit/handoff-fullstack.md +2 -2
  14. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  15. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  16. package/templates/.claude/hooks/block-dangerous.sh +76 -9
  17. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
  18. package/templates/.claude/hooks/project-important.sh +70 -9
  19. package/templates/.claude/hooks/protect-files.sh +24 -7
  20. package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
  21. package/templates/.claude/settings.json +22 -117
  22. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
  23. package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
  24. package/templates/.omp/README.md +11 -7
  25. package/templates/.omp/RULES.md +7 -6
  26. package/templates/.omp/agents/bug-debugger.md +1 -1
  27. package/templates/.omp/agents/code-reviewer.md +1 -1
  28. package/templates/.omp/agents/feature-implementer.md +1 -1
  29. package/templates/.omp/agents/handoff-planner.md +1 -1
  30. package/templates/.omp/agents/ukit-small-task-maintainer.md +2 -2
  31. package/templates/.omp/config.yml +42 -8
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
  33. package/templates/AGENTS.md +23 -11
  34. package/templates/CLAUDE.md +23 -11
  35. package/templates/adapter-presets/opencode/opencode.template.json +1 -1
  36. package/templates/docs/UKIT_INTERNALS.md +1 -1
  37. package/templates/instructions/core.md +23 -11
  38. package/templates/instructions/layout.yaml +12 -12
  39. package/templates/instructions/overlays/omp-rules.md +7 -6
  40. package/templates/ukit/storage/config.json +9 -3
@@ -23,6 +23,7 @@ import {
23
23
  inspectProjectImportantWiring,
24
24
  } from '../../core/projectImportant.js';
25
25
  import { runDocContractChecks } from '../../core/docContracts.js';
26
+ import { inspectUnattendedMode } from '../../core/unattendedDoctor.js';
26
27
 
27
28
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
28
29
  const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs']);
@@ -40,7 +41,7 @@ export function printDoctorHelp() {
40
41
  console.log(' --docs Run doc-contract checks (manifests/documentation.yaml projects only)');
41
42
  }
42
43
 
43
- export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir() }) {
44
+ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir(), ompPath = 'omp' }) {
44
45
  const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
45
46
  if (unknownFlags.length > 0) {
46
47
  throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: ${SUPPORTED_FLAGS_LIST}`);
@@ -254,6 +255,20 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
254
255
  console.log(`[UKit] ${ok(check.passed)} ${check.label}`);
255
256
  }
256
257
 
258
+ // TASK-011 / SPEC §8 (FR-007): unattended-mode checks — same remediation-class
259
+ // shape as projectChecks; install-repairable/owner-action failures block the
260
+ // exit code, advisory never does.
261
+ const unattended = await inspectUnattendedMode({ projectRoot, ompPath });
262
+ console.log('[UKit] Unattended-mode checks:');
263
+ for (const check of unattended.checks) {
264
+ if (check.applicable === false) continue;
265
+ console.log(`[UKit] ${ok(check.passed)} ${check.label}`);
266
+ if (check.detail) console.log(`[UKit] detail: ${check.detail}`);
267
+ if (!check.passed && check.remedy) {
268
+ console.log(`[UKit] remedy: ${check.remedy}`);
269
+ }
270
+ }
271
+
257
272
  if (runtimeConfigInspection.errors.length > 0) {
258
273
  console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
259
274
  }
@@ -402,13 +417,17 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
402
417
  const failedProjectChecks = projectChecks.filter(
403
418
  (check) => check.applicable !== false && !check.passed,
404
419
  );
405
- const blockingFailures = failedProjectChecks.filter(
420
+ const failedUnattendedChecks = unattended.checks.filter(
421
+ (check) => check.applicable !== false && !check.passed,
422
+ );
423
+ const allFailedChecks = [...failedProjectChecks, ...failedUnattendedChecks];
424
+ const blockingFailures = allFailedChecks.filter(
406
425
  (check) => check.remediationClass === 'install-repairable' || check.remediationClass === 'owner-action',
407
426
  );
408
427
 
409
- if (failedProjectChecks.length > 0) {
428
+ if (allFailedChecks.length > 0) {
410
429
  console.log('[UKit] Remedies:');
411
- for (const check of failedProjectChecks) {
430
+ for (const check of allFailedChecks) {
412
431
  console.log(`[UKit] - [${check.remediationClass}] ${check.remedy}`);
413
432
  }
414
433
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { isSymlinkTo } from './fileOps.js';
3
3
  import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
4
+ import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
4
5
 
5
6
  const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
6
7
 
@@ -116,6 +117,13 @@ function resolveFileAction(entry, existingContent) {
116
117
  : entry.mergeStrategy === 'skip'
117
118
  ? 'skip'
118
119
  : 'update';
120
+ } else if (isOmpConfigTarget(entry.targetPath) && typeof existingContent === 'string' && typeof entry.renderedContent === 'string') {
121
+ // SPEC §7 step 9: `.omp/config.yml` gets a semantic, comment-safe migration
122
+ // merge — the action compares the MERGED text against the existing file, so
123
+ // preserved user regions do not count as drift.
124
+ const merged = mergeOmpConfig(existingContent, entry.renderedContent);
125
+ action = merged.text === existingContent ? 'unchanged' : 'update';
126
+ return { ...entry, renderedContent: merged.text, migrationReport: merged.report, exists, action, existingContent };
119
127
  } else if (entry.mergeStrategy === 'merge_env_overwrite_with_backup' && typeof existingContent === 'string') {
120
128
  // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
121
129
  // and may carry user overrides). All other top-level keys — and the rest of the env
@@ -0,0 +1,251 @@
1
+ import YAML from 'yaml';
2
+
3
+ // TASK-010 / SPEC §5.2 + §7 (FR-006): semantic, comment-safe migration merge for
4
+ // `.omp/config.yml`. Operates on the rendered template TEXT with surgical splices
5
+ // so the template's load-bearing comments survive — a full YAML round-trip
6
+ // (parse + stringify) would drop every comment.
7
+
8
+ export const PRESERVED_MARKER = '# Preserved by UKit unattended migration (C35):';
9
+
10
+ const OMP_CONFIG_SUFFIX = '.omp/config.yml';
11
+
12
+ export function isOmpConfigTarget(targetPath) {
13
+ return typeof targetPath === 'string' && targetPath.replace(/\\/g, '/').endsWith(OMP_CONFIG_SUFFIX);
14
+ }
15
+
16
+ function emptyReport() {
17
+ return {
18
+ migrated: false,
19
+ legacyApprovalMode: null,
20
+ strippedPromptApprovals: [],
21
+ coercedPromptPatterns: [],
22
+ preservedModelRoles: false,
23
+ addedModelRoleKeys: [],
24
+ preservedTopLevelKeys: [],
25
+ preservedPatterns: [],
26
+ parseError: null,
27
+ };
28
+ }
29
+
30
+ // Extract a top-level scalar map's value span: the `key:` line plus following
31
+ // lines that are indented or blank. Stops before the next non-indented,
32
+ // non-blank line — comments preceding the next top-level key stay with it.
33
+ function extractScalarMapSpan(lines, key) {
34
+ const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*$`).test(line) || new RegExp(`^${key}:\\s`).test(line));
35
+ if (start === -1) return null;
36
+ let end = start + 1;
37
+ while (end < lines.length) {
38
+ const line = lines[end];
39
+ if (line.trim() === '' || /^\s/.test(line)) {
40
+ end += 1;
41
+ } else {
42
+ break;
43
+ }
44
+ }
45
+ // Trim trailing blank lines out of the span — they belong to the gap before
46
+ // the next key, not to this block.
47
+ let blockEnd = end;
48
+ while (blockEnd > start + 1 && lines[blockEnd - 1].trim() === '') {
49
+ blockEnd -= 1;
50
+ }
51
+ return { start, end: blockEnd, lines: lines.slice(start, blockEnd) };
52
+ }
53
+
54
+ function isPlainObject(value) {
55
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
56
+ }
57
+
58
+ // Indent every line of a YAML fragment by `indent` spaces.
59
+ function indentFragment(fragment, indent) {
60
+ const pad = ' '.repeat(indent);
61
+ return fragment
62
+ .split('\n')
63
+ .filter((line) => line !== '')
64
+ .map((line) => pad + line);
65
+ }
66
+
67
+ export function mergeOmpConfig(existingText, renderedText) {
68
+ const report = emptyReport();
69
+
70
+ let existing;
71
+ try {
72
+ existing = YAML.parse(existingText);
73
+ } catch (error) {
74
+ report.parseError = error?.message ?? String(error);
75
+ return { text: renderedText, report };
76
+ }
77
+ if (!isPlainObject(existing)) {
78
+ report.parseError = 'existing .omp/config.yml did not parse to a mapping';
79
+ return { text: renderedText, report };
80
+ }
81
+
82
+ let rendered;
83
+ try {
84
+ rendered = YAML.parse(renderedText);
85
+ } catch (error) {
86
+ // A broken template is not a migration problem — surface it as parseError so
87
+ // the caller falls back to plain overwrite rather than crashing install.
88
+ report.parseError = `rendered template failed to parse: ${error?.message ?? error}`;
89
+ return { text: renderedText, report };
90
+ }
91
+
92
+ const existingTools = isPlainObject(existing.tools) ? existing.tools : {};
93
+ const existingApproval = isPlainObject(existingTools.approval) ? existingTools.approval : {};
94
+ const renderedApproval = isPlainObject(rendered?.tools?.approval) ? rendered.tools.approval : {};
95
+
96
+ report.legacyApprovalMode = typeof existingTools.approvalMode === 'string'
97
+ ? existingTools.approvalMode
98
+ : null;
99
+
100
+ // §7.2 — user-defined tools.approval.<k> keys absent from the canonical map:
101
+ // preserved only when allow|deny; every `prompt` value (canonical or custom)
102
+ // is recorded in strippedPromptApprovals.
103
+ const extraApprovalEntries = [];
104
+ for (const [key, value] of Object.entries(existingApproval)) {
105
+ if (value === 'prompt') {
106
+ report.strippedPromptApprovals.push(key);
107
+ continue;
108
+ }
109
+ if (!(key in renderedApproval) && (value === 'allow' || value === 'deny')) {
110
+ extraApprovalEntries.push([key, value]);
111
+ }
112
+ }
113
+
114
+ // §7.4 — bash.patterns union: existing-only entries (dedupe by match) appended
115
+ // before the trailing `- match: "*"` allow; `prompt` re-emitted as `deny`.
116
+ const existingPatterns = Array.isArray(existing?.bash?.patterns) ? existing.bash.patterns : [];
117
+ const renderedPatterns = Array.isArray(rendered?.bash?.patterns) ? rendered.bash.patterns : [];
118
+ const renderedMatches = new Set(renderedPatterns.map((p) => p?.match));
119
+ const extraPatterns = [];
120
+ for (const pattern of existingPatterns) {
121
+ if (!isPlainObject(pattern) || typeof pattern.match !== 'string') continue;
122
+ if (renderedMatches.has(pattern.match)) continue;
123
+ let approval = pattern.approval;
124
+ if (approval === 'prompt') {
125
+ approval = 'deny';
126
+ report.coercedPromptPatterns.push(pattern.match);
127
+ }
128
+ extraPatterns.push({ ...pattern, approval });
129
+ report.preservedPatterns.push(pattern.match);
130
+ }
131
+
132
+ // §7.6 — extra top-level keys absent from the rendered template.
133
+ const extraTopLevel = {};
134
+ if (isPlainObject(rendered)) {
135
+ for (const [key, value] of Object.entries(existing)) {
136
+ if (!(key in rendered)) {
137
+ extraTopLevel[key] = value;
138
+ report.preservedTopLevelKeys.push(key);
139
+ }
140
+ }
141
+ }
142
+
143
+ const lines = renderedText.split('\n');
144
+
145
+ // §7.3 / FR-004 — modelRoles per-key union: existing entries win verbatim,
146
+ // rendered keys absent from existing are appended (rendered order) so new
147
+ // template roles (e.g. smol/default/slow) reach pre-existing installs while
148
+ // user-owned roles and overrides survive. The block is regenerated via
149
+ // YAML.stringify under `modelRoles:`; rendered header comments above the
150
+ // `modelRoles:` line are untouched. Known limitation: comments *inside* the
151
+ // existing modelRoles block are dropped — values are preserved, comment
152
+ // text is not (SPEC §10).
153
+ const renderedRoles = extractScalarMapSpan(lines, 'modelRoles');
154
+ if (renderedRoles && isPlainObject(existing.modelRoles)) {
155
+ const merged = { ...existing.modelRoles };
156
+ const renderedMap = isPlainObject(rendered?.modelRoles) ? rendered.modelRoles : {};
157
+ for (const [key, value] of Object.entries(renderedMap)) {
158
+ if (!(key in merged)) {
159
+ merged[key] = value;
160
+ report.addedModelRoleKeys.push(key);
161
+ }
162
+ }
163
+ // Skip regeneration when existing contributed nothing the rendered block
164
+ // doesn't already state — identical keys and values means the rendered
165
+ // block (interior comments included) already IS the merged result.
166
+ // Regenerating here would byte-shift installs whose file matches the
167
+ // template and break install idempotency.
168
+ const renderedKeys = Object.keys(renderedMap);
169
+ const mergedKeys = Object.keys(merged);
170
+ const identicalToRendered =
171
+ mergedKeys.length === renderedKeys.length &&
172
+ mergedKeys.every((k) => k in renderedMap && merged[k] === renderedMap[k]);
173
+ if (!identicalToRendered) {
174
+ const fragment = YAML.stringify(merged, { indent: 2 })
175
+ .trimEnd()
176
+ .split('\n')
177
+ .map((line) => ` ${line}`);
178
+ lines.splice(renderedRoles.start, renderedRoles.end - renderedRoles.start, 'modelRoles:', ...fragment);
179
+ report.preservedModelRoles = true;
180
+ }
181
+ }
182
+
183
+ // §7.2 splice — append preserved allow/deny keys to the rendered
184
+ // tools.approval map (before the first line that leaves the map's indent).
185
+ if (extraApprovalEntries.length > 0) {
186
+ const approvalStart = lines.findIndex((line) => /^\s+approval:\s*$/.test(line));
187
+ if (approvalStart !== -1) {
188
+ const baseIndent = lines[approvalStart].match(/^\s*/)[0].length;
189
+ let insertAt = approvalStart + 1;
190
+ while (insertAt < lines.length) {
191
+ const line = lines[insertAt];
192
+ if (line.trim() === '') {
193
+ insertAt += 1;
194
+ continue;
195
+ }
196
+ const indent = line.match(/^\s*/)[0].length;
197
+ if (indent <= baseIndent) break;
198
+ insertAt += 1;
199
+ }
200
+ const fragment = extraApprovalEntries.map(([k, v]) => `${' '.repeat(baseIndent + 2)}${k}: ${v}`);
201
+ lines.splice(insertAt, 0, ...fragment);
202
+ }
203
+ }
204
+
205
+ // §7.4 splice — insert preserved patterns before the trailing `- match: "*"`
206
+ // allow (or at the end of the patterns list if no wildcard exists).
207
+ if (extraPatterns.length > 0) {
208
+ // YAML.stringify emits sequence items at column 0; preserve each line's
209
+ // relative indentation and shift the whole fragment to the list's indent.
210
+ const rawFragment = YAML.stringify(extraPatterns).trimEnd().split('\n');
211
+ const wildcardIdx = lines.findIndex((line) => /- match: ["']?\*["']?\s*$/.test(line));
212
+ if (wildcardIdx !== -1) {
213
+ const itemIndent = lines[wildcardIdx].match(/^\s*/)[0].length;
214
+ lines.splice(wildcardIdx, 0, ...rawFragment.map((line) => ' '.repeat(itemIndent) + line));
215
+ } else {
216
+ const patternsStart = lines.findIndex((line) => /^\s+patterns:\s*$/.test(line));
217
+ if (patternsStart !== -1) {
218
+ const baseIndent = lines[patternsStart].match(/^\s*/)[0].length;
219
+ let insertAt = patternsStart + 1;
220
+ while (insertAt < lines.length) {
221
+ const line = lines[insertAt];
222
+ if (line.trim() === '') {
223
+ insertAt += 1;
224
+ continue;
225
+ }
226
+ const indent = line.match(/^\s*/)[0].length;
227
+ if (indent <= baseIndent) break;
228
+ insertAt += 1;
229
+ }
230
+ const itemIndent = baseIndent + 2;
231
+ lines.splice(insertAt, 0, ...rawFragment.map((line) => ' '.repeat(itemIndent) + line));
232
+ }
233
+ }
234
+ }
235
+
236
+ // §7.6 splice — append extra top-level keys under the preserved marker.
237
+ if (report.preservedTopLevelKeys.length > 0) {
238
+ const fragment = YAML.stringify(extraTopLevel).trimEnd().split('\n');
239
+ let end = lines.length;
240
+ while (end > 0 && lines[end - 1].trim() === '') end -= 1;
241
+ lines.splice(end, lines.length - end, '', PRESERVED_MARKER, ...fragment, '');
242
+ }
243
+
244
+ const text = lines.join('\n');
245
+ // "migrated" = the merge changed anything relative to what is on disk —
246
+ // includes the approval-surface rewrite itself (legacy approvalMode, prompt
247
+ // values) even when no user region needed splicing.
248
+ report.migrated = text !== existingText;
249
+
250
+ return { text, report };
251
+ }
@@ -310,6 +310,17 @@ export async function runInstallPipeline({
310
310
  projectRoot: pathConfig.projectRoot,
311
311
  });
312
312
 
313
+ // SPEC §7 step 9: surface the .omp/config.yml unattended migration result.
314
+ for (const entry of diffResults) {
315
+ const report = entry?.migrationReport;
316
+ if (!report?.migrated) continue;
317
+ const removed = (report.strippedPromptApprovals?.length ?? 0) + (report.coercedPromptPatterns?.length ?? 0);
318
+ const preserved = (report.preservedModelRoles ? 1 : 0)
319
+ + (report.preservedTopLevelKeys?.length ?? 0)
320
+ + (report.preservedPatterns?.length ?? 0);
321
+ console.log(`[UKit] .omp/config.yml: migrated approval surface (prompt→removed: ${removed}, preserved: ${preserved})`);
322
+ }
323
+
313
324
  // BUG-C22-19: bound the .bak accumulation — every overwrite_with_backup write
314
325
  // adds one and nothing removed them. Advisory: a prune failure must never
315
326
  // fail the install.
@@ -12,6 +12,10 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
12
12
  // config.json naming it must not become invalid.
13
13
  const VALID_AGENTS = new Set(['claude-code', 'codex', 'antigravity', 'opencode', 'omp']);
14
14
 
15
+ // Reserved permission modes for orchestration.permissionMode (single source of truth).
16
+ // 'unattended' is the only mode implemented this cycle; interactive/safe-auto are reserved.
17
+ export const VALID_PERMISSION_MODES = new Set(['interactive', 'safe-auto', 'unattended']);
18
+
15
19
  const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
16
20
 
17
21
  function isPlainObject(value) {
@@ -137,14 +141,17 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
137
141
  enabled: true,
138
142
  orchestratorModel: 'claude-sonnet-5',
139
143
  advisorEnabled: true,
144
+ permissionMode: 'unattended',
140
145
  contracts: buildConfigContracts(),
141
146
  modelTiers: {
142
- lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite' },
143
- code: { claudeModel: 'claude-sonnet-5', genericModel: 'unic-code' },
144
- smart: { claudeModel: 'claude-opus-5', genericModel: 'unic-smart' },
147
+ // ompRole is the .omp/config.yml modelRoles key this tier binds to.
148
+ lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite', ompRole: 'smol' },
149
+ code: { claudeModel: 'claude-sonnet-5', genericModel: 'unic-code', ompRole: 'default' },
150
+ smart: { claudeModel: 'claude-opus-5', genericModel: 'unic-smart', ompRole: 'slow' },
145
151
  vision: {
146
152
  claudeModel: 'unic-vision',
147
153
  genericModel: 'unic-vision',
154
+ ompRole: 'vision',
148
155
  fallbackModel: 'claude-sonnet-5',
149
156
  capabilityTier: true,
150
157
  note: 'Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off.',
@@ -393,6 +400,12 @@ export function validateRuntimeConfig(config) {
393
400
  pushBooleanError(errors, config.orchestration.enabled, 'orchestration.enabled');
394
401
  pushNonEmptyStringError(errors, config.orchestration.orchestratorModel, 'orchestration.orchestratorModel');
395
402
  pushBooleanError(errors, config.orchestration.advisorEnabled, 'orchestration.advisorEnabled');
403
+ // permissionMode is optional-present: absent → tolerated (pre-C35 configs);
404
+ // present → must be a reserved mode.
405
+ if (config.orchestration.permissionMode !== undefined
406
+ && !VALID_PERMISSION_MODES.has(config.orchestration.permissionMode)) {
407
+ errors.push(`orchestration.permissionMode must be one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`);
408
+ }
396
409
  if (!isPlainObject(config.orchestration.contracts)) {
397
410
  errors.push('orchestration.contracts must be an object.');
398
411
  }
@@ -0,0 +1,227 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { execFile } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parse } from 'yaml';
6
+ import { pathExists, readJsonIfExists } from './fileOps.js';
7
+ import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
8
+
9
+ // TASK-011 / SPEC §8 — unattended-mode doctor checks (FR-007).
10
+ // Seven checks, each returning the projectChecks remediation-class shape:
11
+ // { label, passed, applicable?, failed, remediationClass, remedy, detail? }
12
+ // remediationClass ∈ install-repairable | owner-action | advisory.
13
+
14
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
15
+ const INSTALL_REPAIR = 'Run ukit install';
16
+ const OMP_CONFIG_TIMEOUT_MS = 5000;
17
+ const COMPLETION_LOOP_HEADER = '## Unattended Completion Loop';
18
+
19
+ async function readOmpConfig(projectRoot) {
20
+ const configPath = path.join(projectRoot, '.omp', 'config.yml');
21
+ try {
22
+ const text = await fs.readFile(configPath, 'utf8');
23
+ const parsed = parse(text);
24
+ return { exists: true, parsed: parsed && typeof parsed === 'object' ? parsed : {}, parseError: null };
25
+ } catch (error) {
26
+ if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
27
+ return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
28
+ }
29
+ }
30
+
31
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
32
+
33
+ function execOmpConfigGet(projectRoot, ompPath) {
34
+ return new Promise((resolve) => {
35
+ execFile(
36
+ ompPath,
37
+ ['config', 'get', 'tools.approvalMode'],
38
+ { cwd: projectRoot, timeout: OMP_CONFIG_TIMEOUT_MS },
39
+ (error, stdout) => {
40
+ if (error) {
41
+ resolve({ ok: false, value: null });
42
+ return;
43
+ }
44
+ const value = String(stdout ?? '').replace(ANSI_PATTERN, '').trim();
45
+ resolve({ ok: value.length > 0, value: value || null });
46
+ },
47
+ );
48
+ });
49
+ }
50
+
51
+ async function readTemplateDenyMatches() {
52
+ try {
53
+ const text = await fs.readFile(path.join(PACKAGE_ROOT, 'templates', '.omp', 'config.yml'), 'utf8');
54
+ const parsed = parse(text);
55
+ const patterns = parsed?.bash?.patterns;
56
+ if (!Array.isArray(patterns)) return [];
57
+ return patterns
58
+ .filter((entry) => entry && entry.approval === 'deny' && typeof entry.match === 'string')
59
+ .map((entry) => entry.match);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
66
+ const runtimeConfig = await readJsonIfExists(
67
+ path.join(projectRoot, '.ukit', 'storage', 'config.json'),
68
+ );
69
+ const permissionModeRaw = runtimeConfig?.orchestration?.permissionMode;
70
+ const permissionModeValid = permissionModeRaw === undefined
71
+ || VALID_PERMISSION_MODES.has(permissionModeRaw);
72
+ const permissionMode = permissionModeRaw === undefined ? 'unattended' : permissionModeRaw;
73
+
74
+ const omp = await readOmpConfig(projectRoot);
75
+ const tools = omp.parsed?.tools ?? {};
76
+ const approvalMap = tools.approval && typeof tools.approval === 'object' ? tools.approval : {};
77
+ const bashPatterns = Array.isArray(omp.parsed?.bash?.patterns) ? omp.parsed.bash.patterns : [];
78
+
79
+ // Check 2 — effective approvalMode: `omp config get` first (5s timeout), YAML fallback.
80
+ let effectiveApprovalMode = null;
81
+ let approvalSource = null;
82
+ if (omp.exists) {
83
+ const ompResult = await execOmpConfigGet(projectRoot, ompPath);
84
+ if (ompResult.ok) {
85
+ effectiveApprovalMode = ompResult.value;
86
+ approvalSource = 'omp config get';
87
+ } else if (tools.approvalMode !== undefined) {
88
+ effectiveApprovalMode = tools.approvalMode ?? null;
89
+ approvalSource = '.omp/config.yml (omp binary unavailable)';
90
+ }
91
+ }
92
+
93
+ const promptValues = [tools.approvalMode, ...Object.values(approvalMap)];
94
+ for (const entry of bashPatterns) {
95
+ if (entry && typeof entry === 'object' && 'approval' in entry) promptValues.push(entry.approval);
96
+ }
97
+ const promptPolicyCount = promptValues.filter((v) => v === 'prompt').length;
98
+
99
+ const bridgePath = path.join(projectRoot, '.omp', 'hooks', 'pre', 'ukit-bridge.js');
100
+ const bridgeExists = await pathExists(bridgePath);
101
+ const requiredDenyMatches = await readTemplateDenyMatches();
102
+ const installedDenyMatches = new Set(
103
+ bashPatterns
104
+ .filter((entry) => entry && entry.approval === 'deny' && typeof entry.match === 'string')
105
+ .map((entry) => entry.match),
106
+ );
107
+ const missingDenyMatches = requiredDenyMatches.filter((m) => !installedDenyMatches.has(m));
108
+ const guardState = !bridgeExists
109
+ ? 'degraded'
110
+ : (missingDenyMatches.length > 0 ? 'partial' : 'enabled');
111
+
112
+ let loopGuidanceInstalled = false;
113
+ let instructionDocExists = false;
114
+ for (const docName of ['CLAUDE.md', 'AGENTS.md']) {
115
+ try {
116
+ const text = await fs.readFile(path.join(projectRoot, docName), 'utf8');
117
+ instructionDocExists = true;
118
+ if (text.includes(COMPLETION_LOOP_HEADER)) {
119
+ loopGuidanceInstalled = true;
120
+ break;
121
+ }
122
+ } catch {
123
+ // file may not exist
124
+ }
125
+ }
126
+
127
+ const check = (label, passed, remediationClass, remedy, extra = {}) => ({
128
+ label,
129
+ passed,
130
+ failed: !passed,
131
+ remediationClass,
132
+ remedy,
133
+ ...extra,
134
+ });
135
+
136
+ const checks = [
137
+ // 1 — permissionMode declared
138
+ check(
139
+ '`permissionMode` declared',
140
+ permissionModeValid,
141
+ 'owner-action',
142
+ `Set orchestration.permissionMode in .ukit/storage/config.json to one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`,
143
+ permissionModeRaw === undefined ? { detail: 'unattended (default)' } : {},
144
+ ),
145
+ // 2 — effective approvalMode is yolo
146
+ effectiveApprovalMode === null
147
+ ? check(
148
+ 'effective approvalMode is `yolo`',
149
+ false,
150
+ 'install-repairable',
151
+ INSTALL_REPAIR,
152
+ {
153
+ applicable: false,
154
+ detail: omp.exists
155
+ ? 'no tools.approvalMode readable (omp unavailable and YAML value absent)'
156
+ : 'no .omp/config.yml project override',
157
+ },
158
+ )
159
+ : check(
160
+ 'effective approvalMode is `yolo`',
161
+ effectiveApprovalMode === 'yolo',
162
+ 'install-repairable',
163
+ 'Run ukit install to pin tools.approvalMode: yolo in .omp/config.yml.',
164
+ { detail: `value: ${effectiveApprovalMode} (source: ${approvalSource})` },
165
+ ),
166
+ // 3 — eval/bash policy allow
167
+ check(
168
+ "eval/bash policy 'allow'",
169
+ omp.exists && approvalMap.eval === 'allow' && approvalMap.bash === 'allow',
170
+ 'install-repairable',
171
+ 'Run ukit install to restore tools.approval.eval/bash: allow.',
172
+ omp.exists
173
+ ? {}
174
+ : { applicable: false, detail: '.omp/config.yml absent' },
175
+ ),
176
+ // 4 — zero prompt policies
177
+ check(
178
+ 'zero `prompt` policies',
179
+ promptPolicyCount === 0,
180
+ 'install-repairable',
181
+ 'Run ukit install to strip `prompt` approvals (unattended mode cannot surface prompts).',
182
+ {
183
+ ...(omp.exists ? {} : { applicable: false, detail: '.omp/config.yml absent' }),
184
+ detail: `${promptPolicyCount} prompt policy(ies)`,
185
+ },
186
+ ),
187
+ // 5 — project override active
188
+ check(
189
+ 'project override active',
190
+ omp.exists,
191
+ 'advisory',
192
+ 'Run ukit install to materialize the project .omp/config.yml override.',
193
+ omp.exists ? {} : { applicable: false, detail: '.omp/config.yml absent' },
194
+ ),
195
+ // 6 — completion loop guidance installed
196
+ check(
197
+ 'completion loop guidance installed',
198
+ loopGuidanceInstalled,
199
+ 'install-repairable',
200
+ `Run ukit install to add the '${COMPLETION_LOOP_HEADER}' section to CLAUDE.md/AGENTS.md.`,
201
+ instructionDocExists ? {} : { applicable: false, detail: 'no CLAUDE.md/AGENTS.md' },
202
+ ),
203
+ // 7 — safety guard enabled
204
+ check(
205
+ 'safety guard enabled',
206
+ guardState === 'enabled',
207
+ 'advisory',
208
+ !bridgeExists
209
+ ? 'Restore .omp/hooks/pre/ukit-bridge.js (run ukit install); guard is degraded.'
210
+ : `Add the missing deny patterns to .omp/config.yml bash.patterns (${missingDenyMatches.length} missing).`,
211
+ {
212
+ ...(omp.exists || bridgeExists ? {} : { applicable: false }),
213
+ detail: `guard state: ${guardState}${missingDenyMatches.length > 0 ? ` (${missingDenyMatches.length} deny pattern(s) missing)` : ''}`,
214
+ },
215
+ ),
216
+ ];
217
+
218
+ return {
219
+ checks,
220
+ summary: {
221
+ permissionMode,
222
+ effectiveApprovalMode,
223
+ promptPolicyCount,
224
+ guardState,
225
+ },
226
+ };
227
+ }
@@ -6,7 +6,7 @@
6
6
  - Read/understand → lite model (haiku · unic-lite · cheapest available)
7
7
  - Write plan + tasks → strong model (Opus · unic-smart · strongest available)
8
8
 
9
- > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@lite` / `@code` / `@smart`.
9
+ > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
10
10
 
11
11
  ## Problem / feature
12
12
  $ARGUMENTS
@@ -13,9 +13,9 @@
13
13
 
14
14
  > Claude Code: spawn `ukit-small-task-maintainer` (haiku) for light steps; spawn `handoff-planner` (opus) for planning; spawn `feature-implementer` (sonnet) agents for implementation; spawn `code-reviewer` (opus) per task for review.
15
15
  >
16
- > omp: same four agents, launched with the `task` tool (`agent: "<name>"`). They live in `.omp/agents/` and bind their tier through `model: "@lite"` / `"@code"` / `"@smart"`, resolved via `modelRoles` in `.omp/config.yml` — you do not pass a model name.
16
+ > omp: same four agents, launched with the `task` tool (`agent: "<name>"`). They live in `.omp/agents/` and bind their tier through `model: "@smol"` / `"@default"` / `"@slow"`, resolved via `modelRoles` in `.omp/config.yml` — you do not pass a model name.
17
17
 
18
- > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@lite` / `@code` / `@smart`.
18
+ > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
19
19
 
20
20
  ---
21
21
 
@@ -4,7 +4,7 @@
4
4
  **Tool: any** (Claude Code / Codex / omp / Kilo / OpenCode — your choice)
5
5
  **Model: code model** (Sonnet · unic-code · cheap-smart)
6
6
 
7
- > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@lite` / `@code` / `@smart`.
7
+ > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
8
8
 
9
9
  ## Target (optional)
10
10
  $ARGUMENTS
@@ -4,7 +4,7 @@
4
4
  **Tool: any** (Claude Code / Codex / omp / Kilo / OpenCode — your choice)
5
5
  **Model: strong model, MUST differ from executor** (Opus · unic-smart · strongest available)
6
6
 
7
- > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@lite` / `@code` / `@smart`.
7
+ > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
8
8
 
9
9
  ## Target (optional)
10
10
  $ARGUMENTS