@ngockhoale/ukit 3.4.27 → 3.4.28
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/CHANGELOG.md +18 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from './paths.js';
|
|
5
|
+
import { getFileOutline, queryCodeIndex } from './queryIndex.js';
|
|
6
|
+
import { inferRelatedTests } from './relatedTests.js';
|
|
7
|
+
import { discoverProjectFiles } from './buildIndex.js';
|
|
8
|
+
import { isSensitivePath } from './sensitivePath.js';
|
|
9
|
+
|
|
10
|
+
// TASK-B2 (PLAN-ENDUR-B §B2): deterministic, bounded context pack built from the
|
|
11
|
+
// existing index artifacts. No LLM, no network, no clock/random — the same
|
|
12
|
+
// inputs + the same index always serialize to byte-identical JSON.
|
|
13
|
+
//
|
|
14
|
+
// Contract notes (recorded in TASK-B2 Discussion):
|
|
15
|
+
// - `overBudget` (global constraint "flags overBudget instead of dropping a
|
|
16
|
+
// required item"): required entries (targets) are always admitted; if the
|
|
17
|
+
// admitted set alone violates maxBytes/maxFiles the pack says so honestly
|
|
18
|
+
// instead of silently dropping a target. `truncated` marks extras
|
|
19
|
+
// (related tests) dropped for budget — the pack then never exceeds the bounds.
|
|
20
|
+
// - `fallback: 'path-only'` is set when the index artifacts are missing/corrupt
|
|
21
|
+
// (whole pack is raw target paths) OR when any individual target could not be
|
|
22
|
+
// resolved through the index (that target is listed as a raw path).
|
|
23
|
+
// - `index` accepts preloaded `{ meta, files }` artifacts to override the disk
|
|
24
|
+
// reads used for targeting/fingerprint; symbol outlines still come from the
|
|
25
|
+
// on-disk symbols artifact via getFileOutline.
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_MAX_BYTES = 12000;
|
|
28
|
+
export const DEFAULT_MAX_FILES = 8;
|
|
29
|
+
|
|
30
|
+
const TARGET_SCORE = 1000;
|
|
31
|
+
const RELATED_TEST_SCORE = 12;
|
|
32
|
+
const SYMBOL_QUERY_LIMIT = 3;
|
|
33
|
+
const RELATED_TEST_LIMIT = 6;
|
|
34
|
+
|
|
35
|
+
const REQUIRED_REASONS = new Set(['target', 'target-symbol', 'unresolved-target', 'path-only']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a deterministic, bounded context pack from the existing index.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {string} [options.rootDir] Project root (default: cwd).
|
|
42
|
+
* @param {string[]} [options.targets] Target paths or symbol names.
|
|
43
|
+
* @param {number} [options.maxBytes] Byte budget for the files payload (default 12000).
|
|
44
|
+
* @param {number} [options.maxFiles] File-count budget (default 8).
|
|
45
|
+
* @param {{meta?: object, files?: object}} [options.index] Preloaded meta/files artifacts.
|
|
46
|
+
* @returns {Promise<{ok: boolean, files: Array<{path: string, reason: string, outline: Array, bytes: number}>,
|
|
47
|
+
* truncated: boolean, overBudget: boolean, stale: boolean, fallback: 'path-only'|null,
|
|
48
|
+
* bytes: number, fingerprint: {fileCount: number, newestMtimeMs: number, digest: string|null}|null}>}
|
|
49
|
+
* Never throws: every failure mode degrades to `ok: true` with a degraded pack.
|
|
50
|
+
*/
|
|
51
|
+
export async function buildContextPack({
|
|
52
|
+
rootDir = process.cwd(),
|
|
53
|
+
targets = [],
|
|
54
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
55
|
+
maxFiles = DEFAULT_MAX_FILES,
|
|
56
|
+
index = null,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
59
|
+
const byteBudget = normalizeBudget(maxBytes, DEFAULT_MAX_BYTES);
|
|
60
|
+
const fileBudget = normalizeBudget(maxFiles, DEFAULT_MAX_FILES);
|
|
61
|
+
const requestedTargets = normalizeTargets(targets);
|
|
62
|
+
|
|
63
|
+
const [filesArtifact, metaArtifact] = await Promise.all([
|
|
64
|
+
index?.files ?? readJsonArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
|
|
65
|
+
index?.meta ?? readJsonArtifact(absoluteRoot, INDEX_ARTIFACTS.meta),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const indexUsable = isValidItemArtifact(filesArtifact) && isValidSchemaArtifact(metaArtifact);
|
|
69
|
+
const recordedFingerprint = normalizeRecordedFingerprint(metaArtifact?.sourceFingerprint);
|
|
70
|
+
|
|
71
|
+
// Staleness: compare meta.sourceFingerprint's file count / newest mtime to a
|
|
72
|
+
// fresh discovery enumeration. No auto refresh, no clock — pure comparison.
|
|
73
|
+
let stale = false;
|
|
74
|
+
if (indexUsable) {
|
|
75
|
+
if (!recordedFingerprint) {
|
|
76
|
+
stale = true;
|
|
77
|
+
} else {
|
|
78
|
+
const discovered = await discoverProjectFiles(absoluteRoot).catch(() => null);
|
|
79
|
+
stale = discovered
|
|
80
|
+
? isFingerprintStale(recordedFingerprint, fingerprintMetaFromEntries(absoluteRoot, discovered))
|
|
81
|
+
: true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const candidates = [];
|
|
86
|
+
let anyUnresolved = false;
|
|
87
|
+
|
|
88
|
+
if (indexUsable) {
|
|
89
|
+
const indexedFileSet = new Set(
|
|
90
|
+
filesArtifact.items
|
|
91
|
+
.filter((item) => item && typeof item === 'object' && typeof item.filePath === 'string')
|
|
92
|
+
.map((item) => item.filePath),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let rank = 0;
|
|
96
|
+
const resolvedTargetPaths = [];
|
|
97
|
+
for (const target of requestedTargets) {
|
|
98
|
+
if (isSensitivePath(target)) {
|
|
99
|
+
rank += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const score = Math.max(1, TARGET_SCORE - rank);
|
|
103
|
+
rank += 1;
|
|
104
|
+
|
|
105
|
+
const directPath = normalizeTargetPath(target);
|
|
106
|
+
if (indexedFileSet.has(directPath)) {
|
|
107
|
+
candidates.push({ path: directPath, reason: 'target', score });
|
|
108
|
+
resolvedTargetPaths.push(directPath);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const queryHits = await queryCodeIndex({ rootDir: absoluteRoot, query: target, limit: SYMBOL_QUERY_LIMIT })
|
|
113
|
+
.catch(() => []);
|
|
114
|
+
const bestHit = queryHits.find((hit) => hit?.filePath && !isSensitivePath(hit.filePath));
|
|
115
|
+
if (bestHit) {
|
|
116
|
+
candidates.push({ path: bestHit.filePath, reason: 'target-symbol', score });
|
|
117
|
+
resolvedTargetPaths.push(bestHit.filePath);
|
|
118
|
+
} else {
|
|
119
|
+
anyUnresolved = true;
|
|
120
|
+
candidates.push({ path: directPath, reason: 'unresolved-target', score });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const relatedTests = resolvedTargetPaths.length > 0
|
|
125
|
+
? await inferRelatedTests({ rootDir: absoluteRoot, candidateFiles: resolvedTargetPaths, limit: RELATED_TEST_LIMIT })
|
|
126
|
+
.catch(() => [])
|
|
127
|
+
: [];
|
|
128
|
+
for (const related of relatedTests) {
|
|
129
|
+
if (!related?.filePath || isSensitivePath(related.filePath)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
candidates.push({ path: related.filePath, reason: 'related-test', score: RELATED_TEST_SCORE });
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// Missing/corrupt index: path-only fallback listing the raw target paths.
|
|
136
|
+
anyUnresolved = requestedTargets.length > 0;
|
|
137
|
+
let rank = 0;
|
|
138
|
+
for (const target of requestedTargets) {
|
|
139
|
+
if (isSensitivePath(target)) {
|
|
140
|
+
rank += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
candidates.push({
|
|
144
|
+
path: normalizeTargetPath(target),
|
|
145
|
+
reason: 'path-only',
|
|
146
|
+
score: Math.max(1, TARGET_SCORE - rank),
|
|
147
|
+
});
|
|
148
|
+
rank += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Stable order: score desc, then path asc (plan §B2 acceptance).
|
|
153
|
+
candidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
154
|
+
|
|
155
|
+
const seenPaths = new Set();
|
|
156
|
+
const deduped = candidates.filter((candidate) => {
|
|
157
|
+
if (seenPaths.has(candidate.path)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
seenPaths.add(candidate.path);
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const withOutline = indexUsable;
|
|
165
|
+
const selected = [];
|
|
166
|
+
let usedBytes = 0;
|
|
167
|
+
for (const candidate of deduped.filter((entry) => REQUIRED_REASONS.has(entry.reason))) {
|
|
168
|
+
const entry = await materializeEntry(absoluteRoot, candidate, withOutline);
|
|
169
|
+
selected.push(entry);
|
|
170
|
+
usedBytes += entry.bytes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let truncated = false;
|
|
174
|
+
for (const candidate of deduped.filter((entry) => !REQUIRED_REASONS.has(entry.reason))) {
|
|
175
|
+
const entry = await materializeEntry(absoluteRoot, candidate, withOutline);
|
|
176
|
+
if (selected.length >= fileBudget || usedBytes + entry.bytes > byteBudget) {
|
|
177
|
+
truncated = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
selected.push(entry);
|
|
181
|
+
usedBytes += entry.bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const overBudget = selected.length > fileBudget || usedBytes > byteBudget;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
files: selected,
|
|
189
|
+
truncated,
|
|
190
|
+
overBudget,
|
|
191
|
+
stale,
|
|
192
|
+
fallback: !indexUsable || anyUnresolved ? 'path-only' : null,
|
|
193
|
+
bytes: usedBytes,
|
|
194
|
+
fingerprint: recordedFingerprint,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeBudget(value, defaultValue) {
|
|
199
|
+
const parsed = Number(value);
|
|
200
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeTargets(targets) {
|
|
204
|
+
const list = Array.isArray(targets) ? targets : [targets];
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const normalized = [];
|
|
207
|
+
for (const raw of list) {
|
|
208
|
+
const value = String(raw ?? '').trim();
|
|
209
|
+
if (!value || seen.has(value)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
seen.add(value);
|
|
213
|
+
normalized.push(value);
|
|
214
|
+
}
|
|
215
|
+
return normalized;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeTargetPath(target) {
|
|
219
|
+
return String(target ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function materializeEntry(absoluteRoot, candidate, withOutline) {
|
|
223
|
+
const outline = withOutline
|
|
224
|
+
? await getFileOutline({ rootDir: absoluteRoot, filePath: candidate.path }).catch(() => [])
|
|
225
|
+
: [];
|
|
226
|
+
const row = { path: candidate.path, reason: candidate.reason, outline, bytes: 0 };
|
|
227
|
+
row.bytes = Buffer.byteLength(JSON.stringify({ path: row.path, reason: row.reason, outline }));
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readJsonArtifact(rootDir, artifactName) {
|
|
232
|
+
try {
|
|
233
|
+
const content = await fs.readFile(getArtifactPath(rootDir, artifactName), 'utf8');
|
|
234
|
+
return JSON.parse(content);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Same gate queryIndex applies (C92-B-03): artifacts written under a different
|
|
241
|
+
// INDEX_SCHEMA_VERSION are treated as absent. Item artifacts (files.json) must
|
|
242
|
+
// carry an `items` array; meta.json has none, so schema currency is its only gate.
|
|
243
|
+
function isValidItemArtifact(artifact) {
|
|
244
|
+
return Boolean(
|
|
245
|
+
artifact
|
|
246
|
+
&& typeof artifact === 'object'
|
|
247
|
+
&& Array.isArray(artifact.items)
|
|
248
|
+
&& artifact.schemaVersion === INDEX_SCHEMA_VERSION,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isValidSchemaArtifact(artifact) {
|
|
253
|
+
return Boolean(artifact && typeof artifact === 'object' && artifact.schemaVersion === INDEX_SCHEMA_VERSION);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeRecordedFingerprint(raw) {
|
|
257
|
+
if (!raw || typeof raw !== 'object') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
fileCount: Number(raw.fileCount),
|
|
262
|
+
newestMtimeMs: Number(raw.newestMtimeMs),
|
|
263
|
+
digest: typeof raw.digest === 'string' ? raw.digest : null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isFingerprintStale(recorded, current) {
|
|
268
|
+
return recorded.fileCount !== current.fileCount || recorded.newestMtimeMs !== current.newestMtimeMs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Mirrors createSourceFingerprint's normalization (buildIndex.js) for the two
|
|
272
|
+
// fields the plan's staleness contract compares: fileCount and newestMtimeMs.
|
|
273
|
+
function fingerprintMetaFromEntries(rootDir, entries) {
|
|
274
|
+
let fileCount = 0;
|
|
275
|
+
let newestMtimeMs = 0;
|
|
276
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
277
|
+
const filePath = normalizeRelative(rootDir, entry?.absolutePath ?? '');
|
|
278
|
+
if (!filePath || filePath === '.' || filePath.startsWith('../') || path.isAbsolute(filePath)) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
fileCount += 1;
|
|
282
|
+
newestMtimeMs = Math.max(newestMtimeMs, Number(entry.mtimeMs ?? -1));
|
|
283
|
+
}
|
|
284
|
+
return { fileCount, newestMtimeMs };
|
|
285
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure (I/O-free) sensitive-path predicate shared by the context pack builder
|
|
2
|
+
// (src/index/contextPack.js) and the delegation candidate mapper
|
|
3
|
+
// (src/core/agentRuntime/context.js). One definition so the two cannot drift.
|
|
4
|
+
export function isSensitivePath(value) {
|
|
5
|
+
const normalized = String(value ?? '').replace(/\\/g, '/');
|
|
6
|
+
const segments = normalized.split('/');
|
|
7
|
+
const base = segments[segments.length - 1] ?? '';
|
|
8
|
+
if (base.toLowerCase().startsWith('.env')) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (/\.(?:pem|key|p12|pfx|jks|keystore)$/i.test(base)) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (/^(?:credentials?|\.npmrc|\.netrc|\.pypirc)(?:\.[^/]*)?$/i.test(base)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?$/i.test(base)) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return segments.includes('.ssh') || segments.includes('.aws') || segments.includes('.gnupg');
|
|
21
|
+
}
|
package/src/index/taskRouting.js
CHANGED
|
@@ -1193,6 +1193,9 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
|
1193
1193
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1194
1194
|
const residue = trimmed
|
|
1195
1195
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1196
|
+
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
1197
|
+
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
1198
|
+
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
1196
1199
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
1197
1200
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
1198
1201
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# /ukit:ci-check — Manual cross-platform CI (Windows · macOS · Linux)
|
|
2
|
+
|
|
3
|
+
**Role: ORCHESTRATOR**
|
|
4
|
+
**Tool: Bash (`git`, `gh`, `node`)**
|
|
5
|
+
**Model: code model** (sonnet · unic-code)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
GitHub Actions CI never runs by itself in this project: `cross-platform-ci.yml` is `workflow_dispatch` only. **Only the user typing `/ukit:ci-check` (run mode) approves a dispatch.** Implement, commit, push, merge, review, release and handoff NEVER invoke this command implicitly.
|
|
10
|
+
|
|
11
|
+
All work is done by one helper; run it yourself, do not ask the user to:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
node .claude/ukit/index/ci-check.mjs [status [<runId>] | run] [--ref <branch>] [--dry-run] [--explicit] [--wait] [--json]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Modes
|
|
18
|
+
|
|
19
|
+
| User typed | You run | Dispatches? |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `/ukit:ci-check` | `run --explicit --json` then `status --wait --max-wait 540` (repeat until complete) | **max 1** |
|
|
22
|
+
| `/ukit:ci-check status` | `status --wait --max-wait 540` (read-only) | never |
|
|
23
|
+
| `/ukit:ci-check --ref <branch>` | `run --explicit --ref <branch> --json` then `status --wait --max-wait 540` (repeat until complete) | **max 1** |
|
|
24
|
+
| `/ukit:ci-check --dry-run` | `run --dry-run --json` (no `--explicit`) | never |
|
|
25
|
+
|
|
26
|
+
`--explicit` is passed ONLY because the user typed the command in run mode. Never add it for status/dry-run, never from another workflow.
|
|
27
|
+
|
|
28
|
+
## Steps
|
|
29
|
+
|
|
30
|
+
1. **Run the helper once.** It performs: A. precheck (git repo/remote/branch, `gh auth`, every workflow manual-only, commit exists on origin, no duplicate/in-flight run, default-branch registration, budget guard) → B. local preflight (referenced files tracked, docs registry; cheap only, no full suite) → C. exactly one `gh workflow run`, run id written to the project runtime storage (ci-check state file) *before and right after* the dispatch.
|
|
31
|
+
2. **If it reports `BLOCKED` / `PREFLIGHT_FAILED` / `DISPATCH_FAILED`: stop and report the blockers verbatim.** No dispatch happened (or one failed). Do NOT retry, do NOT push, do NOT edit the workflow to work around it. A branch missing on origin, or a local commit not on origin, needs the user's separate approval to push. A missing default-branch registration needs the bootstrap PR (`docs/design/CI-CROSS-PLATFORM-MATRIX.md`, "Default-branch bootstrap"); propose it, never do it silently.
|
|
32
|
+
3. **Monitor** with `status --wait --max-wait 540` (one Bash call is capped at 10 min; if the report is `INCOMPLETE` with `timedOutWaiting`, call it again, never dispatch) until the run completes (long wait: say `Waiting for GitHub Actions — run <id>`). If you are interrupted, the run id is on disk: resume with `/ukit:ci-check status` — it can never dispatch.
|
|
33
|
+
4. **Report** exactly this shape (helper output already contains the data):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
CROSS_PLATFORM_CI: PASS | FAIL | INCOMPLETE
|
|
37
|
+
WINDOWS: <verdict> · stages · tests · time · job URL
|
|
38
|
+
MACOS: ...
|
|
39
|
+
LINUX: ...
|
|
40
|
+
Tests total / skipped (names) · failing stages · root cause WITH log evidence
|
|
41
|
+
Runner usage (billed-minute equivalent) · issues to fix · recommended next action
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A missing platform or an unfinished job is `INCOMPLETE`, never `PASS`. One failed platform makes the whole result `FAIL`. Root causes must quote log lines; if the logs do not show it, say so.
|
|
45
|
+
|
|
46
|
+
## Hard limits
|
|
47
|
+
|
|
48
|
+
- At most ONE dispatch per invocation; no auto-retry, no re-run of failed jobs, no second dispatch for "just one more try".
|
|
49
|
+
- Standard GitHub-hosted runners only; the budget guard fails closed (unknown runner, missing timeout, > `ciCheck.maxJobs` / `maxBilledMinutes`). Never edit `ciCheck.*` in `.ukit/storage/config.json` to get past a failed guard.
|
|
50
|
+
- Never push, merge, publish, bump versions, change billing, or enable `push` / `pull_request` / `schedule` triggers.
|
|
51
|
+
- The helper only reads/writes its own state under the project runtime storage; it never touches the real User Layer.
|
|
52
|
+
|
|
53
|
+
Works the same on Claude Code, Codex and omp (plain `node` + `gh`, no engine-specific tools).
|
|
@@ -489,7 +489,8 @@ function shellWriteHit(rawCommand) {
|
|
|
489
489
|
// fd duplication (`2>&1`, `>&2`, `>&-`) is excluded by the `(?!&)` target shape.
|
|
490
490
|
for (const m of command.matchAll(/(^|[^>])&?>{1,2}\s*(?:&(?![0-9-]))?((?!&)\S+)/g)) {
|
|
491
491
|
// `2>/dev/null;` / `2>/dev/null|` / `2>/dev/null)` glue the shell operator onto the target token.
|
|
492
|
-
|
|
492
|
+
// The target token ends at the first shell operator (`2>/dev/null||true`, `>/dev/null&&x`).
|
|
493
|
+
if (!['/dev/null', '/dev/stdout', '/dev/stderr'].includes(m[2].split(/[;&|)]/)[0])) return true;
|
|
493
494
|
}
|
|
494
495
|
return SHELL_WRITE_PATTERNS.some((re) => re.test(command));
|
|
495
496
|
}
|
|
@@ -505,10 +506,16 @@ function patchTargets(input) {
|
|
|
505
506
|
if (typeof body !== 'string') continue;
|
|
506
507
|
const lines = body.split(/\r?\n/);
|
|
507
508
|
const found = [];
|
|
508
|
-
for (
|
|
509
|
+
for (let li = 0; li < lines.length; li++) {
|
|
510
|
+
const line = lines[li];
|
|
509
511
|
let m = line.match(/^\*\*\*\s+(?:(?:Add|Update|Delete)\s+File|Move\s+to):\s*(.+?)\s*$/);
|
|
510
512
|
if (!m) m = line.match(/^diff --git\s+(?:"?a\/)?(\S+)\s+"?b\/(\S+?)"?\s*$/);
|
|
511
513
|
if (m) { found.push(...m.slice(1).filter(Boolean)); continue; }
|
|
514
|
+
// A `---`/`+++` line is a header only as an adjacent `--- a` / `+++ b` pair; a lone one is
|
|
515
|
+
// hunk content (`--- x` = deleted line `-- x`). Pairing keeps declared hunk counts from hiding a header.
|
|
516
|
+
const isOld = /^--- \S/.test(line) && /^\+\+\+ \S/.test(lines[li + 1] ?? '');
|
|
517
|
+
const isNew = /^\+\+\+ \S/.test(line) && /^--- \S/.test(lines[li - 1] ?? '');
|
|
518
|
+
if (!isOld && !isNew) continue;
|
|
512
519
|
m = line.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?([^\t]+?)\s*(?:\t.*)?$/);
|
|
513
520
|
if (m && m[1] !== '/dev/null') found.push(m[1]);
|
|
514
521
|
}
|
|
@@ -199,6 +199,19 @@ async function emitOrdinaryResume(payload, source) {
|
|
|
199
199
|
process.exit(0);
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
// Structured PAUSED (C100 P1): never inject a resume for a paused run, on any source.
|
|
203
|
+
try {
|
|
204
|
+
const mod = await import(pathToFileURL(intentPath).href);
|
|
205
|
+
if (typeof mod.runCursorPaused === 'function' && mod.runCursorPaused(text) === true) {
|
|
206
|
+
process.stdout.write([
|
|
207
|
+
'UKit note: docs/AI_HANDOFF/RUN.md has a handoff-fullstack run that is PAUSED '
|
|
208
|
+
+ `(Phase: ${phase}; Cursor: ${field('Cursor') || 'not recorded'}).`,
|
|
209
|
+
'Not resumed or spawned; only an explicit user instruction lifts the pause. RUN.md untouched.',
|
|
210
|
+
].join('\n') + '\n');
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
|
|
202
215
|
const goal = field('Goal');
|
|
203
216
|
const base = field('Base');
|
|
204
217
|
const cursor = field('Cursor');
|
|
@@ -98,6 +98,15 @@ async function record({ rawInput, projectRoot, env, signal, deadlineMs }) {
|
|
|
98
98
|
});
|
|
99
99
|
} catch { /* telemetry never blocks the receipt */ }
|
|
100
100
|
}
|
|
101
|
+
// C100 O4: advisory repeated-read note. Own try/catch inside; a failure here is a silent no-op.
|
|
102
|
+
// Dynamic import: a tree without read-advisory.mjs (pre-install) must not break receipt recording.
|
|
103
|
+
if (payload.tool_name === 'Read') {
|
|
104
|
+
try {
|
|
105
|
+
const mod = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'read-advisory.mjs')).href);
|
|
106
|
+
const advisory = mod.readAdvisoryMessage({ payload, projectRoot, env });
|
|
107
|
+
if (advisory) emitted.push(`${JSON.stringify({ systemMessage: advisory })}\n`);
|
|
108
|
+
} catch { /* advisory only */ }
|
|
109
|
+
}
|
|
101
110
|
return { code: 0, stdout: emitted.join(''), stderr: '' };
|
|
102
111
|
}
|
|
103
112
|
|
|
@@ -2118,6 +2118,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2118
2118
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
2119
2119
|
const residue = trimmed
|
|
2120
2120
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
2121
|
+
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
2122
|
+
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
2123
|
+
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
2121
2124
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
2122
2125
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
2123
2126
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|